2024-03-20 13:01:09 +02:00
|
|
|
import { RenderResultPluginAsset } from '@joplin/renderer/types';
|
2024-08-02 15:51:49 +02:00
|
|
|
import { join, dirname } from 'path';
|
2024-03-20 13:01:09 +02:00
|
|
|
|
|
|
|
type PluginAssetRecord = {
|
|
|
|
element: HTMLElement;
|
|
|
|
};
|
|
|
|
const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {};
|
|
|
|
|
2024-08-02 15:51:49 +02:00
|
|
|
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>;
|
|
|
|
}
|
|
|
|
|
2024-03-20 13:01:09 +02:00
|
|
|
// Note that this function keeps track of what's been added so as not to
|
|
|
|
// add the same CSS files multiple times.
|
2024-08-02 15:51:49 +02:00
|
|
|
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
|
2024-03-20 13:01:09 +02:00
|
|
|
if (!assets) return;
|
|
|
|
|
|
|
|
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
|
|
|
|
|
2024-08-02 15:51:49 +02:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
|
2024-03-20 13:01:09 +02:00
|
|
|
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;
|
|
|
|
|
2024-08-02 15:51:49 +02:00
|
|
|
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) {
|
2024-03-20 13:01:09 +02:00
|
|
|
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;
|