@jini-ai/ui 0.4.7 → 0.4.8
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/README.md +2 -0
- package/dist/features/admin-widgets/components/InfoTip.d.ts +23 -2
- package/dist/features/admin-widgets/components/InfoTip.hooks.d.ts +22 -2
- package/dist/features/admin-widgets/components/InfoTip.hooks.js +75 -28
- package/dist/features/admin-widgets/components/InfoTip.js +12 -4
- package/dist/features/admin-widgets/components/InfoTip.rules.d.ts +72 -0
- package/dist/features/admin-widgets/components/InfoTip.rules.js +68 -0
- package/dist/features/admin-widgets/components/SuggestInput/SuggestInput.d.ts +36 -0
- package/dist/features/admin-widgets/components/SuggestInput/SuggestInput.hooks.d.ts +58 -0
- package/dist/features/admin-widgets/components/SuggestInput/SuggestInput.hooks.js +66 -0
- package/dist/features/admin-widgets/components/SuggestInput/SuggestInput.js +24 -0
- package/dist/features/admin-widgets/components/useDebouncedHtmlPreview.hooks.d.ts +23 -0
- package/dist/features/admin-widgets/components/useDebouncedHtmlPreview.hooks.js +28 -0
- package/dist/features/admin-widgets/index.d.ts +9 -0
- package/dist/features/admin-widgets/index.js +5 -0
- package/dist/features/execution/constants.js +11 -10
- package/dist/features/execution/http-adapter.d.ts +3 -1
- package/dist/features/execution/http-adapter.js +9 -4
- package/dist/features/execution/index.d.ts +1 -1
- package/dist/features/execution/index.js +1 -1
- package/dist/features/execution/react/components/ByokProviderForm.d.ts +18 -1
- package/dist/features/execution/react/components/ByokProviderForm.js +14 -16
- package/dist/features/execution/react/components/LocalCliAgentCard.d.ts +5 -1
- package/dist/features/execution/react/components/LocalCliAgentCard.js +12 -87
- package/dist/features/execution/react/components/LocalCliAgentList.d.ts +4 -1
- package/dist/features/execution/react/components/LocalCliAgentList.js +13 -4
- package/dist/features/execution/react/components/LocalCliAgentSettings.d.ts +20 -0
- package/dist/features/execution/react/components/LocalCliAgentSettings.js +20 -0
- package/dist/features/execution/react/hooks/use-byok-field-validation.hooks.d.ts +24 -0
- package/dist/features/execution/react/hooks/use-byok-field-validation.hooks.js +27 -0
- package/dist/features/execution/react/hooks/useExecutionTab.js +10 -1
- package/dist/features/execution/react/hooks/useLocalCliAgentCard.d.ts +48 -0
- package/dist/features/execution/react/hooks/useLocalCliAgentCard.js +110 -0
- package/dist/features/execution/react/hooks/useLocalCliAgentList.d.ts +19 -0
- package/dist/features/execution/react/hooks/useLocalCliAgentList.js +13 -0
- package/dist/features/execution/rules.d.ts +13 -0
- package/dist/features/execution/rules.js +14 -0
- package/dist/features/execution/types.d.ts +2 -0
- package/dist/features/html-editor/canvas-embed-placeholders.d.ts +13 -1
- package/dist/features/html-editor/canvas-embed-placeholders.js +61 -12
- package/dist/features/html-editor/dom.d.ts +5 -0
- package/dist/features/html-editor/dom.js +10 -0
- package/dist/features/html-editor/editor-blocks.d.ts +71 -0
- package/dist/features/html-editor/editor-blocks.js +185 -0
- package/dist/features/html-editor/editor-serialization.d.ts +24 -0
- package/dist/features/html-editor/editor-serialization.js +110 -0
- package/dist/features/html-editor/index.d.ts +5 -0
- package/dist/features/html-editor/index.js +3 -0
- package/dist/features/html-editor/protected-elements.d.ts +64 -0
- package/dist/features/html-editor/protected-elements.js +189 -0
- package/dist/features/html-editor/react/components/InteractiveHtmlEditor.d.ts +6 -4
- package/dist/features/html-editor/react/components/InteractiveHtmlEditor.js +3 -3
- package/dist/features/html-editor/react/hooks/useInteractiveHtmlEditor.d.ts +9 -1
- package/dist/features/html-editor/react/hooks/useInteractiveHtmlEditor.js +107 -196
- package/dist/features/html-editor/source-splice.d.ts +23 -0
- package/dist/features/html-editor/source-splice.js +45 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ar.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.bn.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.de.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.en.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.es.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fa.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fr.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hi.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hu.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.id.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.it.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ja.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ko.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pl.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pt-BR.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ru.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.th.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.tr.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.uk.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ur.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-CN.js +4 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-TW.js +4 -0
- package/dist/features/interactive-ui/providers/shadcn/button.d.ts +1 -1
- package/dist/features/mcp-ui/escape.js +2 -6
- package/dist/features/mcp-ui/surfaces/bridge.js +4 -2
- package/dist/features/mcp-ui/surfaces/document.d.ts +11 -4
- package/dist/features/mcp-ui/surfaces/document.js +25 -3
- package/dist/features/mcp-ui/surfaces/outcome.js +1 -1
- package/dist/features/media-providers/constants.d.ts +0 -7
- package/dist/features/media-providers/constants.js +7 -15
- package/dist/features/media-providers/react/components/MediaProvidersTab.js +3 -2
- package/dist/features/media-providers/react/hooks/useMediaProvidersTab.js +6 -3
- package/dist/features/memory/dependencies.d.ts +2 -0
- package/dist/features/memory/dependencies.js +9 -25
- package/dist/features/memory/index.d.ts +1 -0
- package/dist/features/memory/index.js +1 -0
- package/dist/features/memory/react/MemoryPorts.d.ts +17 -0
- package/dist/features/memory/react/MemoryPorts.js +15 -0
- package/dist/features/memory/react/hooks/useMemoryConfig.hooks.d.ts +2 -2
- package/dist/features/memory/react/hooks/useMemoryConfig.hooks.js +7 -7
- package/dist/features/memory/react/hooks/useMemoryConnectors.hooks.d.ts +2 -3
- package/dist/features/memory/react/hooks/useMemoryConnectors.hooks.js +4 -5
- package/dist/features/memory/react/hooks/useMemoryEntries.hooks.d.ts +2 -3
- package/dist/features/memory/react/hooks/useMemoryEntries.hooks.js +4 -5
- package/dist/features/memory/react/hooks/useMemoryExtractions.hooks.js +2 -2
- package/dist/features/panel-kit/fetch-query/adapter.react.d.ts +2 -2
- package/dist/features/panel-kit/fetch-query/adapter.react.js +2 -2
- package/dist/features/panel-kit/fetch-query/adapter.tanstack.d.ts +1 -1
- package/dist/features/panel-kit/fetch-query/adapter.tanstack.js +35 -13
- package/dist/features/panel-kit/fetch-query/binding.react.d.ts +1 -1
- package/dist/features/panel-kit/fetch-query/environment.browser.d.ts +1 -1
- package/dist/features/panel-kit/fetch-query/index.d.ts +4 -2
- package/dist/features/panel-kit/fetch-query/tanstack.d.ts +4 -2
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.d.ts +3 -1
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.js +9 -2
- package/dist/features/settings/dialog/styles/settings-dialog.css +169 -21
- package/dist/features/tabbed-dialog/styles/tabbed-dialog.css +91 -0
- package/dist/react/components/RemixIcon.d.ts +1 -41
- package/dist/react/components/RemixIcon.js +3 -176
- package/dist/react/components/RemixIcon.stylesheet.d.ts +84 -0
- package/dist/react/components/RemixIcon.stylesheet.js +208 -0
- package/dist/react/components/remixicon-font-recovery.d.ts +55 -0
- package/dist/react/components/remixicon-font-recovery.js +110 -0
- package/dist/styles/admin.css +8 -2
- package/package.json +12 -7
- package/dist/features/panel-kit/fetch-query/cache.d.ts +0 -125
- package/dist/features/panel-kit/fetch-query/cache.js +0 -232
- package/dist/features/panel-kit/fetch-query/types.d.ts +0 -93
- package/dist/features/panel-kit/fetch-query/types.js +0 -2
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { sourceElementAttributeView } from './dom.js';
|
|
2
|
+
import { indexSourceElements } from './source-splice.js';
|
|
3
|
+
const LABELS = {
|
|
4
|
+
kuinetic: 'Animation · Kuinetic', canvas: 'Canvas', 'autoplay-video': 'Autoplay video',
|
|
5
|
+
scripted: 'Scripted section', embed: 'Content',
|
|
6
|
+
};
|
|
7
|
+
const INLINE_TAGS = new Set(['span', 'a', 'b', 'i', 'em', 'strong', 'small', 'mark', 's', 'u', 'sub', 'sup']);
|
|
8
|
+
const UNSUPPORTED_CONTEXTS = new Set(['table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'select', 'option', 'svg', 'math', 'template']);
|
|
9
|
+
// A Kuinetic entrance/decoration effect (fade-up, text-reveal, split-chars, count-up…)
|
|
10
|
+
// only animates authored text the canvas never runs, so locking it hid Landing's headline, ledes and
|
|
11
|
+
// buttons behind grey cards. Such elements stay editable; their data-kui attribute round-trips like
|
|
12
|
+
// any other, including stagger lists and decorated containers. Layout/runtime effects (ring, pin…)
|
|
13
|
+
// stay locked; unknown names retain the structural rule, and data-editor="block" still forces a lock.
|
|
14
|
+
// Names follow Kuinetic's core entrance, text, numbers, media and interaction catalogs; the bare
|
|
15
|
+
// fade/reveal/text-reveal/glow spellings are also part of the editor's authored-effect contract.
|
|
16
|
+
const EDITABLE_EFFECTS = new Set([
|
|
17
|
+
'fade', 'fade-in', 'fade-up', 'fade-down', 'fade-left', 'fade-right', 'fade-blur-in', 'fade-blur-up',
|
|
18
|
+
'slide-up', 'slide-down', 'slide-left', 'slide-right', 'slide-inline-start', 'slide-inline-end', 'slide-block-start', 'slide-block-end',
|
|
19
|
+
'zoom-in', 'zoom-in-up', 'zoom-in-down', 'pop-in', 'flip-in-x', 'flip-in-y', 'rotate-in', 'rotate-in-left', 'rotate-in-right',
|
|
20
|
+
'roll-in', 'swing-in', 'blur-in', 'bounce-in', 'bounce-in-up', 'bounce-in-down', 'back-in-up', 'back-in-down', 'reveal', 'reveal-once',
|
|
21
|
+
'text-reveal', 'text-reveal-up', 'text-reveal-down', 'text-reveal-mask', 'split-chars', 'split-words', 'split-lines',
|
|
22
|
+
'count-up', 'count-down', 'count-currency', 'count-percent', 'count-compact', 'odometer-roll',
|
|
23
|
+
'wipe-up', 'wipe-down', 'wipe-left', 'wipe-right', 'wipe-circle', 'wipe-diagonal', 'mask-reveal', 'curtain-reveal', 'blur-up',
|
|
24
|
+
'magnetic', 'glow', 'glow-pulse', 'border-glow', 'border-draw', 'beam-border', 'beam-border-auto', 'proximity-glow',
|
|
25
|
+
'shine-sweep', 'lift', 'lift-shadow', 'pop', 'underline-slide', 'underline-center', 'icon-wiggle', 'icon-spin', 'icon-bounce',
|
|
26
|
+
]);
|
|
27
|
+
const RUNTIME_EFFECTS = new Set([
|
|
28
|
+
'ring', 'carousel', 'deck', 'slides', 'pin', 'pin-section', 'pin-until', 'pin-spacer', 'marquee', 'marquee-scroll-linked', 'accordion', 'tabs',
|
|
29
|
+
'carousel-3d', 'carousel-3d-high', 'carousel-3d-low', 'carousel-3d-inside', 'carousel-orbit', 'carousel-stack',
|
|
30
|
+
'carousel-fade', 'carousel-slide', 'slideshow', 'video-hero-slideshow', 'step-progress', 'stacking-cards', 'horizontal-scroll',
|
|
31
|
+
'flip-reorder', 'flip-filter', 'flip-sort', 'flip-shuffle', 'grid-to-list', 'masonry-reflow', 'expand-to-modal', 'accordion-height',
|
|
32
|
+
'card-flip-x', 'card-flip-y', 'flip-card', 'scroll-snap-x', 'scroll-snap-y',
|
|
33
|
+
]);
|
|
34
|
+
const TEXT_EFFECT_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'dt', 'dd', 'blockquote', 'figcaption', 'label']);
|
|
35
|
+
const PHRASING_TAGS = new Set([...INLINE_TAGS, 'abbr', 'br', 'cite', 'code', 'data', 'dfn', 'img', 'kbd', 'q', 'samp', 'time', 'var', 'wbr']);
|
|
36
|
+
const isPhrasingOnly = (el) => el.children.every(child => PHRASING_TAGS.has(child.tagName) && isPhrasingOnly(child));
|
|
37
|
+
const isEditableEffect = (el) => {
|
|
38
|
+
if (el.attributes['data-editor'] === 'block')
|
|
39
|
+
return false;
|
|
40
|
+
// Quoted selectors and timing/color functions may contain commas, which are not effect separators.
|
|
41
|
+
const effects = (el.attributes['data-kui'] ?? '').replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\([^)]*\)/g, '')
|
|
42
|
+
.split(',').map(segment => segment.trim().split(/\s+/)[0]).filter(Boolean);
|
|
43
|
+
if (effects.some(effect => RUNTIME_EFFECTS.has(effect)))
|
|
44
|
+
return false;
|
|
45
|
+
if (effects.length > 0 && effects.every(effect => EDITABLE_EFFECTS.has(effect)))
|
|
46
|
+
return true;
|
|
47
|
+
if (el.attributes['data-kui'] === undefined && 'data-kui-stagger' in el.attributes)
|
|
48
|
+
return true;
|
|
49
|
+
return (TEXT_EFFECT_TAGS.has(el.tagName) || PHRASING_TAGS.has(el.tagName)) && isPhrasingOnly(el);
|
|
50
|
+
};
|
|
51
|
+
/** Outermost disjoint roots; explicit live islands veto aggregation but never host embed safety. */
|
|
52
|
+
export function detectEditorBlocks({ html }, options = {}) {
|
|
53
|
+
const elements = indexSourceElements({ html }, {});
|
|
54
|
+
const blocks = [];
|
|
55
|
+
const liveIslands = new Map();
|
|
56
|
+
const annotate = (el) => {
|
|
57
|
+
const descendants = el.children.map(annotate).some(Boolean);
|
|
58
|
+
liveIslands.set(el, descendants);
|
|
59
|
+
return el.attributes['data-editor'] === 'live' || descendants;
|
|
60
|
+
};
|
|
61
|
+
elements.forEach(annotate);
|
|
62
|
+
let unsupported = false;
|
|
63
|
+
const walk = (el, live, invalidAncestor) => {
|
|
64
|
+
const mode = el.attributes['data-editor'];
|
|
65
|
+
const isLive = mode === 'live' || (mode !== 'block' && live);
|
|
66
|
+
const view = sourceElementAttributeView(el, {});
|
|
67
|
+
const host = options.describeHostElement?.(view);
|
|
68
|
+
const embed = !!host || !!options.isProtectedElement?.(view);
|
|
69
|
+
let reason;
|
|
70
|
+
if (!isLive) {
|
|
71
|
+
if (Object.keys(el.attributes).some(name => name.startsWith('data-kui')) && !isEditableEffect(el))
|
|
72
|
+
reason = 'kuinetic';
|
|
73
|
+
else if (el.tagName === 'canvas')
|
|
74
|
+
reason = 'canvas';
|
|
75
|
+
else if (el.tagName === 'video' && 'autoplay' in el.attributes)
|
|
76
|
+
reason = 'autoplay-video';
|
|
77
|
+
else if (mode === 'block')
|
|
78
|
+
reason = 'scripted';
|
|
79
|
+
}
|
|
80
|
+
if (reason && liveIslands.get(el))
|
|
81
|
+
reason = undefined;
|
|
82
|
+
if (!reason && embed)
|
|
83
|
+
reason = 'embed';
|
|
84
|
+
const invalid = invalidAncestor || !el.outerRange || UNSUPPORTED_CONTEXTS.has(el.tagName);
|
|
85
|
+
if (reason) {
|
|
86
|
+
if (invalid || !el.sourceSupported) {
|
|
87
|
+
unsupported = true;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const t = options.translate ?? ((key) => key);
|
|
91
|
+
const id = el.attributes.id;
|
|
92
|
+
const identity = id ? `#${id}` : el.attributes.class ? `.${el.attributes.class.trim().split(/\s+/).slice(0, 2).join('.')}` : el.tagName;
|
|
93
|
+
blocks.push({
|
|
94
|
+
...el.outerRange, tagName: el.tagName, ...(id ? { id } : {}), reason, element: el,
|
|
95
|
+
descriptor: reason === 'embed' && host ? host : {
|
|
96
|
+
kindLabel: t(LABELS[reason]), identityLabel: identity.slice(0, 64), presentation: reason === 'embed' ? 'embed' : 'runtime',
|
|
97
|
+
guidanceLabel: t('Locked · Edit in HTML · Preview plays it'), inline: INLINE_TAGS.has(el.tagName),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
el.children.forEach(child => walk(child, isLive, invalid));
|
|
103
|
+
};
|
|
104
|
+
elements.forEach(el => walk(el, false, false));
|
|
105
|
+
return unsupported || (blocks.length > 0 && elements.some(el => !el.sourceSupported))
|
|
106
|
+
? { ok: false, problem: "Can't edit this source visually. Use HTML." } : { ok: true, blocks, elements };
|
|
107
|
+
}
|
|
108
|
+
const escapeAttribute = (value) => value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
109
|
+
/**
|
|
110
|
+
* Capture before GrapesJS parses. Only a mount-private lookup token reaches the parser; authored
|
|
111
|
+
* source stays in model metadata. Style/script atoms use inert spans, never executable source tags.
|
|
112
|
+
*/
|
|
113
|
+
export function prepareEditorSource({ html }, options = {}) {
|
|
114
|
+
const detected = detectEditorBlocks({ html }, options);
|
|
115
|
+
if (!detected.ok)
|
|
116
|
+
return { ...detected, html };
|
|
117
|
+
const instances = detected.blocks.map(block => ({
|
|
118
|
+
...block, originalHtml: html.slice(block.outerStart, block.outerEnd),
|
|
119
|
+
openingTag: html.slice(block.outerStart, block.openingEnd), nonvisual: false,
|
|
120
|
+
}));
|
|
121
|
+
const blocked = new Set(detected.blocks.map(block => block.element));
|
|
122
|
+
const css = [];
|
|
123
|
+
let unsupported = false;
|
|
124
|
+
const walk = (el, enclosed, invalidAncestor) => {
|
|
125
|
+
const inside = enclosed || blocked.has(el);
|
|
126
|
+
const invalid = invalidAncestor || !el.outerRange;
|
|
127
|
+
if (el.tagName === 'style' && el.outerRange) {
|
|
128
|
+
const original = html.slice(el.outerRange.outerStart, el.outerRange.outerEnd);
|
|
129
|
+
const closingLength = original.length - (original.toLowerCase().lastIndexOf('</style'));
|
|
130
|
+
css.push(html.slice(el.outerRange.openingEnd, el.outerRange.outerEnd - closingLength));
|
|
131
|
+
}
|
|
132
|
+
if (!inside && (el.tagName === 'style' || el.tagName === 'script')) {
|
|
133
|
+
if (invalid)
|
|
134
|
+
unsupported = true;
|
|
135
|
+
else
|
|
136
|
+
instances.push({ ...el.outerRange, originalHtml: html.slice(el.outerRange.outerStart, el.outerRange.outerEnd),
|
|
137
|
+
openingTag: html.slice(el.outerRange.outerStart, el.outerRange.openingEnd), tagName: el.tagName, nonvisual: true });
|
|
138
|
+
}
|
|
139
|
+
el.children.forEach(child => walk(child, inside, invalid));
|
|
140
|
+
};
|
|
141
|
+
detected.elements.forEach(el => walk(el, false, false));
|
|
142
|
+
if (unsupported || (instances.length > 0 && detected.elements.some(el => !el.sourceSupported))) {
|
|
143
|
+
return { ok: false, html, problem: "Can't edit this source visually. Use HTML." };
|
|
144
|
+
}
|
|
145
|
+
instances.sort((a, b) => a.outerStart - b.outerStart);
|
|
146
|
+
let markerAttribute = 'data-jini-raw-source-0';
|
|
147
|
+
let namespace = 0;
|
|
148
|
+
while (html.toLowerCase().includes(markerAttribute))
|
|
149
|
+
markerAttribute = `data-jini-raw-source-${++namespace}`;
|
|
150
|
+
let prepared = html;
|
|
151
|
+
const roots = new Map(detected.blocks.map(block => [block.outerStart, block]));
|
|
152
|
+
for (let i = instances.length - 1; i >= 0; i--) {
|
|
153
|
+
const instance = instances[i];
|
|
154
|
+
const block = roots.get(instance.outerStart);
|
|
155
|
+
const attrs = block?.element.attributes ?? {};
|
|
156
|
+
// Data selectors may declare the root's geometry. They are inert without page scripts; keep
|
|
157
|
+
// them alongside box attributes, while resource URLs, event handlers, autoplay and GrapesJS
|
|
158
|
+
// parser directives stay absent. Authored data-gjs flags must not override the protected type.
|
|
159
|
+
const layout = Object.keys(attrs).filter(name => ['id', 'class', 'style', 'height', 'width'].includes(name)
|
|
160
|
+
|| (name.startsWith('data-') && !name.startsWith('data-gjs-')))
|
|
161
|
+
.map(name => ` ${name}="${escapeAttribute(attrs[name])}"`).join('');
|
|
162
|
+
const tag = instance.nonvisual ? 'span' : instance.tagName;
|
|
163
|
+
const voidTag = instance.openingEnd === instance.outerEnd;
|
|
164
|
+
const shell = `<${tag} ${markerAttribute}="${i}"${layout}>${voidTag && !instance.nonvisual ? '' : `</${tag}>`}`;
|
|
165
|
+
prepared = prepared.slice(0, instance.outerStart) + shell + prepared.slice(instance.outerEnd);
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, html: prepared, markerAttribute, instances, canvasCss: css.join('\n') };
|
|
168
|
+
}
|
|
169
|
+
/** Exact authored opening tag and its source offsets; no regex-based HTML parsing or id guessing. */
|
|
170
|
+
export function findEditorBlockOpening({ html, target }, _options = {}) {
|
|
171
|
+
let occurrence = 0;
|
|
172
|
+
let result;
|
|
173
|
+
const walk = (el) => {
|
|
174
|
+
if (el.tagName === target.tagName.toLowerCase() && (target.id === undefined || el.attributes.id === target.id)) {
|
|
175
|
+
if (occurrence++ === target.occurrenceIndex && el.outerRange) {
|
|
176
|
+
const { outerStart: start, openingEnd: end } = el.outerRange;
|
|
177
|
+
result = { start, end, openingTag: html.slice(start, end) };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
el.children.forEach(walk);
|
|
181
|
+
};
|
|
182
|
+
indexSourceElements({ html }, {}).forEach(walk);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=editor-blocks.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Component, Editor } from 'grapesjs';
|
|
2
|
+
import { type ElementPath } from './source-splice.js';
|
|
3
|
+
import type { PreparedEditorSource } from './editor-blocks.js';
|
|
4
|
+
/** One accumulated text edit, keyed by `pathKey(path)` in the caller's `dirty` map: the element-only
|
|
5
|
+
* path and tag name `spliceElementInner` needs to locate and verify the target, plus the edited
|
|
6
|
+
* component itself. Its inner HTML is read at serialize time, not recorded here: GrapesJS fires
|
|
7
|
+
* `rte:disable` BEFORE `syncContent` writes the edited text into the model, so a snapshot taken at
|
|
8
|
+
* that event is the OLD text. See `serializeWithSplice`'s own doc for why the map accumulates across
|
|
9
|
+
* the whole mount rather than resetting per edit. */
|
|
10
|
+
export interface DirtyTextEdit {
|
|
11
|
+
readonly path: ElementPath;
|
|
12
|
+
readonly tagName: string;
|
|
13
|
+
readonly component: Component;
|
|
14
|
+
}
|
|
15
|
+
/** Existing text-splice/full-export owner, also usable without a rendered canvas in contract tests. */
|
|
16
|
+
export declare function serializeEditorSource({ editor, html, edits, sourcePlan }: {
|
|
17
|
+
editor: Editor;
|
|
18
|
+
html: string;
|
|
19
|
+
edits: Map<string, DirtyTextEdit>;
|
|
20
|
+
sourcePlan: PreparedEditorSource;
|
|
21
|
+
}, { structuralChange }?: {
|
|
22
|
+
structuralChange?: string | undefined;
|
|
23
|
+
}): string;
|
|
24
|
+
//# sourceMappingURL=editor-serialization.d.ts.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { prettifyCss } from './css.js';
|
|
2
|
+
import { indexElementSourceRanges, pathKey, spliceElementInner } from './source-splice.js';
|
|
3
|
+
import { assertProtectedSourceMetadata, EditorSourceError } from './protected-elements.js';
|
|
4
|
+
/** Dev-only, so a fallback is never silent — see `serializeWithSplice` for each trigger this names. */
|
|
5
|
+
function warnFallback(reason) {
|
|
6
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
7
|
+
console.warn(`[useInteractiveHtmlEditor] falling back to full re-serialization: ${reason}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Fallback whole-document serialization for when the lossless splice (see `serializeWithSplice`)
|
|
12
|
+
* cannot be trusted. This is the "cleaned" formula from this repo's `pages-redo` plan ("Bug C"), and
|
|
13
|
+
* differs from the whole-document serialization this hook used before the splice existed:
|
|
14
|
+
* `avoidProtected: true` on `getCss()` drops GrapesJS's own `* { box-sizing: border-box; } body
|
|
15
|
+
* {margin: 0;}` block, which GrapesJS otherwise prepends even to a page with no CSS of its own; and
|
|
16
|
+
* `getWrapper().getInnerHTML()` — the wrapper's INNER content — omits the `<body>` tag `editor.
|
|
17
|
+
* getHtml()` serializes the wrapper component itself as. `avoidInlineStyle: false` and
|
|
18
|
+
* `forceClass: false` keep authored styles inline; `keepEmptyTextNodes: true` keeps whitespace gaps
|
|
19
|
+
* in the model (see `initInteractiveHtmlEditor`). JSON CSS export returns composed rule models,
|
|
20
|
+
* excluding the synthetic `#iXXXX` rules string export derives from those inline styles. Native
|
|
21
|
+
* `rule.toCSS()` retains composed rules' at-rule wrappers. Still not a byte-
|
|
22
|
+
* identical round trip — this is a full re-serialization from GrapesJS's component model, which
|
|
23
|
+
* cannot recover the original string's entities or authored formatting — just the least-lossy shape
|
|
24
|
+
* available when a true splice is not possible.
|
|
25
|
+
*
|
|
26
|
+
* `keepUnusedStyles: true` carries over unchanged from the serialization this replaces: real
|
|
27
|
+
* documents commonly carry `@media` blocks and `::before`/`::after` rules that don't correspond to
|
|
28
|
+
* any single "matched" component in the tree, which GrapesJS's default CSS export can otherwise drop.
|
|
29
|
+
* @complexity O(h + c) time and space for exported HTML h and composed CSS c.
|
|
30
|
+
*/
|
|
31
|
+
function serializeCleaned(editor) {
|
|
32
|
+
// GrapesJS's getCss typing omits its JSON return type; successful init supplies a wrapper.
|
|
33
|
+
const rules = editor.getCss({ json: true, avoidProtected: true, keepUnusedStyles: true });
|
|
34
|
+
const css = rules.map(rule => rule.toCSS()).join('');
|
|
35
|
+
const wrapper = editor.getWrapper();
|
|
36
|
+
// `wrapper` is undefined only before/without a successful init, which cannot be true once this
|
|
37
|
+
// hook's `editor` has fired `update` — defensive only, mirroring `serializeCleaned`'s own doc.
|
|
38
|
+
const html = wrapper ? wrapper.getInnerHTML() : editor.getHtml();
|
|
39
|
+
return css ? `<style>${prettifyCss(css)}</style>${html}` : html;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Builds this edit cycle's exported HTML. The default path splices every accumulated dirty text edit
|
|
43
|
+
* (see `recordDirtyTextEdit`) back into the ORIGINAL source string via `spliceElementInner` (see
|
|
44
|
+
* `source-splice.ts`), so every other byte — entities, attribute quoting, an untouched embed marker,
|
|
45
|
+
* unrelated formatting — passes through unchanged instead of being rebuilt from GrapesJS's component
|
|
46
|
+
* model. Falls back to `serializeCleaned` (and warns via `warnFallback`, so it is never silent)
|
|
47
|
+
* whenever that guarantee cannot be trusted:
|
|
48
|
+
* - `structuralChange` — a block was added or removed since mount (a block move, clone, or delete;
|
|
49
|
+
* a move fires a remove then an add). Its value names the first such event, for the warning. The
|
|
50
|
+
* child churn inside a text component that every RTE close causes does not count (see
|
|
51
|
+
* `isInsideTextComponent`); that is a text edit, which the splice covers. Every dirty path's offset was computed against the
|
|
52
|
+
* pristine `original` string; once the LIVE tree's shape has diverged from it, those offsets (and
|
|
53
|
+
* even the paths themselves) can silently address the wrong element instead of failing loudly, so
|
|
54
|
+
* this is checked first and unconditionally, regardless of what is or isn't in `dirty`. There is no
|
|
55
|
+
* path back to `false` once this flips — a later purely-textual edit still cannot trust offsets
|
|
56
|
+
* computed before the structural change, so the whole rest of the mount stays on the cleaned path.
|
|
57
|
+
* - `dirty` is empty — `update` fired (a real content/style/attribute mutation happened) but no
|
|
58
|
+
* text-component edit was ever recorded for it. Nothing addressable means nothing spliceable.
|
|
59
|
+
* - any individual `spliceElementInner` call returns `null` — that path's element is no longer where
|
|
60
|
+
* it was, or its tag no longer matches what the live component believes.
|
|
61
|
+
*
|
|
62
|
+
* Dirty edits are applied in descending original-offset order (highest first): a splice at a later
|
|
63
|
+
* offset never shifts the byte positions of anything before it, so replaying strictly back-to-front
|
|
64
|
+
* lets every subsequent `spliceElementInner` call re-locate its own (still-untouched-until-its-turn)
|
|
65
|
+
* range correctly without this function tracking running offset deltas itself.
|
|
66
|
+
*
|
|
67
|
+
* @complexity O(n·k) where n = size of `original` (each of up to k dirty splices re-indexes the
|
|
68
|
+
* current string internally) and k = number of dirty edits accumulated this mount — small in
|
|
69
|
+
* practice (an operator edits a handful of elements per session), never a hot loop here.
|
|
70
|
+
*/
|
|
71
|
+
function serializeWithSplice(editor, original, dirty, structuralChange, sourcePlan) {
|
|
72
|
+
if (structuralChange) {
|
|
73
|
+
warnFallback(`a block was added, moved, copied or deleted since mount (first: ${structuralChange})`);
|
|
74
|
+
return serializeCleaned(editor);
|
|
75
|
+
}
|
|
76
|
+
if (dirty.size === 0) {
|
|
77
|
+
warnFallback('no text edit was recorded for this change');
|
|
78
|
+
return serializeCleaned(editor);
|
|
79
|
+
}
|
|
80
|
+
const originalIndex = indexElementSourceRanges(original);
|
|
81
|
+
const ordered = [...dirty.values()].sort((a, b) => {
|
|
82
|
+
const aStart = originalIndex.get(pathKey(a.path))?.start ?? -1;
|
|
83
|
+
const bStart = originalIndex.get(pathKey(b.path))?.start ?? -1;
|
|
84
|
+
return bStart - aStart;
|
|
85
|
+
});
|
|
86
|
+
let result = original;
|
|
87
|
+
for (const edit of ordered) {
|
|
88
|
+
const range = originalIndex.get(pathKey(edit.path));
|
|
89
|
+
if (range && sourcePlan?.instances.some(raw => range.start < raw.outerEnd && range.end > raw.outerStart)) {
|
|
90
|
+
warnFallback('text edit overlaps a protected source region');
|
|
91
|
+
return serializeCleaned(editor);
|
|
92
|
+
}
|
|
93
|
+
const spliced = spliceElementInner(result, edit.path, edit.tagName, edit.component.getInnerHTML());
|
|
94
|
+
if (spliced === null) {
|
|
95
|
+
warnFallback(`element at path "${pathKey(edit.path)}" could not be spliced (removed, or its tag drifted)`);
|
|
96
|
+
return serializeCleaned(editor);
|
|
97
|
+
}
|
|
98
|
+
result = spliced;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
/** Existing text-splice/full-export owner, also usable without a rendered canvas in contract tests. */
|
|
103
|
+
export function serializeEditorSource({ editor, html, edits, sourcePlan }, { structuralChange } = {}) {
|
|
104
|
+
assertProtectedSourceMetadata({ editor }, {});
|
|
105
|
+
const result = serializeWithSplice(editor, html, edits, structuralChange, sourcePlan);
|
|
106
|
+
if (result.includes(sourcePlan.markerAttribute))
|
|
107
|
+
throw new EditorSourceError();
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=editor-serialization.js.map
|
|
@@ -5,4 +5,9 @@ export type { CanvasContentWrapperNode, CanvasStyling } from './canvas-style.js'
|
|
|
5
5
|
export type { CanvasEmbedPlaceholderDescriptor } from './canvas-embed-placeholders.js';
|
|
6
6
|
export { hasAttributeOnAnyNodeShape } from './dom.js';
|
|
7
7
|
export { prettifyCss } from './css.js';
|
|
8
|
+
export { detectEditorBlocks, prepareEditorSource, findEditorBlockOpening } from './editor-blocks.js';
|
|
9
|
+
export type { EditorHtmlTarget, RequestEditHtml, EditorSourcePlan, PreparedEditorSource } from './editor-blocks.js';
|
|
10
|
+
export { EditorSourceError, isEditorSourceError, registerProtectedElementType, protectTextAncestors } from './protected-elements.js';
|
|
11
|
+
export { serializeEditorSource } from './editor-serialization.js';
|
|
12
|
+
export type { DirtyTextEdit } from './editor-serialization.js';
|
|
8
13
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -2,4 +2,7 @@ export { InteractiveHtmlEditor } from './react/components/InteractiveHtmlEditor.
|
|
|
2
2
|
export { useInteractiveHtmlEditor } from './react/hooks/useInteractiveHtmlEditor.js';
|
|
3
3
|
export { hasAttributeOnAnyNodeShape } from './dom.js';
|
|
4
4
|
export { prettifyCss } from './css.js';
|
|
5
|
+
export { detectEditorBlocks, prepareEditorSource, findEditorBlockOpening } from './editor-blocks.js';
|
|
6
|
+
export { EditorSourceError, isEditorSourceError, registerProtectedElementType, protectTextAncestors } from './protected-elements.js';
|
|
7
|
+
export { serializeEditorSource } from './editor-serialization.js';
|
|
5
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Component, Editor } from 'grapesjs';
|
|
2
|
+
import type { CanvasEmbedPlaceholderDescriptor } from './canvas-embed-placeholders.js';
|
|
3
|
+
import { type PreparedEditorSource, type RequestEditHtml } from './editor-blocks.js';
|
|
4
|
+
export declare const PROTECTED_ELEMENT_TYPE = "protected-element";
|
|
5
|
+
/** Distinguishable from an ordinary best-effort RTE timeout: never persist a damaged raw shell. */
|
|
6
|
+
export declare class EditorSourceError extends Error {
|
|
7
|
+
readonly code = "EDITOR_SOURCE_INTEGRITY";
|
|
8
|
+
constructor({ message }?: {
|
|
9
|
+
message?: string;
|
|
10
|
+
}, _options?: object);
|
|
11
|
+
}
|
|
12
|
+
export declare function isEditorSourceError({ error }: {
|
|
13
|
+
error: unknown;
|
|
14
|
+
}, _options?: object): boolean;
|
|
15
|
+
/** Source lives only on the component model, so clone and undo carry it with ordinary model state. */
|
|
16
|
+
export declare function protectedElementHtml({ component }: {
|
|
17
|
+
component: Pick<Component, 'get'>;
|
|
18
|
+
}, _options?: object): string;
|
|
19
|
+
/** Check surviving instances before any export or no-op flush; deletion itself is allowed. */
|
|
20
|
+
export declare function assertProtectedSourceMetadata({ editor }: {
|
|
21
|
+
editor: Editor;
|
|
22
|
+
}, _options?: object): void;
|
|
23
|
+
/** Verify initial parser ownership before the first visual edit; recovered/lost shells fail closed. */
|
|
24
|
+
export declare function assertPreparedSourceModel({ editor, sourcePlan }: {
|
|
25
|
+
editor: Editor;
|
|
26
|
+
sourcePlan: PreparedEditorSource;
|
|
27
|
+
}, _options?: object): void;
|
|
28
|
+
/** Text ancestors must not sync canvas-only card chrome back into a model during RTE close. */
|
|
29
|
+
export declare function protectTextAncestors({ editor }: {
|
|
30
|
+
editor: Editor;
|
|
31
|
+
}, _options?: object): void;
|
|
32
|
+
/** Same canvas card path, addressed through model metadata rather than authored/public selectors. */
|
|
33
|
+
export declare function protectedCanvasDescriber({ editor }: {
|
|
34
|
+
editor: Editor;
|
|
35
|
+
}, _options?: object): (el: Element) => CanvasEmbedPlaceholderDescriptor | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Registers the `protected-element` component type so any parsed element `isProtectedElement`
|
|
38
|
+
* recognizes becomes one atomic block: its content can never be edited (excluded from RTE
|
|
39
|
+
* content-editing, including as a nested island inside a `text` component), dropped into, or styled,
|
|
40
|
+
* but the element itself is selectable and can be deliberately moved, copied or deleted like any other
|
|
41
|
+
* block. Selectable matters for safety: a non-selectable element hands a click to its nearest
|
|
42
|
+
* selectable ancestor, so clicking an embed selected the block around it and Delete then removed that
|
|
43
|
+
* block together with the embed. Pair it with `applyCanvasEmbedPlaceholders`, which makes the marker
|
|
44
|
+
* the clickable surface of its card. Passed as a `plugins` entry so it registers before
|
|
45
|
+
* `editor.setComponents` parses the initial `html` (see `initInteractiveHtmlEditor` below).
|
|
46
|
+
*
|
|
47
|
+
* @complexity O(1) — one type registration; the per-element check GrapesJS then runs during parsing
|
|
48
|
+
* is O(1) per element (whatever `isProtectedElement` costs), not owned by this function.
|
|
49
|
+
* @overallScore 100
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* One protected type for runtime roots, host embeds and nonvisual atoms. Registration precedes
|
|
53
|
+
* parsing; isComponent resolves mount-private tokens to metadata and supplies empty children.
|
|
54
|
+
*/
|
|
55
|
+
export declare function registerProtectedElementType({ editor, sourcePlan }: {
|
|
56
|
+
editor: Editor;
|
|
57
|
+
sourcePlan: PreparedEditorSource;
|
|
58
|
+
}, options?: {
|
|
59
|
+
onRequestEditHtml?: RequestEditHtml | undefined;
|
|
60
|
+
editHtmlLabel?: string | undefined;
|
|
61
|
+
beforeRequestEditHtml?: () => Promise<void>;
|
|
62
|
+
onSerializationError?: ((error: EditorSourceError) => void) | undefined;
|
|
63
|
+
}): void;
|
|
64
|
+
//# sourceMappingURL=protected-elements.d.ts.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { findEditorBlockOpening } from './editor-blocks.js';
|
|
2
|
+
export const PROTECTED_ELEMENT_TYPE = 'protected-element';
|
|
3
|
+
/** Distinguishable from an ordinary best-effort RTE timeout: never persist a damaged raw shell. */
|
|
4
|
+
export class EditorSourceError extends Error {
|
|
5
|
+
code = 'EDITOR_SOURCE_INTEGRITY';
|
|
6
|
+
constructor({ message = "Can't edit this source visually. Use HTML." } = {}, _options = {}) { super(message); this.name = 'EditorSourceError'; }
|
|
7
|
+
}
|
|
8
|
+
export function isEditorSourceError({ error }, _options = {}) {
|
|
9
|
+
return !!error && typeof error === 'object' && 'code' in error && error.code === 'EDITOR_SOURCE_INTEGRITY';
|
|
10
|
+
}
|
|
11
|
+
/** Source lives only on the component model, so clone and undo carry it with ordinary model state. */
|
|
12
|
+
export function protectedElementHtml({ component }, _options = {}) {
|
|
13
|
+
const original = component.get('originalHtml');
|
|
14
|
+
if (typeof original !== 'string' || original.length === 0)
|
|
15
|
+
throw new EditorSourceError();
|
|
16
|
+
return original;
|
|
17
|
+
}
|
|
18
|
+
function visit(component, callback) {
|
|
19
|
+
callback(component);
|
|
20
|
+
component.components().models.forEach(child => visit(child, callback));
|
|
21
|
+
}
|
|
22
|
+
/** Check surviving instances before any export or no-op flush; deletion itself is allowed. */
|
|
23
|
+
export function assertProtectedSourceMetadata({ editor }, _options = {}) {
|
|
24
|
+
const wrapper = editor.getWrapper();
|
|
25
|
+
if (wrapper)
|
|
26
|
+
visit(wrapper, component => {
|
|
27
|
+
if (component.is(PROTECTED_ELEMENT_TYPE))
|
|
28
|
+
protectedElementHtml({ component }, {});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Verify initial parser ownership before the first visual edit; recovered/lost shells fail closed. */
|
|
32
|
+
export function assertPreparedSourceModel({ editor, sourcePlan }, _options = {}) {
|
|
33
|
+
const actual = [];
|
|
34
|
+
const wrapper = editor.getWrapper();
|
|
35
|
+
if (!wrapper)
|
|
36
|
+
throw new EditorSourceError();
|
|
37
|
+
visit(wrapper, component => {
|
|
38
|
+
if (component.is(PROTECTED_ELEMENT_TYPE))
|
|
39
|
+
actual.push(protectedElementHtml({ component }, {}));
|
|
40
|
+
});
|
|
41
|
+
if (actual.length !== sourcePlan.instances.length || actual.some((html, i) => html !== sourcePlan.instances[i].originalHtml)) {
|
|
42
|
+
throw new EditorSourceError();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Text ancestors must not sync canvas-only card chrome back into a model during RTE close. */
|
|
46
|
+
export function protectTextAncestors({ editor }, _options = {}) {
|
|
47
|
+
const wrapper = editor.getWrapper();
|
|
48
|
+
if (!wrapper)
|
|
49
|
+
return;
|
|
50
|
+
visit(wrapper, component => {
|
|
51
|
+
if (!component.is(PROTECTED_ELEMENT_TYPE))
|
|
52
|
+
return;
|
|
53
|
+
for (let parent = component.parent(); parent; parent = parent.parent()) {
|
|
54
|
+
if (parent.is('text'))
|
|
55
|
+
parent.set('editable', false, { silent: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Same canvas card path, addressed through model metadata rather than authored/public selectors. */
|
|
60
|
+
export function protectedCanvasDescriber({ editor }, _options = {}) {
|
|
61
|
+
const descriptors = new Map();
|
|
62
|
+
const wrapper = editor.getWrapper();
|
|
63
|
+
if (wrapper)
|
|
64
|
+
visit(wrapper, component => {
|
|
65
|
+
if (!component.is(PROTECTED_ELEMENT_TYPE) || component.get('sourceNonvisual'))
|
|
66
|
+
return;
|
|
67
|
+
const el = component.getEl();
|
|
68
|
+
const descriptor = component.get('placeholderDescriptor');
|
|
69
|
+
if (el && descriptor)
|
|
70
|
+
descriptors.set(el, descriptor);
|
|
71
|
+
});
|
|
72
|
+
return el => descriptors.get(el);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Registers the `protected-element` component type so any parsed element `isProtectedElement`
|
|
76
|
+
* recognizes becomes one atomic block: its content can never be edited (excluded from RTE
|
|
77
|
+
* content-editing, including as a nested island inside a `text` component), dropped into, or styled,
|
|
78
|
+
* but the element itself is selectable and can be deliberately moved, copied or deleted like any other
|
|
79
|
+
* block. Selectable matters for safety: a non-selectable element hands a click to its nearest
|
|
80
|
+
* selectable ancestor, so clicking an embed selected the block around it and Delete then removed that
|
|
81
|
+
* block together with the embed. Pair it with `applyCanvasEmbedPlaceholders`, which makes the marker
|
|
82
|
+
* the clickable surface of its card. Passed as a `plugins` entry so it registers before
|
|
83
|
+
* `editor.setComponents` parses the initial `html` (see `initInteractiveHtmlEditor` below).
|
|
84
|
+
*
|
|
85
|
+
* @complexity O(1) — one type registration; the per-element check GrapesJS then runs during parsing
|
|
86
|
+
* is O(1) per element (whatever `isProtectedElement` costs), not owned by this function.
|
|
87
|
+
* @overallScore 100
|
|
88
|
+
*/
|
|
89
|
+
/**
|
|
90
|
+
* One protected type for runtime roots, host embeds and nonvisual atoms. Registration precedes
|
|
91
|
+
* parsing; isComponent resolves mount-private tokens to metadata and supplies empty children.
|
|
92
|
+
*/
|
|
93
|
+
export function registerProtectedElementType({ editor, sourcePlan }, options = {}) {
|
|
94
|
+
const command = 'jini:protected-edit-html';
|
|
95
|
+
const requestEditHtml = options.onRequestEditHtml;
|
|
96
|
+
if (requestEditHtml)
|
|
97
|
+
editor.Commands.add(command, {
|
|
98
|
+
async run() {
|
|
99
|
+
try {
|
|
100
|
+
await options.beforeRequestEditHtml?.();
|
|
101
|
+
const selected = editor.getSelected();
|
|
102
|
+
const wrapper = editor.getWrapper();
|
|
103
|
+
if (!selected?.is(PROTECTED_ELEMENT_TYPE) || !wrapper || selected.get('sourceNonvisual'))
|
|
104
|
+
return;
|
|
105
|
+
const original = protectedElementHtml({ component: selected }, {});
|
|
106
|
+
const html = wrapper.getInnerHTML();
|
|
107
|
+
// Search only exact authored slices. Copies share bytes, so count preceding equal instances
|
|
108
|
+
// in model order; no mapping export mode or persistent source tokens are needed.
|
|
109
|
+
let copiesBefore = 0;
|
|
110
|
+
let reached = false;
|
|
111
|
+
visit(wrapper, component => {
|
|
112
|
+
if (component === selected)
|
|
113
|
+
reached = true;
|
|
114
|
+
else if (!reached && component.is(PROTECTED_ELEMENT_TYPE) && protectedElementHtml({ component }, {}) === original)
|
|
115
|
+
copiesBefore++;
|
|
116
|
+
});
|
|
117
|
+
let start = -1;
|
|
118
|
+
for (let i = 0; i <= copiesBefore; i++)
|
|
119
|
+
start = html.indexOf(original, start + 1);
|
|
120
|
+
if (start < 0)
|
|
121
|
+
throw new EditorSourceError();
|
|
122
|
+
const tagName = selected.get('sourceTagName');
|
|
123
|
+
const id = selected.get('sourceId');
|
|
124
|
+
let occurrenceIndex = 0;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const range = findEditorBlockOpening({ html, target: { occurrenceIndex, tagName, ...(id ? { id } : {}) } }, {});
|
|
127
|
+
if (!range || range.start > start)
|
|
128
|
+
throw new EditorSourceError();
|
|
129
|
+
if (range.start === start)
|
|
130
|
+
break;
|
|
131
|
+
occurrenceIndex++;
|
|
132
|
+
}
|
|
133
|
+
await requestEditHtml({ occurrenceIndex, tagName, ...(id ? { id } : {}) }, {});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
options.onSerializationError?.(error instanceof EditorSourceError ? error : new EditorSourceError());
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
editor.Components.addType(PROTECTED_ELEMENT_TYPE, {
|
|
141
|
+
isComponent(el) {
|
|
142
|
+
const token = typeof el.getAttribute === 'function' ? el.getAttribute(sourcePlan.markerAttribute)
|
|
143
|
+
: el.attributes?.[sourcePlan.markerAttribute];
|
|
144
|
+
if (token === null || token === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
const instance = sourcePlan.instances[Number(token)];
|
|
147
|
+
if (!instance || String(Number(token)) !== token)
|
|
148
|
+
throw new EditorSourceError();
|
|
149
|
+
return {
|
|
150
|
+
type: PROTECTED_ELEMENT_TYPE, originalHtml: instance.originalHtml, sourceTagName: instance.tagName,
|
|
151
|
+
sourceId: instance.id, sourceNonvisual: instance.nonvisual, placeholderDescriptor: instance.descriptor,
|
|
152
|
+
components: [], content: '',
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
model: {
|
|
156
|
+
defaults: {
|
|
157
|
+
editable: false, droppable: false, stylable: false, resizable: false, traits: [],
|
|
158
|
+
layerable: false, badgable: false, selectable: true, hoverable: true, highlightable: true,
|
|
159
|
+
removable: true, draggable: true, copyable: true,
|
|
160
|
+
},
|
|
161
|
+
init() {
|
|
162
|
+
// Keep default move/copy/delete toolbar commands, then append the host navigation action.
|
|
163
|
+
if (!this.get('sourceNonvisual') && options.onRequestEditHtml) {
|
|
164
|
+
const toolbar = (this.get('toolbar') ?? []);
|
|
165
|
+
this.set('toolbar', [...toolbar.filter(item => item.command !== command), {
|
|
166
|
+
label: options.editHtmlLabel ?? 'Edit HTML', command,
|
|
167
|
+
// GrapesJS sizes every toolbar item as a 26px icon square; a text label overflowed past the
|
|
168
|
+
// toolbar (and the canvas edge on full-width blocks), so this item sizes to its label.
|
|
169
|
+
attributes: {
|
|
170
|
+
title: options.editHtmlLabel ?? 'Edit HTML', 'aria-label': options.editHtmlLabel ?? 'Edit HTML',
|
|
171
|
+
style: 'width:auto;white-space:nowrap;padding:5px 8px',
|
|
172
|
+
},
|
|
173
|
+
}], { silent: true });
|
|
174
|
+
}
|
|
175
|
+
if (this.get('sourceNonvisual'))
|
|
176
|
+
this.set({ selectable: false, draggable: false, hoverable: false, copyable: false }, { silent: true });
|
|
177
|
+
},
|
|
178
|
+
toHTML() { return protectedElementHtml({ component: this }, {}); },
|
|
179
|
+
},
|
|
180
|
+
view: {
|
|
181
|
+
onRender() {
|
|
182
|
+
this.el.setAttribute('contenteditable', 'false');
|
|
183
|
+
if (this.model.get('sourceNonvisual'))
|
|
184
|
+
this.el.style.setProperty('display', 'none', 'important');
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=protected-elements.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'grapesjs/dist/css/grapes.min.css';
|
|
2
2
|
import type { CanvasEmbedPlaceholderDescriptor } from '../../canvas-embed-placeholders.js';
|
|
3
3
|
import type { CanvasStyling } from '../../canvas-style.js';
|
|
4
|
+
import { type InteractiveEditorOptions } from '../hooks/useInteractiveHtmlEditor.js';
|
|
4
5
|
/**
|
|
5
6
|
* @file A visual, click-into-text editing surface for a bespoke HTML document, built on GrapesJS
|
|
6
7
|
* (MIT). Renders `html` like a live preview but lets the operator click into rendered text and edit
|
|
@@ -32,7 +33,8 @@ import type { CanvasStyling } from '../../canvas-style.js';
|
|
|
32
33
|
* is an optional predicate the caller supplies to turn specific elements (identified however the
|
|
33
34
|
* caller likes) into atomic blocks: their content can never be edited, dropped into or styled, but a
|
|
34
35
|
* click selects the element itself (never the block around it), and it can be deliberately moved,
|
|
35
|
-
* copied or deleted.
|
|
36
|
+
* copied or deleted. Runtime/native and author-opted blocks are always protected; the predicate
|
|
37
|
+
* adds host-owned embed recognition.
|
|
36
38
|
* `describeEmbedPlaceholder` is the same idea for a second, independent concern: recognizing an
|
|
37
39
|
* unresolved embed marker and labeling it for `../../canvas-embed-placeholders.ts`'s explicit
|
|
38
40
|
* placeholder card. See `@jini-ai/admin/react`'s `InteractiveHtmlEditor` for a worked example of both:
|
|
@@ -66,7 +68,7 @@ import type { CanvasStyling } from '../../canvas-style.js';
|
|
|
66
68
|
* guaranteed by WHEN it is applied (against the live canvas, after GrapesJS has already parsed
|
|
67
69
|
* `html`), not by a strip-on-export step.
|
|
68
70
|
*/
|
|
69
|
-
export interface InteractiveHtmlEditorProps {
|
|
71
|
+
export interface InteractiveHtmlEditorProps extends InteractiveEditorOptions {
|
|
70
72
|
/** The document to load into the editor. Read once, at mount — see this file's header and
|
|
71
73
|
* `react/hooks/useInteractiveHtmlEditor.ts` for why. */
|
|
72
74
|
html: string;
|
|
@@ -79,7 +81,7 @@ export interface InteractiveHtmlEditorProps {
|
|
|
79
81
|
/** Identifies elements whose content must never be edited or dropped into inside the editor. Each
|
|
80
82
|
* one is selectable as a single block that can be deliberately moved, copied or deleted. Read once, at mount, same as `html`. Predicates that check attributes should use
|
|
81
83
|
* `hasAttributeOnAnyNodeShape` (exported alongside this component) rather than `el.hasAttribute`
|
|
82
|
-
* directly — see that helper's own doc for why. Omit
|
|
84
|
+
* directly — see that helper's own doc for why. Omit for runtime/native blocks only. */
|
|
83
85
|
isProtectedElement?: (el: Element) => boolean;
|
|
84
86
|
/** CSS to render the edited document against inside the editing canvas, so the operator sees
|
|
85
87
|
* roughly what the document publishes as rather than browser defaults. Read once, at mount, same
|
|
@@ -90,7 +92,7 @@ export interface InteractiveHtmlEditorProps {
|
|
|
90
92
|
* fallback content it authored) — see `../../canvas-embed-placeholders.ts`'s own file header for
|
|
91
93
|
* the export-safety argument. Read once, at mount, same as `isProtectedElement`. Returning
|
|
92
94
|
* `undefined` for an element means "not a marker this host owns" — left completely untouched.
|
|
93
|
-
* Omit
|
|
95
|
+
* Omit for runtime/native cards only. This component has the same zero knowledge
|
|
94
96
|
* of any host's marker convention that `isProtectedElement` documents above. */
|
|
95
97
|
describeEmbedPlaceholder?: (el: Element) => CanvasEmbedPlaceholderDescriptor | undefined;
|
|
96
98
|
}
|