1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-27 10:32:58 +02:00
joplin/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.ts
2024-08-02 14:51:49 +01:00

154 lines
4.6 KiB
TypeScript

import { RenderResultPluginAsset } from '@joplin/renderer/types';
import { join, dirname } from 'path';
type PluginAssetRecord = {
element: HTMLElement;
};
const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {};
const assetUrlMap_: Map<string, ()=> Promise<string>> = new Map();
// Some resources (e.g. CSS) reference other resources with relative paths. On web, due to sandboxing
// and how plugin assets are stored, these links need to be rewritten.
const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content: string) => {
if (asset.mime === 'text/css') {
const urlRegex = /(url\()([^)]+)(\))/g;
// Converting resource paths to URLs is async. To handle this, we do two passes.
// In the first, the original URLs are collected. In the second, the URLs are replaced.
const replacements: [string, string][] = [];
let replacementIndex = 0;
content = content.replace(urlRegex, (match, _group1, url, _group3) => {
const target = join(dirname(asset.path), url);
if (!assetUrlMap_.has(target)) return match;
const replaceString = `<<to-replace-with-url-${replacementIndex++}>>`;
replacements.push([replaceString, target]);
return `url(${replaceString})`;
});
for (const [replacement, path] of replacements) {
const url = await assetUrlMap_.get(path)();
content = content.replace(replacement, url);
}
return content;
} else {
return content;
}
};
interface Options {
inlineAssets: boolean;
readAssetBlob?(path: string): Promise<Blob>;
}
// Note that this function keeps track of what's been added so as not to
// add the same CSS files multiple times.
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
if (!assets) return;
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
const prepareAssetBlobUrls = () => {
for (const asset of assets) {
const path = asset.path;
if (!assetUrlMap_.has(path)) {
// Fetching assets can be expensive -- avoid refetching assets where possible.
let url: string|null = null;
assetUrlMap_.set(path, async () => {
if (url !== null) return url;
const blob = await options.readAssetBlob(path);
if (!blob) {
url = '';
} else {
url = URL.createObjectURL(blob);
}
return url;
});
}
}
};
if (options.inlineAssets) {
prepareAssetBlobUrls();
}
const processedAssetIds = [];
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
// # and ? can be used in valid paths and shouldn't be treated as the start of a query or fragment
const encodedPath = asset.path
.replace(/#/g, '%23')
.replace(/\?/g, '%3F');
const assetId = asset.name ? asset.name : encodedPath;
processedAssetIds.push(assetId);
if (pluginAssetsAdded_[assetId]) continue;
let element = null;
if (options.inlineAssets) {
if (asset.mime === 'application/javascript') {
element = document.createElement('script');
} else if (asset.mime === 'text/css') {
element = document.createElement('style');
}
if (element) {
const blob = await options.readAssetBlob(asset.path);
if (blob) {
const assetContent = await blob.text();
element.appendChild(
document.createTextNode(await rewriteInternalAssetLinks(asset, assetContent)),
);
}
}
} else {
if (asset.mime === 'application/javascript') {
element = document.createElement('script');
element.src = encodedPath;
} else if (asset.mime === 'text/css') {
element = document.createElement('link');
element.rel = 'stylesheet';
element.href = encodedPath;
}
}
if (element) {
pluginAssetsContainer.appendChild(element);
}
pluginAssetsAdded_[assetId] = {
element,
};
}
// Once we have added the relevant assets, we also remove those that
// are no longer needed. It's necessary in particular for the CSS
// generated by noteStyle - if we don't remove it, we might end up
// with two or more stylesheet and that will create conflicts.
//
// It was happening for example when automatically switching from
// light to dark theme, and then back to light theme - in that case
// the viewer would remain dark because it would use the dark
// stylesheet that would still be in the DOM.
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
if (!processedAssetIds.includes(assetId)) {
try {
asset.element.remove();
} catch (error) {
// We don't throw an exception but we log it since
// it shouldn't happen
console.warn('Tried to remove an asset but got an error', error);
}
pluginAssetsAdded_[assetId] = null;
}
}
};
export default addPluginAssets;