You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-23 22:36:32 +02:00
Mobile: Add a Rich Text Editor (#12748)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
container: HTMLElement;
|
||||
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 = options.container;
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user