@widgetic/editor 4.0.10 → 4.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +118 -0
  2. package/dist/components/WidgetPreview.svelte +65 -27
  3. package/dist/components/WidgetPreview.svelte.d.ts +8 -0
  4. package/dist/components/editors/CompositionEditorApp.svelte +508 -0
  5. package/dist/components/editors/CompositionEditorApp.svelte.d.ts +75 -0
  6. package/dist/components/editors/PropsEditor.svelte +193 -792
  7. package/dist/components/editors/PropsEditor.svelte.d.ts +7 -0
  8. package/dist/components/ic/BrowserInputController.svelte +21 -2
  9. package/dist/components/ic/ColorPickerInputController.svelte +24 -6
  10. package/dist/components/ic/DateFieldInputController.svelte +7 -3
  11. package/dist/components/ic/DatePickerInputController.svelte +10 -4
  12. package/dist/components/ic/DropdownInputController.svelte +11 -4
  13. package/dist/components/ic/FontInputController.svelte +68 -49
  14. package/dist/components/ic/InputControllerHeader.svelte +11 -8
  15. package/dist/components/ic/NumberInputController.svelte +4 -2
  16. package/dist/components/ic/PositionInputController.svelte +14 -11
  17. package/dist/components/ic/RangeCalendarInputController.svelte +10 -4
  18. package/dist/components/ic/RoundedCornersInputController.svelte +17 -9
  19. package/dist/components/ic/TextAreaInputController.svelte +20 -7
  20. package/dist/components/ic/TextAreaPreviewInputController.svelte +19 -69
  21. package/dist/components/ic/TimeInputController.svelte +9 -3
  22. package/dist/components/ic/VectorInputController.svelte +80 -54
  23. package/dist/components/ic/shared/ItemGrid.svelte +5 -1
  24. package/dist/composition/compositionApi.d.ts +95 -0
  25. package/dist/composition/compositionApi.js +289 -0
  26. package/dist/composition/compositionEditorController.d.ts +254 -0
  27. package/dist/composition/compositionEditorController.js +734 -0
  28. package/dist/composition/compositionHelpers.d.ts +72 -0
  29. package/dist/composition/compositionHelpers.js +403 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.js +4 -0
  32. package/dist/mediaLibraryContext.d.ts +10 -0
  33. package/dist/mediaLibraryContext.js +30 -0
  34. package/dist/utils/FontManager.d.ts +21 -5
  35. package/dist/utils/FontManager.js +62 -67
  36. package/dist/utils/VectorManager.d.ts +0 -1
  37. package/dist/utils/VectorManager.js +10 -12
  38. package/dist/utils/editorStorage.d.ts +23 -0
  39. package/dist/utils/editorStorage.js +95 -0
  40. package/dist/utils/fontUtils.js +5 -3
  41. package/dist/utils/htmlSafety.d.ts +21 -0
  42. package/dist/utils/htmlSafety.js +38 -0
  43. package/dist/utils/itemReorder.d.ts +90 -0
  44. package/dist/utils/itemReorder.js +354 -0
  45. package/dist/utils/recentColors.js +4 -2
  46. package/dist/utils/richTextPreview.d.ts +38 -0
  47. package/dist/utils/richTextPreview.js +207 -0
  48. package/dist/utils/scrollActivity.d.ts +14 -0
  49. package/dist/utils/scrollActivity.js +63 -0
  50. package/dist/utils/shadowDom.d.ts +67 -0
  51. package/dist/utils/shadowDom.js +211 -0
  52. package/dist/utils/vectorUtils.js +5 -3
  53. package/dist/utils/visibilityCondition.d.ts +40 -0
  54. package/dist/utils/visibilityCondition.js +535 -0
  55. package/package.json +6 -5
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Reordering a list of items by dragging a handle.
3
+ *
4
+ * This replaces @shopify/draggable, which cannot work inside a shadow root (the editor runs in one when it is a
5
+ * module on a customer's page):
6
+ * - it listens on the `document`, where every event that comes from inside a shadow root has the shadow host as its
7
+ * target, so it never finds the list it was given and no drag ever starts;
8
+ * - the events it makes itself do not leave the shadow root, so its own listeners on the `document` never hear them;
9
+ * - it adds classes to the page's `<body>`, switches text selection off on it, appends its floating copy of the item
10
+ * to it, and leaves a screen reader region in it that stays after the editor is gone.
11
+ *
12
+ * This one listens on the list itself, keeps hold of the pointer with pointer capture (so the drag carries on over the
13
+ * preview frame and outside the editor), works out where an item would be dropped from where the items are on the
14
+ * screen, and leaves the page it runs in alone. It never moves the items itself: the list changes only when the
15
+ * caller changes the data the list is drawn from, in `onReorder`.
16
+ */
17
+ import { acquirePortalHost, releasePortalHost } from './shadowDom';
18
+ /** How far (px) the pointer must move from where it was pressed before the press counts as a drag and not a click. */
19
+ export const DRAG_START_DISTANCE_PX = 4;
20
+ /** The height (px) of the copy of the item that follows the pointer. */
21
+ export const MIRROR_HEIGHT_PX = 56;
22
+ /** How close (px) to the top or bottom edge of the scrolling area the pointer must be for the area to start scrolling. */
23
+ export const EDGE_SCROLL_ZONE_PX = 56;
24
+ /** The most (px) the scrolling area moves in one frame, reached when the pointer is at or beyond the edge. */
25
+ export const EDGE_SCROLL_MAX_STEP_PX = 18;
26
+ /** How long (ms) a click right after a drag is ignored, so letting go of the handle does not also open the item. */
27
+ const CLICK_AFTER_DRAG_GRACE_MS = 50;
28
+ /** The id of the layer, in the popover layer of the tree the list is in, that the floating copy of the item is drawn in. */
29
+ const MIRROR_LAYER_ID = 'widgetic-reorder-mirror-layer';
30
+ /** The classes put on the list and its items while one is dragged; the editor's stylesheet draws them. */
31
+ export const REORDER_CLASSES = {
32
+ /** On the list for as long as an item is being dragged. */
33
+ list: 'is-reordering',
34
+ /** On the item being dragged. */
35
+ source: 'is-reorder-source',
36
+ /** On the item the dragged one would be dropped in front of. */
37
+ dropBefore: 'is-reorder-drop-before',
38
+ /** On the last item, when the dragged one would be dropped behind it. */
39
+ dropAfter: 'is-reorder-drop-after',
40
+ /** On the floating copy of the dragged item. */
41
+ mirror: 'reorder-mirror'
42
+ };
43
+ /**
44
+ * Where a dragged item ends up when it is dropped with the pointer at `pointerY`: its index in the list once it has
45
+ * been taken out and put back. `otherItems` are the boxes of the items on the screen without the dragged one. The
46
+ * dragged item goes after every item whose middle is above the pointer.
47
+ */
48
+ export function dropIndexForPointer(otherItems, pointerY) {
49
+ let dropIndex = 0;
50
+ for (const box of otherItems) {
51
+ if (box.top + box.height / 2 < pointerY)
52
+ dropIndex += 1;
53
+ }
54
+ return dropIndex;
55
+ }
56
+ /**
57
+ * How far (px) to scroll the area that holds the list in this frame: upwards (negative) when the pointer is in the
58
+ * top `zone` of the area or above it, downwards (positive) in the bottom `zone` or below it, faster the nearer the
59
+ * edge, and 0 anywhere else.
60
+ */
61
+ export function edgeScrollStep(areaTop, areaBottom, pointerY, zone = EDGE_SCROLL_ZONE_PX, maxStep = EDGE_SCROLL_MAX_STEP_PX) {
62
+ if (!(zone > 0))
63
+ return 0;
64
+ const depthIntoTopZone = areaTop + zone - pointerY;
65
+ if (depthIntoTopZone > 0)
66
+ return -Math.round(maxStep * Math.min(1, depthIntoTopZone / zone)) || 0;
67
+ const depthIntoBottomZone = pointerY - (areaBottom - zone);
68
+ if (depthIntoBottomZone > 0)
69
+ return Math.round(maxStep * Math.min(1, depthIntoBottomZone / zone)) || 0;
70
+ return 0;
71
+ }
72
+ /**
73
+ * A copy of `items` with the item at `oldIndex` moved to `newIndex` (its index in the copy). When either index is not
74
+ * a place in the list, the copy keeps the original order.
75
+ */
76
+ export function moveListItem(items, oldIndex, newIndex) {
77
+ const reordered = [...items];
78
+ const isPlaceInList = (index) => Number.isInteger(index) && index >= 0 && index < items.length;
79
+ if (!isPlaceInList(oldIndex) || !isPlaceInList(newIndex))
80
+ return reordered;
81
+ const [movedItem] = reordered.splice(oldIndex, 1);
82
+ reordered.splice(newIndex, 0, movedItem);
83
+ return reordered;
84
+ }
85
+ /** The nearest ancestor that scrolls vertically, looking out of shadow roots too; `null` when the page itself scrolls. */
86
+ function findScrollingAncestor(start) {
87
+ let current = start.parentNode;
88
+ while (current) {
89
+ if (current instanceof HTMLElement) {
90
+ const { overflowY } = getComputedStyle(current);
91
+ if ((overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight) {
92
+ return current;
93
+ }
94
+ }
95
+ current = current.parentNode ?? (current instanceof ShadowRoot ? current.host : null);
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Svelte action that makes the direct children of a list reorderable by dragging their handle:
101
+ * `<div use:reorderableList={{ itemSelector, handleSelector, onReorder }}>`.
102
+ */
103
+ export function reorderableList(list, initialOptions) {
104
+ let options = initialOptions;
105
+ let press = null;
106
+ const view = list.ownerDocument.defaultView ?? window;
107
+ /** Runs a callback of the caller; one that throws must not leave a drag half done. */
108
+ function runCallback(name, callback) {
109
+ try {
110
+ callback();
111
+ }
112
+ catch (error) {
113
+ console.error(`[itemReorder] The ${name} callback failed:`, error);
114
+ }
115
+ }
116
+ function currentItems() {
117
+ return Array.from(list.children).filter((child) => child instanceof HTMLElement && child.matches(options.itemSelector));
118
+ }
119
+ /** The copy of the item that follows the pointer, drawn in the popover layer so no ancestor can clip it. */
120
+ function createMirror(current, itemBox) {
121
+ const mirrorLayer = acquirePortalHost(list, MIRROR_LAYER_ID);
122
+ if (!mirrorLayer)
123
+ return;
124
+ const mirror = current.item.cloneNode(true);
125
+ mirror.classList.add(REORDER_CLASSES.mirror, 'widgetic-colors', 'widgetic-radius');
126
+ // The copy is only a picture: no ids that clash with the real item's, nothing to focus or press.
127
+ mirror.removeAttribute('data-id');
128
+ mirror.querySelectorAll('[id]').forEach((element) => element.removeAttribute('id'));
129
+ mirror.setAttribute('aria-hidden', 'true');
130
+ mirror.setAttribute('inert', '');
131
+ Object.assign(mirror.style, {
132
+ position: 'fixed',
133
+ left: '0',
134
+ top: '0',
135
+ width: `${itemBox.width}px`,
136
+ height: `${MIRROR_HEIGHT_PX}px`,
137
+ overflow: 'hidden',
138
+ pointerEvents: 'none',
139
+ zIndex: '1000'
140
+ });
141
+ mirrorLayer.appendChild(mirror);
142
+ current.mirror = mirror;
143
+ current.mirrorLayer = mirrorLayer;
144
+ }
145
+ function positionMirror(current) {
146
+ if (!current.mirror)
147
+ return;
148
+ const left = current.lastX - current.grabOffsetX;
149
+ const top = current.lastY - MIRROR_HEIGHT_PX / 2;
150
+ current.mirror.style.transform = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`;
151
+ }
152
+ /** Works out where the item would be dropped, and draws a line there. */
153
+ function updateDropTarget(current) {
154
+ const items = currentItems();
155
+ const otherItems = items.filter((item) => item !== current.item);
156
+ const boxes = otherItems.map((item) => {
157
+ const { top, height } = item.getBoundingClientRect();
158
+ return { top, height };
159
+ });
160
+ current.dropIndex = dropIndexForPointer(boxes, current.lastY);
161
+ for (const item of items) {
162
+ item.classList.remove(REORDER_CLASSES.dropBefore, REORDER_CLASSES.dropAfter);
163
+ }
164
+ if (otherItems.length === 0 || current.dropIndex === current.sourceIndex)
165
+ return;
166
+ if (current.dropIndex < otherItems.length) {
167
+ otherItems[current.dropIndex].classList.add(REORDER_CLASSES.dropBefore);
168
+ }
169
+ else {
170
+ otherItems[otherItems.length - 1].classList.add(REORDER_CLASSES.dropAfter);
171
+ }
172
+ }
173
+ /** Scrolls the area that holds the list while the pointer is near its top or bottom edge. Runs every frame of a drag. */
174
+ function scrollNearEdges() {
175
+ const current = press;
176
+ if (!current?.dragging)
177
+ return;
178
+ try {
179
+ const area = current.scroller
180
+ ? current.scroller.getBoundingClientRect()
181
+ : { top: 0, bottom: view.innerHeight };
182
+ const step = edgeScrollStep(area.top, area.bottom, current.lastY);
183
+ if (step !== 0) {
184
+ if (current.scroller)
185
+ current.scroller.scrollTop += step;
186
+ else
187
+ view.scrollBy(0, step);
188
+ updateDropTarget(current);
189
+ }
190
+ }
191
+ catch (error) {
192
+ console.warn('[itemReorder] Could not scroll while dragging:', error);
193
+ }
194
+ current.scrollFrame = view.requestAnimationFrame(scrollNearEdges);
195
+ }
196
+ function beginDragging(current) {
197
+ current.dragging = true;
198
+ current.sourceIndex = currentItems().indexOf(current.item);
199
+ current.dropIndex = current.sourceIndex;
200
+ const itemBox = current.item.getBoundingClientRect();
201
+ current.grabOffsetX = Math.min(Math.max(current.startX - itemBox.left, 0), itemBox.width);
202
+ current.scroller = findScrollingAncestor(list);
203
+ // The copy is made first, so it does not carry the "this is the one being dragged" class.
204
+ createMirror(current, itemBox);
205
+ list.classList.add(REORDER_CLASSES.list);
206
+ current.item.classList.add(REORDER_CLASSES.source);
207
+ runCallback('onDragStart', () => options.onDragStart?.({ item: current.item, index: current.sourceIndex }));
208
+ current.scrollFrame = view.requestAnimationFrame(scrollNearEdges);
209
+ }
210
+ /** Lets a click right after a drag pass unseen: it would open or close the item the handle belongs to. */
211
+ function ignoreClickAfterDrag() {
212
+ const ignore = (event) => {
213
+ event.stopPropagation();
214
+ event.preventDefault();
215
+ };
216
+ list.addEventListener('click', ignore, { capture: true, once: true });
217
+ view.setTimeout(() => list.removeEventListener('click', ignore, { capture: true }), CLICK_AFTER_DRAG_GRACE_MS);
218
+ }
219
+ /** Ends the press, whether the item was dropped or the drag was cancelled, and puts everything back the way it was. */
220
+ function endPress(outcome) {
221
+ const current = press;
222
+ if (!current)
223
+ return;
224
+ // First of all: whatever happens below, this press is over and nothing can end it a second time.
225
+ press = null;
226
+ view.removeEventListener('pointermove', handlePointerMove);
227
+ view.removeEventListener('pointerup', handlePointerUp);
228
+ view.removeEventListener('pointercancel', handlePointerCancel);
229
+ view.removeEventListener('keydown', handleKeyDown, true);
230
+ if (current.scrollFrame !== null)
231
+ view.cancelAnimationFrame(current.scrollFrame);
232
+ try {
233
+ current.handle.releasePointerCapture(current.pointerId);
234
+ }
235
+ catch {
236
+ // The pointer was not captured any more (the handle left the page, or the browser let go): nothing to release.
237
+ }
238
+ if (!current.dragging)
239
+ return;
240
+ list.classList.remove(REORDER_CLASSES.list);
241
+ current.item.classList.remove(REORDER_CLASSES.source);
242
+ for (const item of currentItems()) {
243
+ item.classList.remove(REORDER_CLASSES.dropBefore, REORDER_CLASSES.dropAfter);
244
+ }
245
+ current.mirror?.remove();
246
+ releasePortalHost(current.mirrorLayer);
247
+ const hasMoved = outcome === 'dropped' && current.dropIndex !== current.sourceIndex;
248
+ ignoreClickAfterDrag();
249
+ if (hasMoved) {
250
+ runCallback('onReorder', () => options.onReorder({
251
+ item: current.item,
252
+ oldIndex: current.sourceIndex,
253
+ newIndex: current.dropIndex
254
+ }));
255
+ }
256
+ runCallback('onDragEnd', () => options.onDragEnd?.({ reordered: hasMoved }));
257
+ }
258
+ function handlePointerDown(event) {
259
+ try {
260
+ // One press at a time: a second finger or a second button does not start another drag.
261
+ if (press || event.button !== 0 || !event.isPrimary)
262
+ return;
263
+ const target = event.target;
264
+ if (!(target instanceof Element))
265
+ return;
266
+ const handle = target.closest(options.handleSelector);
267
+ if (!(handle instanceof HTMLElement) || !list.contains(handle))
268
+ return;
269
+ const item = handle.closest(options.itemSelector);
270
+ if (!(item instanceof HTMLElement) || item.parentElement !== list)
271
+ return;
272
+ press = {
273
+ pointerId: event.pointerId,
274
+ startX: event.clientX,
275
+ startY: event.clientY,
276
+ lastX: event.clientX,
277
+ lastY: event.clientY,
278
+ item,
279
+ handle,
280
+ dragging: false,
281
+ sourceIndex: -1,
282
+ dropIndex: -1,
283
+ grabOffsetX: 0,
284
+ mirror: null,
285
+ mirrorLayer: null,
286
+ scroller: null,
287
+ scrollFrame: null
288
+ };
289
+ // Keep getting this pointer wherever it goes: over the preview frame, outside the editor, outside the window.
290
+ try {
291
+ handle.setPointerCapture(event.pointerId);
292
+ }
293
+ catch (captureError) {
294
+ console.warn('[itemReorder] Could not capture the pointer; the drag stops at the edge of the page:', captureError);
295
+ }
296
+ view.addEventListener('pointermove', handlePointerMove);
297
+ view.addEventListener('pointerup', handlePointerUp);
298
+ view.addEventListener('pointercancel', handlePointerCancel);
299
+ view.addEventListener('keydown', handleKeyDown, true);
300
+ }
301
+ catch (error) {
302
+ console.error('[itemReorder] Could not start the press:', error);
303
+ endPress('cancelled');
304
+ }
305
+ }
306
+ function handlePointerMove(event) {
307
+ const current = press;
308
+ if (!current || event.pointerId !== current.pointerId)
309
+ return;
310
+ try {
311
+ current.lastX = event.clientX;
312
+ current.lastY = event.clientY;
313
+ if (!current.dragging) {
314
+ const distance = Math.hypot(event.clientX - current.startX, event.clientY - current.startY);
315
+ if (distance < DRAG_START_DISTANCE_PX)
316
+ return;
317
+ beginDragging(current);
318
+ }
319
+ positionMirror(current);
320
+ updateDropTarget(current);
321
+ }
322
+ catch (error) {
323
+ console.error('[itemReorder] Could not follow the pointer:', error);
324
+ endPress('cancelled');
325
+ }
326
+ }
327
+ function handlePointerUp(event) {
328
+ if (!press || event.pointerId !== press.pointerId)
329
+ return;
330
+ endPress('dropped');
331
+ }
332
+ function handlePointerCancel(event) {
333
+ if (!press || event.pointerId !== press.pointerId)
334
+ return;
335
+ endPress('cancelled');
336
+ }
337
+ function handleKeyDown(event) {
338
+ if (event.key !== 'Escape' || !press)
339
+ return;
340
+ event.preventDefault();
341
+ endPress('cancelled');
342
+ }
343
+ list.addEventListener('pointerdown', handlePointerDown);
344
+ return {
345
+ /** Svelte calls this when the options change; the callbacks of the new options are the ones used from then on. */
346
+ update(nextOptions) {
347
+ options = nextOptions;
348
+ },
349
+ destroy() {
350
+ list.removeEventListener('pointerdown', handlePointerDown);
351
+ endPress('cancelled');
352
+ }
353
+ };
354
+ }
@@ -1,9 +1,11 @@
1
+ import { readEditorStorageValue, writeEditorStorageValue } from './editorStorage';
2
+ // Stored in the editor's own corner of localStorage (see editorStorage.ts), not under a key the customer's page may use.
1
3
  const STORAGE_KEY = 'recentColors';
2
4
  const MAX_COLORS = 12;
3
5
  export const recentColorsUtil = {
4
6
  getRecentColors() {
5
7
  try {
6
- const stored = localStorage.getItem(STORAGE_KEY);
8
+ const stored = readEditorStorageValue(STORAGE_KEY);
7
9
  return stored ? JSON.parse(stored) : [];
8
10
  }
9
11
  catch (error) {
@@ -51,7 +53,7 @@ export const recentColorsUtil = {
51
53
  // Trim to max size
52
54
  colors = colors.slice(0, MAX_COLORS);
53
55
  // Save to localStorage
54
- localStorage.setItem(STORAGE_KEY, JSON.stringify(colors));
56
+ writeEditorStorageValue(STORAGE_KEY, JSON.stringify(colors));
55
57
  // console.log('Final colors list:', colors);
56
58
  return colors;
57
59
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The compact preview of a rich text (what the text area control shows before it is opened): the text with its bold,
3
+ * italic, underline, colours and so on, in a few lines.
4
+ *
5
+ * The rich text is HTML that somebody typed or pasted, so it is not trusted. The preview used to be made by copying
6
+ * the HTML it was given, tag by tag, into a string for `{@html}`: a text that said `&lt;img src=x onerror=...&gt;` came
7
+ * out as a real `<img>` that ran its script in the page the editor is on. It is now made from a description of the text
8
+ * (the `PreviewNode`s below), and everything that goes into the string is either escaped or comes from a short list of
9
+ * what is allowed: some tags, a few style properties with plain values, one class per marker.
10
+ *
11
+ * html text --parseRichText (the browser's own parser, which runs nothing)--> PreviewNode[]
12
+ * --renderPreviewNodes (plain string work, no browser needed)--> HTML that is safe for {@html}
13
+ */
14
+ /** A piece of rich text: a plain text, or an element with the few things the preview keeps of it. */
15
+ export type PreviewNode = string | PreviewElement;
16
+ export interface PreviewElement {
17
+ /** The tag name in lower case, as the HTML parser reports it. */
18
+ tag: string;
19
+ /** The element's style, as the browser reads it: `[property, value]`, property in lower case (`font-size`). */
20
+ declarations: Array<[property: string, value: string]>;
21
+ /** The element's `data-text-style` attribute, or null when it has none. */
22
+ textStyle: string | null;
23
+ children: PreviewNode[];
24
+ }
25
+ /** Whether one style declaration may go into the preview. */
26
+ export declare function isAllowedDeclaration(property: string, value: string): boolean;
27
+ /** HTML for `{@html}` from the description of a rich text. Everything in it is escaped or on the lists above. */
28
+ export declare function renderPreviewNodes(nodes: PreviewNode[]): string;
29
+ /**
30
+ * Reads rich text with the browser's HTML parser. A document made by `DOMParser` has no page behind it: scripts do
31
+ * not run, pictures are not fetched, event handlers never fire.
32
+ */
33
+ export declare function parseRichText(html: string): PreviewNode[];
34
+ /**
35
+ * The preview of a rich text, as HTML that is safe for `{@html}`. A text that arrives written out as entities
36
+ * (`&lt;strong&gt;...`) is read as the markup it spells, as it always has been.
37
+ */
38
+ export declare function createPreviewHtml(text: string): string;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The compact preview of a rich text (what the text area control shows before it is opened): the text with its bold,
3
+ * italic, underline, colours and so on, in a few lines.
4
+ *
5
+ * The rich text is HTML that somebody typed or pasted, so it is not trusted. The preview used to be made by copying
6
+ * the HTML it was given, tag by tag, into a string for `{@html}`: a text that said `&lt;img src=x onerror=...&gt;` came
7
+ * out as a real `<img>` that ran its script in the page the editor is on. It is now made from a description of the text
8
+ * (the `PreviewNode`s below), and everything that goes into the string is either escaped or comes from a short list of
9
+ * what is allowed: some tags, a few style properties with plain values, one class per marker.
10
+ *
11
+ * html text --parseRichText (the browser's own parser, which runs nothing)--> PreviewNode[]
12
+ * --renderPreviewNodes (plain string work, no browser needed)--> HTML that is safe for {@html}
13
+ */
14
+ import { escapeHtml } from './htmlSafety';
15
+ /** Elements that are shown as they are (their formatting is the point of the preview). */
16
+ const KEPT_TAGS = new Set([
17
+ 'a',
18
+ 'b',
19
+ 'code',
20
+ 'del',
21
+ 'em',
22
+ 'i',
23
+ 'ins',
24
+ 'li',
25
+ 'mark',
26
+ 'ol',
27
+ 's',
28
+ 'small',
29
+ 'span',
30
+ 'strike',
31
+ 'strong',
32
+ 'sub',
33
+ 'sup',
34
+ 'u',
35
+ 'ul'
36
+ ]);
37
+ /** Elements whose content is not text to show at all: nothing of them, not even their text, reaches the preview. */
38
+ const DROPPED_TAGS = new Set([
39
+ 'applet',
40
+ 'audio',
41
+ 'canvas',
42
+ 'embed',
43
+ 'frame',
44
+ 'frameset',
45
+ 'iframe',
46
+ 'img',
47
+ 'link',
48
+ 'math',
49
+ 'meta',
50
+ 'noscript',
51
+ 'object',
52
+ 'script',
53
+ 'style',
54
+ 'svg',
55
+ 'template',
56
+ 'textarea',
57
+ 'title',
58
+ 'video'
59
+ ]);
60
+ /** The style properties a text can carry into the preview. */
61
+ const ALLOWED_STYLE_PROPERTIES = new Set([
62
+ 'background-color',
63
+ 'color',
64
+ 'font-family',
65
+ 'font-size',
66
+ 'font-style',
67
+ 'font-weight',
68
+ 'letter-spacing',
69
+ 'line-height',
70
+ 'text-decoration',
71
+ 'text-decoration-color',
72
+ 'text-decoration-line',
73
+ 'text-transform'
74
+ ]);
75
+ /** Functions that may appear in a style value (colours and arithmetic); `url(...)`, `expression(...)`... may not. */
76
+ const ALLOWED_STYLE_FUNCTIONS = new Set([
77
+ 'calc',
78
+ 'clamp',
79
+ 'hsl',
80
+ 'hsla',
81
+ 'max',
82
+ 'min',
83
+ 'rgb',
84
+ 'rgba',
85
+ 'var'
86
+ ]);
87
+ const MAX_STYLE_VALUE_LENGTH = 200;
88
+ /** Letters, digits, spaces and the punctuation of colours, sizes and font lists. No `;`, `{`, `}`, `<`, `>`, `\`, `@`. */
89
+ const SAFE_STYLE_CHARACTERS = /^[\p{L}\p{N} #%.,+\-_'"()/*]*$/u;
90
+ /** A value for the preview's `data-text-style` attribute: a short name such as `heading-1`. */
91
+ const TEXT_STYLE_NAME = /^[A-Za-z0-9_-]{1,40}$/;
92
+ /** Whether one style declaration may go into the preview. */
93
+ export function isAllowedDeclaration(property, value) {
94
+ if (!ALLOWED_STYLE_PROPERTIES.has(property))
95
+ return false;
96
+ if (value.length === 0 || value.length > MAX_STYLE_VALUE_LENGTH)
97
+ return false;
98
+ if (!SAFE_STYLE_CHARACTERS.test(value))
99
+ return false;
100
+ // Comments can hide the words below from the checks that follow; an escape can spell them.
101
+ if (value.includes('/*') || value.includes('*/'))
102
+ return false;
103
+ for (const call of value.matchAll(/([A-Za-z-]+)\s*\(/g)) {
104
+ if (!ALLOWED_STYLE_FUNCTIONS.has(call[1].toLowerCase()))
105
+ return false;
106
+ }
107
+ return true;
108
+ }
109
+ /** The `style` attribute value (already escaped for an attribute) for the declarations that are allowed, or ''. */
110
+ function styleAttribute(declarations) {
111
+ const allowed = declarations.filter(([property, value]) => isAllowedDeclaration(property, value));
112
+ if (allowed.length === 0)
113
+ return '';
114
+ const colour = allowed.find(([property]) => property === 'color');
115
+ const parts = allowed.map(([property, value]) => `${property}: ${value}`);
116
+ // The preview's own stylesheet reads the text colour from --color.
117
+ if (colour)
118
+ parts.unshift(`--color: ${colour[1]}`);
119
+ return escapeHtml(`${parts.join('; ')};`);
120
+ }
121
+ function renderNode(node) {
122
+ if (typeof node === 'string')
123
+ return escapeHtml(node);
124
+ const tag = node.tag.toLowerCase();
125
+ if (DROPPED_TAGS.has(tag))
126
+ return '';
127
+ const children = node.children.map(renderNode).join('');
128
+ // Line breaks and paragraphs become spaces: the preview is a few lines of running text.
129
+ if (tag === 'br')
130
+ return ' ';
131
+ if (tag === 'p' || tag === 'div')
132
+ return `${children} `;
133
+ const isHeading = /^h[1-4]$/.test(tag);
134
+ if (!isHeading && !KEPT_TAGS.has(tag))
135
+ return children;
136
+ const classes = [];
137
+ const textStyle = node.textStyle !== null && TEXT_STYLE_NAME.test(node.textStyle) ? node.textStyle : null;
138
+ if (textStyle !== null)
139
+ classes.push('preserve-color');
140
+ if (isHeading)
141
+ classes.push('tiptap-heading');
142
+ const style = styleAttribute(node.declarations);
143
+ const outputTag = isHeading ? 'span' : tag;
144
+ let startTag = `<${outputTag}`;
145
+ if (style !== '')
146
+ startTag += ` style="${style}"`;
147
+ if (textStyle !== null)
148
+ startTag += ` data-text-style="${escapeHtml(textStyle)}"`;
149
+ if (classes.length > 0)
150
+ startTag += ` class="${classes.join(' ')}"`;
151
+ return `${startTag}>${children}</${outputTag}>`;
152
+ }
153
+ /** HTML for `{@html}` from the description of a rich text. Everything in it is escaped or on the lists above. */
154
+ export function renderPreviewNodes(nodes) {
155
+ return nodes.map(renderNode).join('').trim();
156
+ }
157
+ // ---- From the browser ---------------------------------------------------------------------------------------------
158
+ function toPreviewNode(node) {
159
+ if (node.nodeType === Node.TEXT_NODE)
160
+ return node.textContent ?? '';
161
+ if (node.nodeType !== Node.ELEMENT_NODE)
162
+ return null;
163
+ const element = node;
164
+ const declarations = [];
165
+ // The browser reads the style the way it would apply it: what it cannot read is already gone.
166
+ const style = element.style;
167
+ if (style) {
168
+ for (let index = 0; index < style.length; index += 1) {
169
+ const property = style.item(index);
170
+ declarations.push([property, style.getPropertyValue(property)]);
171
+ }
172
+ }
173
+ return {
174
+ tag: element.tagName.toLowerCase(),
175
+ declarations,
176
+ textStyle: element.getAttribute('data-text-style'),
177
+ children: Array.from(element.childNodes)
178
+ .map(toPreviewNode)
179
+ .filter((child) => child !== null)
180
+ };
181
+ }
182
+ /**
183
+ * Reads rich text with the browser's HTML parser. A document made by `DOMParser` has no page behind it: scripts do
184
+ * not run, pictures are not fetched, event handlers never fire.
185
+ */
186
+ export function parseRichText(html) {
187
+ const document = new DOMParser().parseFromString(html, 'text/html');
188
+ return Array.from(document.body.childNodes)
189
+ .map(toPreviewNode)
190
+ .filter((node) => node !== null);
191
+ }
192
+ /**
193
+ * The preview of a rich text, as HTML that is safe for `{@html}`. A text that arrives written out as entities
194
+ * (`&lt;strong&gt;...`) is read as the markup it spells, as it always has been.
195
+ */
196
+ export function createPreviewHtml(text) {
197
+ if (!text)
198
+ return '';
199
+ let html = text;
200
+ if (!html.includes('<') && html.includes('&lt;')) {
201
+ // A detached <textarea> only decodes entities: nothing in it is parsed as markup or run.
202
+ const decoder = document.createElement('textarea');
203
+ decoder.innerHTML = html;
204
+ html = decoder.value;
205
+ }
206
+ return renderPreviewNodes(parseRichText(html));
207
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Something that wants to know the user is scrolling (the help tooltips hide while the panel moves under them).
3
+ *
4
+ * Every control used to put its own three listeners on the window and the document, which is hundreds of global
5
+ * listeners on a customer's page and a pass over every control for each wheel tick. The listeners are shared now:
6
+ * one set per tree (the page, or the shadow root of a module), however many controls ask.
7
+ */
8
+ type ScrollActivityListener = () => void;
9
+ /**
10
+ * Calls `listener` whenever the user scrolls in the tree `anchor` lives in: the page scrolling, a panel scrolling, a
11
+ * wheel turning or a finger moving. Gives back the function that stops it; call that when the control goes away.
12
+ */
13
+ export declare function onScrollActivity(anchor: Node | null | undefined, listener: ScrollActivityListener): () => void;
14
+ export {};