Skip to content

Commit 000d8c7

Browse files
committed
Implement preview for editor components
1 parent 6ac5079 commit 000d8c7

3 files changed

Lines changed: 647 additions & 32 deletions

File tree

src/lib/components/contents/details/fields/rich-text/rich-text-preview.svelte

Lines changed: 197 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,53 @@
88
import { sanitize } from 'isomorphic-dompurify';
99
import { parse, use } from 'marked';
1010
import markedBidi from 'marked-bidi';
11+
import { isValidElement } from 'react';
12+
import { createRoot } from 'react-dom/client';
13+
import { onMount } from 'svelte';
14+
import { SvelteMap } from 'svelte/reactivity';
1115
1216
import { getMediaFieldURL } from '$lib/services/assets/info';
1317
import { entryDraft } from '$lib/services/contents/draft';
14-
import { GLOBAL_IMAGE_REGEX } from '$lib/services/contents/fields/rich-text/constants';
15-
import { encodeImageSrc } from '$lib/services/contents/fields/rich-text/helper';
18+
import { BUILTIN_COMPONENTS } from '$lib/services/contents/fields/rich-text';
19+
import {
20+
customComponentRegistry,
21+
getComponentDef,
22+
} from '$lib/services/contents/fields/rich-text/components/definitions';
23+
import {
24+
buildMarkdownWithPreviews,
25+
COMPONENT_QUERY_SELECTOR,
26+
inlineStringPreviews,
27+
SANITIZE_OPTIONS,
28+
splitMarkdownBlocks,
29+
} from '$lib/services/contents/fields/rich-text/helper';
30+
31+
use(markedBidi());
32+
33+
use({
34+
renderer: {
35+
// Add syntax highlighting for code blocks using Prism.js if available. This is done in the
36+
// renderer to ensure it runs before sanitization, allowing the highlighted HTML to be
37+
// preserved in the preview.
38+
// eslint-disable-next-line jsdoc/require-jsdoc
39+
code({ text, lang }) {
40+
const { Prism } = /** @type {any} */ (window);
41+
42+
if (Prism && lang && Prism.languages[lang]) {
43+
const highlighted = Prism.highlight(text, Prism.languages[lang], lang);
44+
45+
return `<pre><code class="language-${lang}">${highlighted}</code></pre>\n`;
46+
}
47+
48+
return false;
49+
},
50+
},
51+
});
1652
1753
/**
54+
* @import { MarkedOptions, Token } from 'marked';
1855
* @import { FieldPreviewProps } from '$lib/types/private';
1956
* @import { MarkdownField } from '$lib/types/public';
57+
* @import { ComponentPreview } from '$lib/services/contents/fields/rich-text/helper';
2058
*/
2159
2260
/**
@@ -25,6 +63,12 @@
2563
* @property {string | undefined} currentValue Field value.
2664
*/
2765
66+
/** @type {SvelteMap<HTMLElement, import('react-dom/client').Root>} */
67+
const reactRoots = new SvelteMap();
68+
69+
/** @type {SvelteMap<string, ComponentPreview>} */
70+
let previewMap = new SvelteMap();
71+
2872
/** @type {FieldPreviewProps & Props} */
2973
let {
3074
/* eslint-disable prefer-const */
@@ -33,49 +77,163 @@
3377
/* eslint-enable prefer-const */
3478
} = $props();
3579
36-
let rawHTML = $state('');
80+
/** @type {HTMLElement | undefined} */
81+
let container = $state();
3782
3883
const entry = $derived($entryDraft?.originalEntry);
3984
const collectionName = $derived($entryDraft?.collectionName ?? '');
4085
const fileName = $derived($entryDraft?.fileName);
41-
const { sanitize_preview: doSanitize = true } = $derived(fieldConfig);
42-
const markdown = $derived((currentValue ?? '').replace(GLOBAL_IMAGE_REGEX, encodeImageSrc));
43-
44-
/** @type {import("marked").MarkedOptions} */
45-
const markedOptions = {
46-
breaks: true,
47-
async: true,
48-
// eslint-disable-next-line jsdoc/require-jsdoc
49-
walkTokens: async (token) => {
50-
if (token.type === 'image') {
51-
const url = await getMediaFieldURL({ value: token.href, entry, collectionName, fileName });
52-
53-
if (url) {
54-
token.href = url;
86+
const {
87+
sanitize_preview: doSanitize = true,
88+
editor_components:
89+
// Include all built-in and custom components by default
90+
_editorComponents = [...BUILTIN_COMPONENTS, ...customComponentRegistry.keys()],
91+
linked_images: linkedImagesEnabled = true,
92+
} = $derived(fieldConfig);
93+
const componentDefs = $derived(
94+
_editorComponents
95+
.map((name) =>
96+
getComponentDef(name === 'image' && linkedImagesEnabled ? 'linked-image' : name),
97+
)
98+
.filter((def) => !!def),
99+
);
100+
101+
const markdown = $derived.by(() => {
102+
if (typeof currentValue !== 'string' || !currentValue.trim()) {
103+
return '';
104+
}
105+
106+
const { markdown: string, previewMap: newMap } = buildMarkdownWithPreviews(
107+
currentValue,
108+
componentDefs,
109+
);
110+
111+
previewMap = /** @type {SvelteMap<string, ComponentPreview>} */ (newMap);
112+
113+
return string;
114+
});
115+
116+
/**
117+
* Render a React component preview into the specified element based on its `data-component-key`
118+
* attribute.
119+
* @param {HTMLElement} element The element to render the component preview into.
120+
*/
121+
const renderComponent = (element) => {
122+
const key = element.dataset.componentKey;
123+
const preview = key ? previewMap.get(key) : undefined;
124+
125+
if (isValidElement(preview)) {
126+
// Mount the React component
127+
const root = createRoot(element);
128+
129+
reactRoots.set(element, root);
130+
root.render(preview);
131+
} else {
132+
// Remove the placeholder if there's no valid preview to render
133+
element.remove();
134+
}
135+
};
136+
137+
/**
138+
* Unmount any React component previews that are removed from the DOM.
139+
* @param {HTMLElement} element The removed element to check for mounted React components.
140+
*/
141+
const unmountRemovedRoots = (element) => {
142+
[element, ...element.querySelectorAll(COMPONENT_QUERY_SELECTOR)].forEach((el) => {
143+
const root = reactRoots.get(/** @type {HTMLElement} */ (el));
144+
145+
if (root) {
146+
root.unmount();
147+
reactRoots.delete(/** @type {HTMLElement} */ (el));
148+
}
149+
});
150+
};
151+
152+
/**
153+
* Callback for the `MutationObserver` to detect added and removed nodes in the container. It
154+
* renders component previews for added nodes and unmounts React roots for removed nodes.
155+
* @param {MutationRecord[]} mutations The list of mutations observed.
156+
*/
157+
const mutationCallback = (mutations) => {
158+
mutations.forEach(({ removedNodes, addedNodes }) => {
159+
removedNodes.forEach((node) => {
160+
if (node.nodeType === Node.ELEMENT_NODE) {
161+
unmountRemovedRoots(/** @type {HTMLElement} */ (node));
162+
}
163+
});
164+
165+
addedNodes.forEach((node) => {
166+
if (node.nodeType !== Node.ELEMENT_NODE) return;
167+
168+
const element = /** @type {HTMLElement} */ (node);
169+
170+
if (element.matches(COMPONENT_QUERY_SELECTOR)) {
171+
renderComponent(element);
172+
} else {
173+
element.querySelectorAll(COMPONENT_QUERY_SELECTOR).forEach((el) => {
174+
renderComponent(/** @type {HTMLElement} */ (el));
175+
});
55176
}
177+
});
178+
});
179+
};
180+
181+
/**
182+
* Walk through the tokens generated by marked and replace image URLs with their media field URLs.
183+
* @param {Token} token The token to process.
184+
*/
185+
const walkTokens = async (token) => {
186+
if (token.type === 'image') {
187+
const url = await getMediaFieldURL({ value: token.href, entry, collectionName, fileName });
188+
189+
if (url) {
190+
token.href = url;
56191
}
57-
},
192+
}
58193
};
59194
60-
const sanitizeOptions = {
61-
// Allow `blob` images
62-
// @see https://github.com/cure53/DOMPurify/issues/549
63-
// @see https://github.com/cure53/DOMPurify#control-permitted-attribute-values
64-
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|blob):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
195+
/**
196+
* Marked options with the custom walkTokens function to process image URLs and enable line
197+
* breaks. The async option is set to true to allow for asynchronous token processing.
198+
* @type {MarkedOptions}
199+
*/
200+
const MARKED_OPTIONS = { breaks: true, async: true, walkTokens };
201+
202+
/**
203+
* Parse a block of markdown into HTML, replacing component placeholders with their previews and
204+
* sanitizing the result if needed.
205+
* @param {string} block The markdown block to parse.
206+
* @returns {Promise<string>} The parsed (and possibly sanitized) HTML string.
207+
*/
208+
const parseMarkdown = async (block) => {
209+
const rawHTML = await parse(block, MARKED_OPTIONS);
210+
const replacedHTML = inlineStringPreviews(rawHTML, previewMap);
211+
212+
return doSanitize ? sanitize(replacedHTML, SANITIZE_OPTIONS) : replacedHTML;
65213
};
66214
67-
use(markedBidi());
215+
onMount(() => {
216+
const observer = new MutationObserver(mutationCallback);
68217
69-
$effect(() => {
70-
(async () => {
71-
rawHTML = await parse(markdown, markedOptions);
72-
})();
218+
if (container) {
219+
observer.observe(container, { childList: true, subtree: true });
220+
}
221+
222+
return () => {
223+
observer.disconnect();
224+
reactRoots.forEach((root) => root.unmount());
225+
reactRoots.clear();
226+
};
73227
});
74228
</script>
75229
76-
<div role="none">
77-
{#if typeof currentValue === 'string' && currentValue.trim()}
78-
{@html doSanitize ? sanitize(rawHTML, sanitizeOptions) : rawHTML}
230+
<div role="none" bind:this={container}>
231+
{#if markdown}
232+
{#each splitMarkdownBlocks(markdown) as block, index (`${index}-${block}`)}
233+
{#await parseMarkdown(block) then parsedHTML}
234+
{@html parsedHTML}
235+
{/await}
236+
{/each}
79237
{/if}
80238
</div>
81239
@@ -100,4 +258,12 @@
100258
}
101259
}
102260
}
261+
262+
div {
263+
:global {
264+
[data-component-key] {
265+
display: contents;
266+
}
267+
}
268+
}
103269
</style>

0 commit comments

Comments
 (0)