tungsten 0.3.3 → 0.3.4
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.
- package/dist/.build-complete +1 -1
- package/dist/Components.d.ts +6 -1
- package/dist/Components.d.ts.map +1 -1
- package/dist/Components.js +103 -40
- package/dist/DomPointer.d.ts +11 -0
- package/dist/DomPointer.d.ts.map +1 -1
- package/dist/DomPointer.js +15 -0
- package/dist/DomRegion.d.ts +2 -0
- package/dist/DomRegion.d.ts.map +1 -1
- package/dist/DomRegion.js +23 -3
- package/dist/Framework.d.ts.map +1 -1
- package/dist/Framework.js +27 -5
- package/dist/FrameworkConfig.d.ts +2 -0
- package/dist/FrameworkConfig.d.ts.map +1 -1
- package/dist/Instance/Instance.d.ts +1 -1
- package/dist/Instance/Instance.d.ts.map +1 -1
- package/dist/Servers/DevServer.d.ts.map +1 -1
- package/dist/Servers/DevServer.js +3 -3
- package/dist/Servers/ProdServer.d.ts +1 -1
- package/dist/Servers/ProdServer.d.ts.map +1 -1
- package/dist/Servers/ProdServer.js +13 -3
- package/dist/Servers/loadEnvsDev.d.ts.map +1 -1
- package/dist/Servers/loadEnvsDev.js +16 -5
- package/dist/babelPlugins/BabelExtractComponentStylesheet.d.ts +13 -0
- package/dist/babelPlugins/BabelExtractComponentStylesheet.d.ts.map +1 -0
- package/dist/babelPlugins/BabelExtractComponentStylesheet.js +24 -0
- package/dist/babelPlugins/BabelTransformComponentStyles.d.ts +2 -0
- package/dist/babelPlugins/BabelTransformComponentStyles.d.ts.map +1 -1
- package/dist/babelPlugins/BabelTransformComponentStyles.js +21 -5
- package/dist/babelPlugins/BabelTransformPackageImports.d.ts +1 -0
- package/dist/babelPlugins/BabelTransformPackageImports.d.ts.map +1 -1
- package/dist/babelPlugins/BabelTransformPackageImports.js +21 -9
- package/dist/bin/commands/build.d.ts.map +1 -1
- package/dist/bin/commands/build.js +9 -2
- package/dist/bin/commands/compile.d.ts.map +1 -1
- package/dist/bin/commands/compile.js +7 -2
- package/dist/build/CompilationInfo.d.ts +1 -0
- package/dist/build/CompilationInfo.d.ts.map +1 -1
- package/dist/build/combineComponentCss.d.ts +1 -1
- package/dist/build/combineComponentCss.d.ts.map +1 -1
- package/dist/build/combineComponentCss.js +3 -3
- package/dist/build/compileJavascript.d.ts.map +1 -1
- package/dist/build/compileJavascript.js +2 -2
- package/dist/build/compileUserApp.d.ts.map +1 -1
- package/dist/build/compileUserApp.js +14 -5
- package/dist/build/frameworkStylesheets.d.ts +5 -0
- package/dist/build/frameworkStylesheets.d.ts.map +1 -0
- package/dist/build/frameworkStylesheets.js +49 -0
- package/dist/build/getPackageDirForFile.d.ts +2 -0
- package/dist/build/getPackageDirForFile.d.ts.map +1 -0
- package/dist/build/getPackageDirForFile.js +11 -0
- package/dist/build/getSxyClientPackageAliases.d.ts.map +1 -1
- package/dist/build/getSxyClientPackageAliases.js +3 -1
- package/dist/build/makeBabelUserDevConfig.d.ts +1 -1
- package/dist/build/makeBabelUserDevConfig.d.ts.map +1 -1
- package/dist/build/makeBabelUserDevConfig.js +3 -2
- package/dist/build/makeBabelUserProdConfig.d.ts +1 -1
- package/dist/build/makeBabelUserProdConfig.d.ts.map +1 -1
- package/dist/build/makeBabelUserProdConfig.js +4 -2
- package/dist/build/rollup.user.prod.config.d.ts.map +1 -1
- package/dist/build/rollup.user.prod.config.js +4 -1
- package/dist/build/rollupExtractComponentStylesheets.d.ts +12 -0
- package/dist/build/rollupExtractComponentStylesheets.d.ts.map +1 -0
- package/dist/build/rollupExtractComponentStylesheets.js +35 -0
- package/dist/compat/context.d.ts +1 -1
- package/dist/compat/context.d.ts.map +1 -1
- package/dist/componentCss/i/T-components-library-RichTextEditor-RichTextEditor.css +115 -0
- package/dist/componentCss/i/T-components-library-__StatefulMessage-StatefulMessage.css +25 -0
- package/dist/componentCss/tungsten.css +2 -0
- package/dist/componentProxyHandlers.d.ts +2 -1
- package/dist/componentProxyHandlers.d.ts.map +1 -1
- package/dist/componentProxyHandlers.js +45 -9
- package/dist/components/GoToComponent.d.ts +1 -1
- package/dist/components/GoToComponent.d.ts.map +1 -1
- package/dist/components/_GoToComponent.d.ts +10 -0
- package/dist/components/_GoToComponent.d.ts.map +1 -0
- package/dist/components/_GoToComponent.js +13 -0
- package/dist/components/library/RichTextEditor/RichTextEditor.d.ts +50 -0
- package/dist/components/library/RichTextEditor/RichTextEditor.d.ts.map +1 -0
- package/dist/components/library/RichTextEditor/RichTextEditor.js +458 -0
- package/dist/components/library/RichTextEditor/StoredContentNode.d.ts +5 -0
- package/dist/components/library/RichTextEditor/StoredContentNode.d.ts.map +1 -0
- package/dist/components/library/RichTextEditor/StoredContentNode.js +1 -0
- package/dist/components/library/RichTextEditor/activeFormats.d.ts +20 -0
- package/dist/components/library/RichTextEditor/activeFormats.d.ts.map +1 -0
- package/dist/components/library/RichTextEditor/activeFormats.js +48 -0
- package/dist/components/library/RichTextEditor/contentTree.d.ts +30 -0
- package/dist/components/library/RichTextEditor/contentTree.d.ts.map +1 -0
- package/dist/components/library/RichTextEditor/contentTree.js +81 -0
- package/dist/components/library/RichTextEditor/sanitizeAuthoredHtml.d.ts +30 -0
- package/dist/components/library/RichTextEditor/sanitizeAuthoredHtml.d.ts.map +1 -0
- package/dist/components/library/RichTextEditor/sanitizeAuthoredHtml.js +97 -0
- package/dist/components/library/__StatefulMessage/MessageData.d.ts +7 -0
- package/dist/components/library/__StatefulMessage/MessageData.d.ts.map +1 -0
- package/dist/components/library/__StatefulMessage/MessageData.js +1 -0
- package/dist/components/library/__StatefulMessage/StatefulMessage.d.ts +22 -0
- package/dist/components/library/__StatefulMessage/StatefulMessage.d.ts.map +1 -0
- package/dist/components/library/__StatefulMessage/StatefulMessage.js +88 -0
- package/dist/components/library/index.d.ts +5 -0
- package/dist/components/library/index.d.ts.map +1 -0
- package/dist/components/library/index.js +7 -0
- package/dist/defaultConfig.d.ts +1 -0
- package/dist/defaultConfig.d.ts.map +1 -1
- package/dist/defaultConfig.js +1 -0
- package/dist/env/injectEnv.d.ts +1 -1
- package/dist/env/injectEnv.d.ts.map +1 -1
- package/dist/env/injectEnv.js +16 -6
- package/dist/findUserConfig.d.ts +1 -1
- package/dist/indexInner.d.ts +5 -5
- package/dist/indexInner.d.ts.map +1 -1
- package/dist/indexInner.js +2 -1
- package/dist/postcssPlugins/TransformCssNames.d.ts.map +1 -1
- package/dist/postcssPlugins/TransformCssNames.js +10 -2
- package/dist/propsAreEqual.d.ts +8 -0
- package/dist/propsAreEqual.d.ts.map +1 -0
- package/dist/propsAreEqual.js +97 -0
- package/dist/render/HeadData.d.ts +1 -0
- package/dist/render/HeadData.d.ts.map +1 -1
- package/dist/render/contentToHtml.d.ts.map +1 -1
- package/dist/render/contentToHtml.js +1 -0
- package/dist/render/instantiateClassComponent.d.ts.map +1 -1
- package/dist/render/instantiateClassComponent.js +4 -0
- package/dist/render/processHead.d.ts.map +1 -1
- package/dist/render/processHead.js +14 -1
- package/dist/settings.d.ts +2 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +3 -0
- package/dist/stateTracking.d.ts +47 -0
- package/dist/stateTracking.d.ts.map +1 -0
- package/dist/stateTracking.js +83 -0
- package/dist/staticGen/staticGen.d.ts.map +1 -1
- package/dist/staticGen/staticGen.js +117 -107
- package/dist/testing/installTestStyleProcessors.d.ts +2 -0
- package/dist/testing/installTestStyleProcessors.d.ts.map +1 -0
- package/dist/testing/installTestStyleProcessors.js +21 -0
- package/dist/testing/temp/componentPackageMock.d.ts +18 -0
- package/dist/testing/temp/componentPackageMock.d.ts.map +1 -0
- package/dist/testing/temp/componentPackageMock.js +37 -0
- package/dist/testing/temp/contentHtmlElements.d.ts +3 -0
- package/dist/testing/temp/contentHtmlElements.d.ts.map +1 -0
- package/dist/testing/temp/contentHtmlElements.js +12 -0
- package/dist/update/integrateUpdateVdom.d.ts.map +1 -1
- package/dist/update/integrateUpdateVdom.js +160 -51
- package/dist/update/pendingMounts.d.ts +25 -0
- package/dist/update/pendingMounts.d.ts.map +1 -0
- package/dist/update/pendingMounts.js +55 -0
- package/package.json +4 -2
- package/dist/env/parseEnv.d.ts +0 -2
- package/dist/env/parseEnv.d.ts.map +0 -1
- package/dist/env/parseEnv.js +0 -14
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
function _defineProperty(e, r, t) {
|
|
2
|
+
return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
3
|
+
value: t,
|
|
4
|
+
enumerable: !0,
|
|
5
|
+
configurable: !0,
|
|
6
|
+
writable: !0
|
|
7
|
+
}) : e[r] = t, e;
|
|
8
|
+
}
|
|
9
|
+
function _toPropertyKey(t) {
|
|
10
|
+
var i = _toPrimitive(t, "string");
|
|
11
|
+
return "symbol" == typeof i ? i : i + "";
|
|
12
|
+
}
|
|
13
|
+
function _toPrimitive(t, r) {
|
|
14
|
+
if ("object" != typeof t || !t) return t;
|
|
15
|
+
var e = t[Symbol.toPrimitive];
|
|
16
|
+
if (void 0 !== e) {
|
|
17
|
+
var i = e.call(t, r || "default");
|
|
18
|
+
if ("object" != typeof i) return i;
|
|
19
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
20
|
+
}
|
|
21
|
+
return ("string" === r ? String : Number)(t);
|
|
22
|
+
}
|
|
23
|
+
import { TungstenStatefulComponent } from "../../../Components.js";
|
|
24
|
+
import { createElementRef } from "../../../createRef.js";
|
|
25
|
+
import { combineClassNames } from "../../../helpers/combineClassNames.js";
|
|
26
|
+
import { parseHtml } from "../../../rawHtml/parseHtml.js";
|
|
27
|
+
import { safeHtmlAllowList } from "../../../rawHtml/safeHtmlAllowList.js";
|
|
28
|
+
import { activeFormatsAt } from "./activeFormats.js";
|
|
29
|
+
import { serializeContentTree } from "./contentTree.js";
|
|
30
|
+
import { sanitizeAuthoredHtml } from "./sanitizeAuthoredHtml.js";
|
|
31
|
+
import { jsx as _jsx, jsxs as _jsxs } from "tungsten/jsxRuntime.js";
|
|
32
|
+
const formatBlockOptions = [
|
|
33
|
+
// Applying this produces a <p> which the sanitizer then flattens back to
|
|
34
|
+
// inline content — the way out of a heading or quote, and the state plain
|
|
35
|
+
// text reports as.
|
|
36
|
+
{
|
|
37
|
+
value: 'p',
|
|
38
|
+
text: 'Normal text'
|
|
39
|
+
}, {
|
|
40
|
+
value: 'h2',
|
|
41
|
+
text: 'Heading 2'
|
|
42
|
+
}, {
|
|
43
|
+
value: 'h3',
|
|
44
|
+
text: 'Heading 3'
|
|
45
|
+
}, {
|
|
46
|
+
value: 'h4',
|
|
47
|
+
text: 'Heading 4'
|
|
48
|
+
}, {
|
|
49
|
+
value: 'blockquote',
|
|
50
|
+
text: 'Quote'
|
|
51
|
+
}];
|
|
52
|
+
// The blocks the menu can actually display. A caret inside anything else —
|
|
53
|
+
// contentEditable's own bare <div> wrappers, say — shows the placeholder
|
|
54
|
+
// rather than silently misreporting the format.
|
|
55
|
+
const knownBlocks = new Set(formatBlockOptions.map(option => option.value));
|
|
56
|
+
// Commands whose active state at the cursor is reflected on the toolbar.
|
|
57
|
+
const trackedCommands = ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript', 'insertUnorderedList', 'insertOrderedList'];
|
|
58
|
+
// `mark` names the styling each label carries, so the icon demonstrates the
|
|
59
|
+
// formatting it applies: a bold B, an underlined U, a struck-through S.
|
|
60
|
+
const formattingButtons = [{
|
|
61
|
+
command: 'bold',
|
|
62
|
+
label: 'B',
|
|
63
|
+
title: 'Bold',
|
|
64
|
+
mark: 'bold'
|
|
65
|
+
}, {
|
|
66
|
+
command: 'italic',
|
|
67
|
+
label: 'I',
|
|
68
|
+
title: 'Italic',
|
|
69
|
+
mark: 'italic'
|
|
70
|
+
}, {
|
|
71
|
+
command: 'underline',
|
|
72
|
+
label: 'U',
|
|
73
|
+
title: 'Underline',
|
|
74
|
+
mark: 'underline'
|
|
75
|
+
}, {
|
|
76
|
+
command: 'strikeThrough',
|
|
77
|
+
label: 'S',
|
|
78
|
+
title: 'Strikethrough',
|
|
79
|
+
mark: 'strike'
|
|
80
|
+
}, {
|
|
81
|
+
command: 'superscript',
|
|
82
|
+
label: 'x²',
|
|
83
|
+
title: 'Superscript',
|
|
84
|
+
mark: 'plain'
|
|
85
|
+
}, {
|
|
86
|
+
command: 'subscript',
|
|
87
|
+
label: 'x₂',
|
|
88
|
+
title: 'Subscript',
|
|
89
|
+
mark: 'plain'
|
|
90
|
+
}];
|
|
91
|
+
const listButtons = [{
|
|
92
|
+
command: 'insertUnorderedList',
|
|
93
|
+
label: '•≡',
|
|
94
|
+
title: 'Bulleted list'
|
|
95
|
+
}, {
|
|
96
|
+
command: 'insertOrderedList',
|
|
97
|
+
label: '1≡',
|
|
98
|
+
title: 'Numbered list'
|
|
99
|
+
}];
|
|
100
|
+
const escapeHtml = text => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
|
101
|
+
const readHtmlValue = bind => {
|
|
102
|
+
const value = bind[0][bind[1]];
|
|
103
|
+
return typeof value === 'string' ? value : '';
|
|
104
|
+
};
|
|
105
|
+
const writeHtmlValue = (bind, html) => {
|
|
106
|
+
// TypedBindVariable doesn't constrain O[K] to string, so this assumes the
|
|
107
|
+
// caller binds htmlBind to a string-valued field.
|
|
108
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
109
|
+
bind[0][bind[1]] = html;
|
|
110
|
+
};
|
|
111
|
+
const writeTreeValue = (bind, tree) => {
|
|
112
|
+
// As writeHtmlValue above: assumes treeBind targets a
|
|
113
|
+
// StoredContentNode-typed field.
|
|
114
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
115
|
+
bind[0][bind[1]] = tree;
|
|
116
|
+
};
|
|
117
|
+
export class RichTextEditor extends TungstenStatefulComponent {
|
|
118
|
+
constructor(...args) {
|
|
119
|
+
super(...args);
|
|
120
|
+
_defineProperty(this, "state", {
|
|
121
|
+
initialHtml: readHtmlValue(this.props.htmlBind),
|
|
122
|
+
sourceMode: false,
|
|
123
|
+
sourceDraft: '',
|
|
124
|
+
active: new Set(),
|
|
125
|
+
blockFormat: 'p',
|
|
126
|
+
loadedRecordKey: this.props.recordKey
|
|
127
|
+
});
|
|
128
|
+
_defineProperty(this, "surfaceRef", createElementRef());
|
|
129
|
+
_defineProperty(this, "sourceRef", createElementRef());
|
|
130
|
+
// Re-seeds the surface when the page points this editor at a different
|
|
131
|
+
// record. `initialHtml` is otherwise captured once and left alone (see
|
|
132
|
+
// State), which is what keeps the reconciler away from the live DOM — but
|
|
133
|
+
// that same stability means a record swap would leave the previous
|
|
134
|
+
// record's content on screen, and the next keystroke would commit it over
|
|
135
|
+
// the newly selected record. Writing innerHTML directly as well as
|
|
136
|
+
// updating initialHtml covers the case where the two records happen to
|
|
137
|
+
// share the stored string but the DOM has since been edited.
|
|
138
|
+
_defineProperty(this, "componentDidUpdate", () => {
|
|
139
|
+
const {
|
|
140
|
+
recordKey
|
|
141
|
+
} = this.props;
|
|
142
|
+
if (recordKey === this.state.loadedRecordKey) return;
|
|
143
|
+
const html = readHtmlValue(this.props.htmlBind);
|
|
144
|
+
this.state.loadedRecordKey = recordKey;
|
|
145
|
+
this.state.initialHtml = html;
|
|
146
|
+
this.state.sourceMode = false;
|
|
147
|
+
this.state.sourceDraft = '';
|
|
148
|
+
this.state.active = new Set();
|
|
149
|
+
if (this.surfaceRef.current !== null) this.surfaceRef.current.innerHTML = html;
|
|
150
|
+
});
|
|
151
|
+
// Derives the owning document from whichever host element is currently
|
|
152
|
+
// mounted, rather than the ambient global `document` — the global is
|
|
153
|
+
// only ever correct by accident of a real browser having exactly one,
|
|
154
|
+
// and a test harness (or a future iframe/multi-window use) may not
|
|
155
|
+
// define it at all.
|
|
156
|
+
_defineProperty(this, "ownerDocument", () => this.surfaceRef.current?.ownerDocument ?? this.sourceRef.current?.ownerDocument ?? null);
|
|
157
|
+
// Reads the surface as a string snapshot — the live editing DOM is never
|
|
158
|
+
// mutated here — then derives the tree from the *sanitized* string rather
|
|
159
|
+
// than from the raw one, so the two stored representations always describe
|
|
160
|
+
// the same content.
|
|
161
|
+
//
|
|
162
|
+
// Deriving them independently looked appealing (each through its own
|
|
163
|
+
// hardened pipeline) but is wrong: removing an element splits the text
|
|
164
|
+
// around it, so raw `<p>New <script>x</script>text</p>` parses to adjacent
|
|
165
|
+
// text nodes `['New ', 'text']`, while the sanitized string is the merged
|
|
166
|
+
// `<p>New text</p>`. Those render alike, but re-parsing the stored HTML
|
|
167
|
+
// would then disagree with the stored tree — and worse, a tree with
|
|
168
|
+
// adjacent text nodes renders to markup the browser re-parses as one text
|
|
169
|
+
// node, which is exactly the server/client shape mismatch that breaks
|
|
170
|
+
// hydration. Parsing the sanitized string keeps both allow-list passes
|
|
171
|
+
// (defence in depth) and yields one normalized text node.
|
|
172
|
+
_defineProperty(this, "commitSurface", () => {
|
|
173
|
+
const surface = this.surfaceRef.current;
|
|
174
|
+
if (surface === null) return;
|
|
175
|
+
const cleaned = sanitizeAuthoredHtml(surface.innerHTML, surface.ownerDocument);
|
|
176
|
+
writeHtmlValue(this.props.htmlBind, cleaned);
|
|
177
|
+
writeTreeValue(this.props.treeBind, serializeContentTree(parseHtml(cleaned, {
|
|
178
|
+
allowList: safeHtmlAllowList
|
|
179
|
+
})));
|
|
180
|
+
});
|
|
181
|
+
// The caret/selection last seen inside the surface. Toolbar controls that
|
|
182
|
+
// must take focus to work at all — the format <select> — necessarily blur
|
|
183
|
+
// the editable area first, and execCommand acts on whatever is selected
|
|
184
|
+
// when it runs, so the range is remembered here and put back before the
|
|
185
|
+
// command. Refocusing alone is not dependable enough to rely on.
|
|
186
|
+
_defineProperty(this, "savedRange", null);
|
|
187
|
+
_defineProperty(this, "captureSelection", () => {
|
|
188
|
+
const surface = this.surfaceRef.current;
|
|
189
|
+
const selection = this.ownerDocument()?.defaultView?.getSelection();
|
|
190
|
+
if (surface === null || selection === undefined || selection === null) return;
|
|
191
|
+
if (selection.rangeCount === 0) return;
|
|
192
|
+
const range = selection.getRangeAt(0);
|
|
193
|
+
// Ignore selections outside this editor, so an unrelated click
|
|
194
|
+
// elsewhere on the page cannot redirect a later command.
|
|
195
|
+
if (!surface.contains(range.commonAncestorContainer)) return;
|
|
196
|
+
this.savedRange = range.cloneRange();
|
|
197
|
+
});
|
|
198
|
+
_defineProperty(this, "restoreSelection", () => {
|
|
199
|
+
const selection = this.ownerDocument()?.defaultView?.getSelection();
|
|
200
|
+
if (this.savedRange === null || selection === undefined || selection === null) return;
|
|
201
|
+
selection.removeAllRanges();
|
|
202
|
+
selection.addRange(this.savedRange);
|
|
203
|
+
});
|
|
204
|
+
// Wired to the surface's keyup/mouseup/focus: both jobs want the same
|
|
205
|
+
// moments, whenever the caret may have moved.
|
|
206
|
+
_defineProperty(this, "selectionChanged", () => {
|
|
207
|
+
this.captureSelection();
|
|
208
|
+
this.refreshFormatState();
|
|
209
|
+
});
|
|
210
|
+
// Reflects the formatting at the caret onto the toolbar: which marks are
|
|
211
|
+
// lit, and which block the format menu shows.
|
|
212
|
+
//
|
|
213
|
+
// Read from the element ancestry (activeFormatsAt), not
|
|
214
|
+
// document.queryCommandState, which answers from computed style and so
|
|
215
|
+
// reports bold inside any heading.
|
|
216
|
+
_defineProperty(this, "refreshFormatState", () => {
|
|
217
|
+
const surface = this.surfaceRef.current;
|
|
218
|
+
const selection = this.ownerDocument()?.defaultView?.getSelection();
|
|
219
|
+
if (surface === null) return;
|
|
220
|
+
const at = selection === undefined || selection === null ? null : selection.focusNode;
|
|
221
|
+
const {
|
|
222
|
+
block,
|
|
223
|
+
marks
|
|
224
|
+
} = activeFormatsAt(at, surface);
|
|
225
|
+
this.state.active = new Set(trackedCommands.filter(command => marks.has(command)));
|
|
226
|
+
// Anything the menu has no option for — bare text, or a <div> the
|
|
227
|
+
// browser made — is Normal text, which is what it would become on save.
|
|
228
|
+
this.state.blockFormat = block !== null && knownBlocks.has(block) ? block : 'p';
|
|
229
|
+
});
|
|
230
|
+
_defineProperty(this, "exec", (command, value) => {
|
|
231
|
+
const doc = this.ownerDocument();
|
|
232
|
+
if (doc === null) return;
|
|
233
|
+
this.surfaceRef.current?.focus();
|
|
234
|
+
this.restoreSelection();
|
|
235
|
+
doc.execCommand(command, false, value);
|
|
236
|
+
this.captureSelection();
|
|
237
|
+
this.commitSurface();
|
|
238
|
+
this.refreshFormatState();
|
|
239
|
+
});
|
|
240
|
+
// Buttons take focus on click, which would drop the surface's selection
|
|
241
|
+
// before the click handler runs; preventing the mousedown keeps it.
|
|
242
|
+
//
|
|
243
|
+
// This must NOT go on the format <select>: opening the dropdown *is* that
|
|
244
|
+
// element's default mousedown action, so preventing it makes the control
|
|
245
|
+
// completely dead to the mouse. The select instead relies on
|
|
246
|
+
// saveRange/restoreSelection around exec().
|
|
247
|
+
_defineProperty(this, "preventFocusLoss", event => {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
});
|
|
250
|
+
_defineProperty(this, "changeFormatBlock", event => {
|
|
251
|
+
const tag = event.currentTarget.value;
|
|
252
|
+
if (tag === '') return;
|
|
253
|
+
// No reset afterwards: exec() ends in refreshFormatState(), which sets
|
|
254
|
+
// the menu from the caret's actual block, so it now shows the applied
|
|
255
|
+
// format the way a format menu is expected to.
|
|
256
|
+
this.exec('formatBlock', `<${tag}>`);
|
|
257
|
+
});
|
|
258
|
+
_defineProperty(this, "insertLink", () => {
|
|
259
|
+
// Via the surface's own window rather than the ambient global, for the
|
|
260
|
+
// same reason ownerDocument() exists.
|
|
261
|
+
const url = this.ownerDocument()?.defaultView?.prompt('Link URL');
|
|
262
|
+
if (url === undefined || url === null || url.trim() === '') return;
|
|
263
|
+
this.exec('createLink', url.trim());
|
|
264
|
+
});
|
|
265
|
+
_defineProperty(this, "removeLink", () => this.exec('unlink'));
|
|
266
|
+
_defineProperty(this, "clearFormatting", () => this.exec('removeFormat'));
|
|
267
|
+
_defineProperty(this, "undo", () => this.exec('undo'));
|
|
268
|
+
_defineProperty(this, "redo", () => this.exec('redo'));
|
|
269
|
+
// Enter inserts a line break instead of letting the browser open a new
|
|
270
|
+
// block. Browsers disagree over whether that block is a <p> or a <div>,
|
|
271
|
+
// and leave empty ones behind as content is edited — the markup this
|
|
272
|
+
// editor produces stays inline, with deliberate blocks (headings, quotes,
|
|
273
|
+
// lists) applied explicitly from the toolbar. Shift+Enter already means
|
|
274
|
+
// "line break", so it is left to the browser.
|
|
275
|
+
_defineProperty(this, "handleKeyDown", event => {
|
|
276
|
+
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
277
|
+
const doc = this.ownerDocument();
|
|
278
|
+
if (doc === null) return;
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
doc.execCommand('insertLineBreak');
|
|
281
|
+
this.commitSurface();
|
|
282
|
+
this.selectionChanged();
|
|
283
|
+
});
|
|
284
|
+
_defineProperty(this, "handlePaste", event => {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
const doc = event.currentTarget.ownerDocument;
|
|
287
|
+
const rawHtml = event.clipboardData.getData('text/html');
|
|
288
|
+
const cleaned = rawHtml !== '' ? sanitizeAuthoredHtml(rawHtml, doc) : escapeHtml(event.clipboardData.getData('text/plain'));
|
|
289
|
+
doc.execCommand('insertHTML', false, cleaned);
|
|
290
|
+
this.commitSurface();
|
|
291
|
+
});
|
|
292
|
+
_defineProperty(this, "toggleSource", () => {
|
|
293
|
+
if (this.state.sourceMode) {
|
|
294
|
+
const cleaned = sanitizeAuthoredHtml(this.state.sourceDraft, this.ownerDocument() ?? undefined);
|
|
295
|
+
this.state.initialHtml = cleaned;
|
|
296
|
+
this.state.sourceMode = false;
|
|
297
|
+
writeHtmlValue(this.props.htmlBind, cleaned);
|
|
298
|
+
const cleanedTree = parseHtml(cleaned, {
|
|
299
|
+
allowList: safeHtmlAllowList
|
|
300
|
+
});
|
|
301
|
+
writeTreeValue(this.props.treeBind, serializeContentTree(cleanedTree));
|
|
302
|
+
} else {
|
|
303
|
+
const current = this.surfaceRef.current?.innerHTML ?? readHtmlValue(this.props.htmlBind);
|
|
304
|
+
this.state.sourceDraft = current;
|
|
305
|
+
this.state.sourceMode = true;
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
_defineProperty(this, "changeSourceDraft", event => {
|
|
309
|
+
this.state.sourceDraft = event.currentTarget.value;
|
|
310
|
+
});
|
|
311
|
+
_defineProperty(this, "content", ({
|
|
312
|
+
className,
|
|
313
|
+
name,
|
|
314
|
+
placeholder
|
|
315
|
+
}, state) => _jsxs("div", {
|
|
316
|
+
className: combineClassNames(className, 'editor'),
|
|
317
|
+
children: [_jsxs("div", {
|
|
318
|
+
className: "toolbar",
|
|
319
|
+
role: "toolbar",
|
|
320
|
+
children: [_jsx("select", {
|
|
321
|
+
className: "format-block",
|
|
322
|
+
value: state.blockFormat,
|
|
323
|
+
onChange: this.changeFormatBlock,
|
|
324
|
+
disabled: state.sourceMode,
|
|
325
|
+
"aria-label": "Paragraph style",
|
|
326
|
+
children: formatBlockOptions.map(option => _jsx("option", {
|
|
327
|
+
value: option.value,
|
|
328
|
+
children: option.text
|
|
329
|
+
}, option.value))
|
|
330
|
+
}), _jsx("span", {
|
|
331
|
+
className: "group",
|
|
332
|
+
children: formattingButtons.map(item => _jsx("button", {
|
|
333
|
+
type: "button",
|
|
334
|
+
title: item.title,
|
|
335
|
+
className: combineClassNames('tool', item.mark, state.active.has(item.command) && 'active'),
|
|
336
|
+
disabled: state.sourceMode,
|
|
337
|
+
onMouseDown: this.preventFocusLoss,
|
|
338
|
+
onClick: () => this.exec(item.command),
|
|
339
|
+
children: item.label
|
|
340
|
+
}, item.command))
|
|
341
|
+
}), _jsx("span", {
|
|
342
|
+
className: "group",
|
|
343
|
+
children: listButtons.map(item => _jsx("button", {
|
|
344
|
+
type: "button",
|
|
345
|
+
title: item.title,
|
|
346
|
+
className: combineClassNames('tool', state.active.has(item.command) && 'active'),
|
|
347
|
+
disabled: state.sourceMode,
|
|
348
|
+
onMouseDown: this.preventFocusLoss,
|
|
349
|
+
onClick: () => this.exec(item.command),
|
|
350
|
+
children: item.label
|
|
351
|
+
}, item.command))
|
|
352
|
+
}), _jsxs("span", {
|
|
353
|
+
className: "group",
|
|
354
|
+
children: [_jsx("button", {
|
|
355
|
+
type: "button",
|
|
356
|
+
title: "Insert link",
|
|
357
|
+
className: "tool",
|
|
358
|
+
disabled: state.sourceMode,
|
|
359
|
+
onMouseDown: this.preventFocusLoss,
|
|
360
|
+
onClick: this.insertLink,
|
|
361
|
+
children: "Link"
|
|
362
|
+
}), _jsx("button", {
|
|
363
|
+
type: "button",
|
|
364
|
+
title: "Remove link",
|
|
365
|
+
className: "tool",
|
|
366
|
+
disabled: state.sourceMode,
|
|
367
|
+
onMouseDown: this.preventFocusLoss,
|
|
368
|
+
onClick: this.removeLink,
|
|
369
|
+
children: "Unlink"
|
|
370
|
+
})]
|
|
371
|
+
}), _jsxs("span", {
|
|
372
|
+
className: "group",
|
|
373
|
+
children: [_jsx("button", {
|
|
374
|
+
type: "button",
|
|
375
|
+
title: "Clear formatting",
|
|
376
|
+
className: "tool",
|
|
377
|
+
disabled: state.sourceMode,
|
|
378
|
+
onMouseDown: this.preventFocusLoss,
|
|
379
|
+
onClick: this.clearFormatting,
|
|
380
|
+
children: "Clear"
|
|
381
|
+
}), _jsx("button", {
|
|
382
|
+
type: "button",
|
|
383
|
+
title: "Undo",
|
|
384
|
+
className: "tool",
|
|
385
|
+
disabled: state.sourceMode,
|
|
386
|
+
onMouseDown: this.preventFocusLoss,
|
|
387
|
+
onClick: this.undo,
|
|
388
|
+
children: "\u21B6"
|
|
389
|
+
}), _jsx("button", {
|
|
390
|
+
type: "button",
|
|
391
|
+
title: "Redo",
|
|
392
|
+
className: "tool",
|
|
393
|
+
disabled: state.sourceMode,
|
|
394
|
+
onMouseDown: this.preventFocusLoss,
|
|
395
|
+
onClick: this.redo,
|
|
396
|
+
children: "\u21B7"
|
|
397
|
+
})]
|
|
398
|
+
}), _jsx("button", {
|
|
399
|
+
type: "button",
|
|
400
|
+
title: "View HTML source",
|
|
401
|
+
className: combineClassNames('tool', 'source-toggle', state.sourceMode && 'active'),
|
|
402
|
+
onMouseDown: this.preventFocusLoss,
|
|
403
|
+
onClick: this.toggleSource,
|
|
404
|
+
children: "HTML"
|
|
405
|
+
})]
|
|
406
|
+
}), state.sourceMode ? _jsx("textarea", {
|
|
407
|
+
className: "source",
|
|
408
|
+
name: name,
|
|
409
|
+
ref: this.sourceRef,
|
|
410
|
+
value: state.sourceDraft,
|
|
411
|
+
onInput: this.changeSourceDraft
|
|
412
|
+
})
|
|
413
|
+
// The explicit string rather than a bare/boolean prop. Tungsten
|
|
414
|
+
// now renders either as `contenteditable="true"`, so this is no
|
|
415
|
+
// longer working around anything — it just states the value of
|
|
416
|
+
// a three-state enumerated attribute (true/false/inherit)
|
|
417
|
+
// rather than leaving it to a boolean-prop convention.
|
|
418
|
+
: _jsx("div", {
|
|
419
|
+
className: "surface",
|
|
420
|
+
id: name,
|
|
421
|
+
ref: this.surfaceRef,
|
|
422
|
+
contentEditable: "true",
|
|
423
|
+
"data-placeholder": placeholder ?? '',
|
|
424
|
+
dangerouslySetInnerHTML: {
|
|
425
|
+
__html: state.initialHtml
|
|
426
|
+
},
|
|
427
|
+
onInput: this.commitSurface,
|
|
428
|
+
onPaste: this.handlePaste,
|
|
429
|
+
onKeyDown: this.handleKeyDown,
|
|
430
|
+
onKeyUp: this.selectionChanged,
|
|
431
|
+
onMouseUp: this.selectionChanged,
|
|
432
|
+
onFocus: this.selectionChanged
|
|
433
|
+
})]
|
|
434
|
+
}));
|
|
435
|
+
_defineProperty(this, "style", {
|
|
436
|
+
"moduleName": "T#-components-library-RichTextEditor-RichTextEditor",
|
|
437
|
+
"tags": {
|
|
438
|
+
"select": "T#-components-library-RichTextEditor-RichTextEditor_#_select"
|
|
439
|
+
},
|
|
440
|
+
"classes": {
|
|
441
|
+
"editor": "T#-components-library-RichTextEditor-RichTextEditor_editor",
|
|
442
|
+
"toolbar": "T#-components-library-RichTextEditor-RichTextEditor_toolbar",
|
|
443
|
+
"group": "T#-components-library-RichTextEditor-RichTextEditor_group",
|
|
444
|
+
"format-block": "T#-components-library-RichTextEditor-RichTextEditor_format-block",
|
|
445
|
+
"tool": "T#-components-library-RichTextEditor-RichTextEditor_tool",
|
|
446
|
+
"active": "T#-components-library-RichTextEditor-RichTextEditor_active",
|
|
447
|
+
"bold": "T#-components-library-RichTextEditor-RichTextEditor_bold",
|
|
448
|
+
"italic": "T#-components-library-RichTextEditor-RichTextEditor_italic",
|
|
449
|
+
"underline": "T#-components-library-RichTextEditor-RichTextEditor_underline",
|
|
450
|
+
"strike": "T#-components-library-RichTextEditor-RichTextEditor_strike",
|
|
451
|
+
"source-toggle": "T#-components-library-RichTextEditor-RichTextEditor_source-toggle",
|
|
452
|
+
"surface": "T#-components-library-RichTextEditor-RichTextEditor_surface",
|
|
453
|
+
"source": "T#-components-library-RichTextEditor-RichTextEditor_source"
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
static $stylesheet = "i/T-components-library-RichTextEditor-RichTextEditor.css";
|
|
458
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StoredContentNode.d.ts","sourceRoot":"","sources":["../../../../src/components/library/RichTextEditor/StoredContentNode.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,iBAAiB,GACvB,MAAM,GACN,MAAM,GACN,IAAI,GACJ,iBAAiB,EAAE,GACnB;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ActiveFormats = {
|
|
2
|
+
/** Tag of the nearest block element inside the surface, or null. */
|
|
3
|
+
block: string | null;
|
|
4
|
+
/** execCommand names for the inline marks in force at this point. */
|
|
5
|
+
marks: ReadonlySet<string>;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The formatting in force at a point in the editable surface, read from the
|
|
9
|
+
* element ancestry rather than `document.queryCommandState`.
|
|
10
|
+
*
|
|
11
|
+
* queryCommandState answers from computed style, so it reports `bold` as true
|
|
12
|
+
* anywhere inside a heading — headings render bold — which lights up the Bold
|
|
13
|
+
* button misleadingly and invites a redundant `<strong>` inside an `<h2>`.
|
|
14
|
+
* Walking the ancestors instead means only real markup counts.
|
|
15
|
+
*
|
|
16
|
+
* The walk stops at `root` so formatting *around* the editor cannot leak into
|
|
17
|
+
* its toolbar state.
|
|
18
|
+
*/
|
|
19
|
+
export declare function activeFormatsAt(node: Node | null, root: Element): ActiveFormats;
|
|
20
|
+
//# sourceMappingURL=activeFormats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activeFormats.d.ts","sourceRoot":"","sources":["../../../../src/components/library/RichTextEditor/activeFormats.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,aAAa,GAAG;IACxB,oEAAoE;IACpE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CAC7B,CAAA;AAmBD;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,EAAE,IAAI,EAAE,OAAO,GAAG,aAAa,CAe/E"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Tag to the execCommand that produces it. Several tags map to one command
|
|
2
|
+
// because browsers differ over which they emit (`b`/`strong`, `s`/`strike`/`del`).
|
|
3
|
+
const markCommands = {
|
|
4
|
+
b: 'bold',
|
|
5
|
+
strong: 'bold',
|
|
6
|
+
i: 'italic',
|
|
7
|
+
em: 'italic',
|
|
8
|
+
u: 'underline',
|
|
9
|
+
s: 'strikeThrough',
|
|
10
|
+
strike: 'strikeThrough',
|
|
11
|
+
del: 'strikeThrough',
|
|
12
|
+
sub: 'subscript',
|
|
13
|
+
sup: 'superscript',
|
|
14
|
+
ul: 'insertUnorderedList',
|
|
15
|
+
ol: 'insertOrderedList'
|
|
16
|
+
};
|
|
17
|
+
const blockTags = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'div']);
|
|
18
|
+
function NodeIsElement(node) {
|
|
19
|
+
return node.nodeType === 1;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The formatting in force at a point in the editable surface, read from the
|
|
23
|
+
* element ancestry rather than `document.queryCommandState`.
|
|
24
|
+
*
|
|
25
|
+
* queryCommandState answers from computed style, so it reports `bold` as true
|
|
26
|
+
* anywhere inside a heading — headings render bold — which lights up the Bold
|
|
27
|
+
* button misleadingly and invites a redundant `<strong>` inside an `<h2>`.
|
|
28
|
+
* Walking the ancestors instead means only real markup counts.
|
|
29
|
+
*
|
|
30
|
+
* The walk stops at `root` so formatting *around* the editor cannot leak into
|
|
31
|
+
* its toolbar state.
|
|
32
|
+
*/
|
|
33
|
+
export function activeFormatsAt(node, root) {
|
|
34
|
+
const marks = new Set();
|
|
35
|
+
let block = null;
|
|
36
|
+
for (let current = node; current !== null && current !== root; current = current.parentNode) {
|
|
37
|
+
if (!NodeIsElement(current)) continue;
|
|
38
|
+
const tag = current.tagName.toLowerCase();
|
|
39
|
+
const command = markCommands[tag];
|
|
40
|
+
if (command !== undefined) marks.add(command);
|
|
41
|
+
// nearest block wins, so only take the first one met on the way up
|
|
42
|
+
if (block === null && blockTags.has(tag)) block = tag;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
block,
|
|
46
|
+
marks
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ContentNode } from '../../../ContentNode.js';
|
|
2
|
+
import type { HtmlAllowList } from '../../../rawHtml/HtmlAllowList.js';
|
|
3
|
+
import type { StoredContentNode } from './StoredContentNode.js';
|
|
4
|
+
/**
|
|
5
|
+
* Converts a Tungsten content tree into the JSON-safe StoredContentNode shape
|
|
6
|
+
* for persistence/transport. Only host HTML nodes are supported — the shape
|
|
7
|
+
* `parseHtml` produces — never component elements, which have no meaningful
|
|
8
|
+
* serialized form.
|
|
9
|
+
*/
|
|
10
|
+
export declare function serializeContentTree(node: ContentNode): StoredContentNode;
|
|
11
|
+
/**
|
|
12
|
+
* Rebuilds a Tungsten content tree from stored JSON, filtered against the
|
|
13
|
+
* allow list.
|
|
14
|
+
*
|
|
15
|
+
* Filtering on *read* is deliberate and not redundant with the write side.
|
|
16
|
+
* Storing a pre-parsed tree is what makes rendering cheap — no re-parse per
|
|
17
|
+
* render — but it also means the stored value is handed to the renderer
|
|
18
|
+
* without passing back through `parseHtml`, which is where `<RawHtml safe>`
|
|
19
|
+
* would otherwise re-apply the allow list every time. Anything that ever put
|
|
20
|
+
* an unfiltered tree in the column (an endpoint bug, a hand-edited row) would
|
|
21
|
+
* then render unchecked, including `on*` props that Tungsten binds as event
|
|
22
|
+
* listeners.
|
|
23
|
+
*
|
|
24
|
+
* The rules themselves are Tungsten's `filterContentNode`, which makes the same
|
|
25
|
+
* decisions `parseHtml` makes while parsing — so the stored-tree path, the
|
|
26
|
+
* editor's write path and `<RawHtml safe>` all agree by construction rather
|
|
27
|
+
* than by three implementations being kept in step.
|
|
28
|
+
*/
|
|
29
|
+
export declare function deserializeContentTree(stored: StoredContentNode, allowList?: HtmlAllowList): ContentNode;
|
|
30
|
+
//# sourceMappingURL=contentTree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contentTree.d.ts","sourceRoot":"","sources":["../../../../src/components/library/RichTextEditor/contentTree.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAA8B,MAAM,yBAAyB,CAAA;AAGtF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAA;AACtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAE/D;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,GAAG,iBAAiB,CAezE;AA6BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CAClC,MAAM,EAAE,iBAAiB,EAAE,SAAS,GAAE,aAAiC,GACxE,WAAW,CAEb"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { $props, $type } from "../../../contentNodeSymbols.js";
|
|
2
|
+
import { ContentNodeIsArray, ContentNodeIsHtml, ContentNodeIsLiteral, ContentNodeIsNonRendered } from "../../../ContentNode.js";
|
|
3
|
+
import { filterContentNode } from "../../../rawHtml/filterContentNode.js";
|
|
4
|
+
import { safeHtmlAllowList } from "../../../rawHtml/safeHtmlAllowList.js";
|
|
5
|
+
/**
|
|
6
|
+
* Converts a Tungsten content tree into the JSON-safe StoredContentNode shape
|
|
7
|
+
* for persistence/transport. Only host HTML nodes are supported — the shape
|
|
8
|
+
* `parseHtml` produces — never component elements, which have no meaningful
|
|
9
|
+
* serialized form.
|
|
10
|
+
*/
|
|
11
|
+
export function serializeContentTree(node) {
|
|
12
|
+
if (ContentNodeIsNonRendered(node)) return null;
|
|
13
|
+
if (ContentNodeIsArray(node)) return node.map(serializeContentTree);
|
|
14
|
+
if (ContentNodeIsLiteral(node)) return node;
|
|
15
|
+
if (!ContentNodeIsHtml(node)) {
|
|
16
|
+
throw new Error('serializeContentTree only supports host HTML nodes (e.g. from parseHtml), not component elements.');
|
|
17
|
+
}
|
|
18
|
+
const {
|
|
19
|
+
children,
|
|
20
|
+
...attributes
|
|
21
|
+
} = node[$props];
|
|
22
|
+
return {
|
|
23
|
+
tag: node[$type],
|
|
24
|
+
props: children === undefined ? attributes : {
|
|
25
|
+
...attributes,
|
|
26
|
+
children: serializeContentTree(children)
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Rebuilds the tree's shape from stored JSON, and nothing else. Whatever is in
|
|
32
|
+
* the column is reproduced faithfully, including markup that must not survive
|
|
33
|
+
* to a render — filtering is `deserializeContentTree`'s job, in one pass over
|
|
34
|
+
* the finished tree, so that this stays a plain structural inverse of
|
|
35
|
+
* `serializeContentTree`.
|
|
36
|
+
*/
|
|
37
|
+
function rebuildContentTree(stored) {
|
|
38
|
+
if (stored === null) return null;
|
|
39
|
+
if (Array.isArray(stored)) return stored.map(rebuildContentTree);
|
|
40
|
+
if (typeof stored !== 'object') return stored;
|
|
41
|
+
const {
|
|
42
|
+
children,
|
|
43
|
+
...attributes
|
|
44
|
+
} = stored.props;
|
|
45
|
+
const props = {
|
|
46
|
+
...attributes
|
|
47
|
+
};
|
|
48
|
+
if (children !== undefined) {
|
|
49
|
+
// Storage round-trip boundary: `children` holds whatever
|
|
50
|
+
// serializeContentTree put there.
|
|
51
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
52
|
+
props.children = rebuildContentTree(children);
|
|
53
|
+
}
|
|
54
|
+
// Lowercased to match what parseHtml produces, so a hand-edited row cannot
|
|
55
|
+
// present a tag the allow list would then fail to recognise.
|
|
56
|
+
return {
|
|
57
|
+
[$type]: stored.tag.toLowerCase(),
|
|
58
|
+
[$props]: props
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Rebuilds a Tungsten content tree from stored JSON, filtered against the
|
|
63
|
+
* allow list.
|
|
64
|
+
*
|
|
65
|
+
* Filtering on *read* is deliberate and not redundant with the write side.
|
|
66
|
+
* Storing a pre-parsed tree is what makes rendering cheap — no re-parse per
|
|
67
|
+
* render — but it also means the stored value is handed to the renderer
|
|
68
|
+
* without passing back through `parseHtml`, which is where `<RawHtml safe>`
|
|
69
|
+
* would otherwise re-apply the allow list every time. Anything that ever put
|
|
70
|
+
* an unfiltered tree in the column (an endpoint bug, a hand-edited row) would
|
|
71
|
+
* then render unchecked, including `on*` props that Tungsten binds as event
|
|
72
|
+
* listeners.
|
|
73
|
+
*
|
|
74
|
+
* The rules themselves are Tungsten's `filterContentNode`, which makes the same
|
|
75
|
+
* decisions `parseHtml` makes while parsing — so the stored-tree path, the
|
|
76
|
+
* editor's write path and `<RawHtml safe>` all agree by construction rather
|
|
77
|
+
* than by three implementations being kept in step.
|
|
78
|
+
*/
|
|
79
|
+
export function deserializeContentTree(stored, allowList = safeHtmlAllowList) {
|
|
80
|
+
return filterContentNode(rebuildContentTree(stored), allowList);
|
|
81
|
+
}
|