Add shared automation and WebUI editing primitives

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent f0898fcdee
commit 5b55f59a92
42 changed files with 3788 additions and 554 deletions
+767
View File
@@ -0,0 +1,767 @@
import {
mergeAttributes,
Node,
nodeInputRule,
nodePasteRule,
type Editor
} from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
Bold,
Code2,
ImagePlus,
Italic,
Link2,
Link2Off,
List,
ListOrdered,
Quote,
Redo2,
RemoveFormatting,
Strikethrough,
Underline,
Undo2
} from "lucide-react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type MouseEvent
} from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import Button from "./Button";
import Dialog from "./Dialog";
import FormField from "./FormField";
import IconButton from "./IconButton";
import SegmentedControl from "./SegmentedControl";
import { normalizeWysiwygImageUrl, normalizeWysiwygLinkUrl } from "./wysiwygEditorUrls";
export type WysiwygEditorMode = "visual" | "source";
export type WysiwygEditorToken = {
value: string;
label?: string;
};
export type WysiwygEditorHandle = {
focus: () => void;
insertText: (value: string) => boolean;
insertToken: (token: WysiwygEditorToken | string) => boolean;
};
export type WysiwygEditorLabels = {
editor: string;
visual: string;
source: string;
blockStyle: string;
paragraph: string;
heading1: string;
heading2: string;
heading3: string;
undo: string;
redo: string;
bold: string;
italic: string;
underline: string;
strike: string;
inlineCodeLabel: string;
bulletList: string;
numberedList: string;
quote: string;
insertLink: string;
removeLink: string;
insertImage: string;
clearFormatting: string;
linkText: string;
linkUrlLabel: string;
imageUrlLabel: string;
alternativeText: string;
apply: string;
cancel: string;
invalidLink: string;
invalidImage: string;
sourceWarning: string;
};
export type WysiwygEditorProps = {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
defaultMode?: WysiwygEditorMode;
allowSourceMode?: boolean;
ariaLabel?: string;
className?: string;
minHeight?: number | string;
sourceRows?: number;
labels?: Partial<WysiwygEditorLabels>;
onFocus?: () => void;
};
type BlockStyle = "paragraph" | "heading-1" | "heading-2" | "heading-3";
const INLINE_TOKEN_NAME = "govoplanInlineToken";
const TOKEN_INPUT_PATTERN = /((?:\{\{[^{}\r\n]+\}\}|\$\{[^{}\r\n]+\}))$/;
const TOKEN_PASTE_PATTERN = /(?:\{\{[^{}\r\n]+\}\}|\$\{[^{}\r\n]+\})/g;
const TOKEN_SCAN_PATTERN = /(?:\{\{[^{}\r\n]+\}\}|\$\{[^{}\r\n]+\})/g;
const DEFAULT_LABELS: WysiwygEditorLabels = {
editor: "i18n:govoplan-core.rich_text_editor.b56157a6",
visual: "i18n:govoplan-core.visual.770d690e",
source: "i18n:govoplan-core.html_source.1d4100b2",
blockStyle: "i18n:govoplan-core.block_style.c050e2eb",
paragraph: "i18n:govoplan-core.paragraph.05058e0b",
heading1: "i18n:govoplan-core.heading_1.fdc7d47a",
heading2: "i18n:govoplan-core.heading_2.6542d386",
heading3: "i18n:govoplan-core.heading_3.9694d18f",
undo: "i18n:govoplan-core.undo.39fc7212",
redo: "i18n:govoplan-core.redo.471b94d4",
bold: "i18n:govoplan-core.bold.19e07430",
italic: "i18n:govoplan-core.italic.1616e2e5",
underline: "i18n:govoplan-core.underline.39773aa3",
strike: "i18n:govoplan-core.strikethrough.a93b9a68",
inlineCodeLabel: "i18n:govoplan-core.inline_code.6f3a4263",
bulletList: "i18n:govoplan-core.bullet_list.660d9fbe",
numberedList: "i18n:govoplan-core.numbered_list.8294ea4f",
quote: "i18n:govoplan-core.block_quote.b3399cd3",
insertLink: "i18n:govoplan-core.insert_link.4d4ac9a1",
removeLink: "i18n:govoplan-core.remove_link.8d48d1f9",
insertImage: "i18n:govoplan-core.insert_image.c8141eb5",
clearFormatting: "i18n:govoplan-core.clear_formatting.6c8a26c4",
linkText: "i18n:govoplan-core.link_text.502f872d",
linkUrlLabel: "i18n:govoplan-core.link_url.08738f6e",
imageUrlLabel: "i18n:govoplan-core.image_url.35c80f96",
alternativeText: "i18n:govoplan-core.alternative_text.8d87ae1b",
apply: "i18n:govoplan-core.apply.cfea419c",
cancel: "i18n:govoplan-core.cancel.77dfd213",
invalidLink: "i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137",
invalidImage: "i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c",
sourceWarning: "i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c"
};
const InlineToken = Node.create({
name: INLINE_TOKEN_NAME,
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
value: { default: "" },
label: { default: "" }
};
},
parseHTML() {
return [{
tag: "span[data-govoplan-token]",
getAttrs: (element) => {
if (!(element instanceof HTMLElement)) return false;
const value = element.dataset.govoplanToken || "";
if (!value) return false;
return {
value,
label: element.dataset.govoplanTokenLabel || tokenLabel(value)
};
}
}];
},
renderHTML({ node, HTMLAttributes }) {
const value = String(node.attrs.value || "");
const label = String(node.attrs.label || tokenLabel(value));
return [
"span",
mergeAttributes(HTMLAttributes, {
class: "wysiwyg-inline-token",
"data-govoplan-token": value,
"data-govoplan-token-label": label,
contenteditable: "false"
}),
label
];
},
addInputRules() {
return [nodeInputRule({
find: TOKEN_INPUT_PATTERN,
type: this.type,
getAttributes: (match) => tokenAttributes(match[1] || match[0])
})];
},
addPasteRules() {
return [nodePasteRule({
find: TOKEN_PASTE_PATTERN,
type: this.type,
getAttributes: (match) => tokenAttributes(match[0])
})];
}
});
const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(function WysiwygEditor({
value,
onChange,
disabled = false,
defaultMode = "visual",
allowSourceMode = true,
ariaLabel,
className = "",
minHeight = 300,
sourceRows = 16,
labels,
onFocus
}, forwardedRef) {
const { translateText } = usePlatformLanguage();
const translatedLabels = useMemo(
() => translateLabels({ ...DEFAULT_LABELS, ...labels }, translateText),
[labels, translateText]
);
const translatedAriaLabel = translateText(ariaLabel || translatedLabels.editor);
const [mode, setMode] = useState<WysiwygEditorMode>(() => {
if (!allowSourceMode) return "visual";
if (defaultMode === "source" || hasUnsupportedVisualMarkup(value)) return "source";
return "visual";
});
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkText, setLinkText] = useState("");
const [linkError, setLinkError] = useState("");
const [imageDialogOpen, setImageDialogOpen] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const [imageAlt, setImageAlt] = useState("");
const [imageError, setImageError] = useState("");
const [editingImage, setEditingImage] = useState(false);
const sourceRef = useRef<HTMLTextAreaElement | null>(null);
const onChangeRef = useRef(onChange);
const onFocusRef = useRef(onFocus);
const valueRef = useRef(value);
const appliedValueRef = useRef(value);
const modeWasChosenRef = useRef(false);
const linkSelectionRef = useRef({ from: 0, to: 0 });
onChangeRef.current = onChange;
onFocusRef.current = onFocus;
valueRef.current = value;
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
link: {
openOnClick: false,
autolink: true,
defaultProtocol: "https",
HTMLAttributes: {
rel: "noopener noreferrer",
target: "_blank"
}
}
}),
Image.configure({ allowBase64: true }),
InlineToken
],
content: decorateTokensForEditor(value),
editable: !disabled,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": translatedAriaLabel,
"aria-multiline": "true",
class: "wysiwyg-prosemirror",
role: "textbox"
}
},
onFocus: () => onFocusRef.current?.(),
onUpdate: ({ editor: currentEditor }) => {
const nextValue = editorHtmlValue(currentEditor);
appliedValueRef.current = nextValue;
onChangeRef.current(nextValue);
}
});
const toolbarState = useEditorState({
editor,
selector: ({ editor: currentEditor }) => ({
blockStyle: activeBlockStyle(currentEditor),
bold: currentEditor.isActive("bold"),
italic: currentEditor.isActive("italic"),
underline: currentEditor.isActive("underline"),
strike: currentEditor.isActive("strike"),
code: currentEditor.isActive("code"),
bulletList: currentEditor.isActive("bulletList"),
numberedList: currentEditor.isActive("orderedList"),
quote: currentEditor.isActive("blockquote"),
link: currentEditor.isActive("link"),
canUndo: currentEditor.can().chain().undo().run(),
canRedo: currentEditor.can().chain().redo().run()
})
});
const unsupportedSource = useMemo(() => hasUnsupportedVisualMarkup(value), [value]);
const editorStyle = {
"--wysiwyg-editor-min-height": typeof minHeight === "number" ? `${minHeight}px` : minHeight
} as CSSProperties;
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
useEffect(() => {
editor.setOptions({
editorProps: {
attributes: {
"aria-label": translatedAriaLabel,
"aria-multiline": "true",
class: "wysiwyg-prosemirror",
role: "textbox"
}
}
});
}, [editor, translatedAriaLabel]);
useEffect(() => {
if (value === appliedValueRef.current) return;
appliedValueRef.current = value;
if (mode === "visual") {
editor.commands.setContent(decorateTokensForEditor(value), { emitUpdate: false });
}
}, [editor, mode, value]);
useEffect(() => {
if (
!allowSourceMode
|| mode !== "visual"
|| modeWasChosenRef.current
|| !hasUnsupportedVisualMarkup(value)
) return;
setMode("source");
}, [allowSourceMode, mode, value]);
useImperativeHandle(forwardedRef, () => ({
focus() {
if (mode === "source") sourceRef.current?.focus();
else editor.commands.focus();
},
insertText(text) {
if (disabled || !text) return false;
if (mode === "source") return insertIntoSource(text, valueRef.current, sourceRef.current, onChangeRef.current);
return editor.chain().focus().insertContent(text).run();
},
insertToken(token) {
if (disabled) return false;
const normalized = typeof token === "string" ? tokenAttributes(token) : tokenAttributes(token.value, token.label);
if (!normalized.value) return false;
if (mode === "source") {
return insertIntoSource(normalized.value, valueRef.current, sourceRef.current, onChangeRef.current);
}
return editor.chain().focus().insertContent({
type: INLINE_TOKEN_NAME,
attrs: normalized
}).run();
}
}), [disabled, editor, mode]);
function selectMode(nextMode: WysiwygEditorMode) {
if (nextMode === mode || (!allowSourceMode && nextMode === "source")) return;
modeWasChosenRef.current = true;
if (nextMode === "visual") {
appliedValueRef.current = valueRef.current;
editor.commands.setContent(decorateTokensForEditor(valueRef.current), { emitUpdate: false });
setMode("visual");
window.requestAnimationFrame(() => editor.commands.focus("end"));
return;
}
const nextValue = editorHtmlValue(editor);
appliedValueRef.current = nextValue;
if (nextValue !== valueRef.current) onChangeRef.current(nextValue);
setMode("source");
window.requestAnimationFrame(() => sourceRef.current?.focus());
}
function changeSource(event: ChangeEvent<HTMLTextAreaElement>) {
const nextValue = event.target.value;
appliedValueRef.current = nextValue;
onChangeRef.current(nextValue);
}
function changeBlockStyle(nextStyle: BlockStyle) {
if (nextStyle === "paragraph") {
editor.chain().focus().setParagraph().run();
return;
}
const level = Number(nextStyle.slice(-1)) as 1 | 2 | 3;
editor.chain().focus().setHeading({ level }).run();
}
function openLinkDialog() {
if (toolbarState.link) editor.chain().focus().extendMarkRange("link").run();
const { from, to } = editor.state.selection;
const attributes = editor.getAttributes("link");
linkSelectionRef.current = { from, to };
setLinkUrl(String(attributes.href || ""));
setLinkText(from === to ? "" : editor.state.doc.textBetween(from, to, " "));
setLinkError("");
setLinkDialogOpen(true);
}
function closeLinkDialog() {
setLinkDialogOpen(false);
setLinkError("");
}
function applyLink() {
const href = normalizeWysiwygLinkUrl(linkUrl);
if (!href) {
setLinkError(translatedLabels.invalidLink);
return;
}
const { from, to } = linkSelectionRef.current;
if (from === to) {
editor.chain().focus().setTextSelection(from).insertContent({
type: "text",
text: linkText.trim() || href,
marks: [{ type: "link", attrs: { href } }]
}).run();
} else {
editor.chain().focus().setTextSelection({ from, to }).setLink({ href }).run();
}
closeLinkDialog();
}
function openImageDialog() {
const isEditing = editor.isActive("image");
const attributes = isEditing ? editor.getAttributes("image") : {};
setEditingImage(isEditing);
setImageUrl(String(attributes.src || ""));
setImageAlt(String(attributes.alt || ""));
setImageError("");
setImageDialogOpen(true);
}
function closeImageDialog() {
setImageDialogOpen(false);
setImageError("");
}
function applyImage() {
const src = normalizeWysiwygImageUrl(imageUrl);
if (!src) {
setImageError(translatedLabels.invalidImage);
return;
}
const attributes = { src, alt: imageAlt.trim() || undefined };
if (editingImage) editor.chain().focus().updateAttributes("image", attributes).run();
else editor.chain().focus().setImage(attributes).run();
closeImageDialog();
}
function preserveSelection(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
const rootClassName = [
"wysiwyg-editor",
disabled ? "is-disabled" : "",
mode === "source" ? "is-source-mode" : "",
className
].filter(Boolean).join(" ");
return (
<div className={rootClassName} style={editorStyle} role="group" aria-label={translatedAriaLabel}>
<div className="wysiwyg-editor-header">
{mode === "visual" && (
<div className="wysiwyg-editor-toolbar" role="toolbar" aria-label={translatedLabels.editor}>
<div className="wysiwyg-toolbar-group">
<IconButton
variant="ghost"
className="wysiwyg-toolbar-button"
label={translatedLabels.undo}
icon={<Undo2 />}
disabled={disabled || !toolbarState.canUndo}
onMouseDown={preserveSelection}
onClick={() => editor.chain().focus().undo().run()}
/>
<IconButton
variant="ghost"
className="wysiwyg-toolbar-button"
label={translatedLabels.redo}
icon={<Redo2 />}
disabled={disabled || !toolbarState.canRedo}
onMouseDown={preserveSelection}
onClick={() => editor.chain().focus().redo().run()}
/>
</div>
<label className="wysiwyg-block-style">
<span className="sr-only">{translatedLabels.blockStyle}</span>
<select
aria-label={translatedLabels.blockStyle}
value={toolbarState.blockStyle}
disabled={disabled}
onChange={(event) => changeBlockStyle(event.target.value as BlockStyle)}
>
<option value="paragraph">{translatedLabels.paragraph}</option>
<option value="heading-1">{translatedLabels.heading1}</option>
<option value="heading-2">{translatedLabels.heading2}</option>
<option value="heading-3">{translatedLabels.heading3}</option>
</select>
</label>
<div className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bold} disabled={disabled} label={translatedLabels.bold} icon={<Bold />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBold().run()} />
<ToolbarButton active={toolbarState.italic} disabled={disabled} label={translatedLabels.italic} icon={<Italic />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleItalic().run()} />
<ToolbarButton active={toolbarState.underline} disabled={disabled} label={translatedLabels.underline} icon={<Underline />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleUnderline().run()} />
<ToolbarButton active={toolbarState.strike} disabled={disabled} label={translatedLabels.strike} icon={<Strikethrough />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleStrike().run()} />
<ToolbarButton active={toolbarState.code} disabled={disabled} label={translatedLabels.inlineCodeLabel} icon={<Code2 />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleCode().run()} />
</div>
<div className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bulletList} disabled={disabled} label={translatedLabels.bulletList} icon={<List />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBulletList().run()} />
<ToolbarButton active={toolbarState.numberedList} disabled={disabled} label={translatedLabels.numberedList} icon={<ListOrdered />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleOrderedList().run()} />
<ToolbarButton active={toolbarState.quote} disabled={disabled} label={translatedLabels.quote} icon={<Quote />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBlockquote().run()} />
</div>
<div className="wysiwyg-toolbar-group">
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertLink} icon={<Link2 />} disabled={disabled} onMouseDown={preserveSelection} onClick={openLinkDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.removeLink} icon={<Link2Off />} disabled={disabled || !toolbarState.link} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetLink().run()} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertImage} icon={<ImagePlus />} disabled={disabled} onMouseDown={preserveSelection} onClick={openImageDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.clearFormatting} icon={<RemoveFormatting />} disabled={disabled} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} />
</div>
</div>
)}
{allowSourceMode && (
<SegmentedControl
className="wysiwyg-editor-mode"
size="content"
width="inline"
ariaLabel={translatedLabels.editor}
value={mode}
onChange={selectMode}
options={[
{ id: "visual", label: translatedLabels.visual },
{ id: "source", label: translatedLabels.source }
]}
/>
)}
</div>
{mode === "visual" ? (
<EditorContent editor={editor} className="wysiwyg-editor-content" data-i18n-skip="true" />
) : (
<>
{unsupportedSource && <p className="wysiwyg-editor-source-note">{translatedLabels.sourceWarning}</p>}
<textarea
ref={sourceRef}
className="wysiwyg-editor-source"
rows={sourceRows}
value={value}
disabled={disabled}
aria-label={translatedLabels.source}
onFocus={() => onFocusRef.current?.()}
onChange={changeSource}
/>
</>
)}
<Dialog
open={linkDialogOpen}
className="wysiwyg-editor-dialog"
title={translatedLabels.insertLink}
onClose={closeLinkDialog}
footer={(
<>
<Button onClick={closeLinkDialog}>{translatedLabels.cancel}</Button>
<Button variant="primary" onClick={applyLink}>{translatedLabels.apply}</Button>
</>
)}
>
<div className="form-grid">
{linkSelectionRef.current.from === linkSelectionRef.current.to && (
<FormField label={translatedLabels.linkText}>
<input value={linkText} onChange={(event) => setLinkText(event.target.value)} />
</FormField>
)}
<FormField label={translatedLabels.linkUrlLabel}>
<input value={linkUrl} inputMode="url" onChange={(event) => setLinkUrl(event.target.value)} />
</FormField>
{linkError && <p className="wysiwyg-editor-dialog-error" role="alert">{linkError}</p>}
</div>
</Dialog>
<Dialog
open={imageDialogOpen}
className="wysiwyg-editor-dialog"
title={translatedLabels.insertImage}
onClose={closeImageDialog}
footer={(
<>
<Button onClick={closeImageDialog}>{translatedLabels.cancel}</Button>
<Button variant="primary" onClick={applyImage}>{translatedLabels.apply}</Button>
</>
)}
>
<div className="form-grid">
<FormField label={translatedLabels.imageUrlLabel}>
<input value={imageUrl} inputMode="url" onChange={(event) => setImageUrl(event.target.value)} />
</FormField>
<FormField label={translatedLabels.alternativeText}>
<input value={imageAlt} onChange={(event) => setImageAlt(event.target.value)} />
</FormField>
{imageError && <p className="wysiwyg-editor-dialog-error" role="alert">{imageError}</p>}
</div>
</Dialog>
</div>
);
});
export default WysiwygEditor;
function ToolbarButton({
active,
label,
icon,
disabled,
onMouseDown,
onClick
}: {
active: boolean;
label: string;
icon: React.ReactNode;
disabled: boolean;
onMouseDown: (event: MouseEvent<HTMLButtonElement>) => void;
onClick: () => void;
}) {
return (
<IconButton
variant="ghost"
className={`wysiwyg-toolbar-button ${active ? "is-active" : ""}`}
label={label}
icon={icon}
disabled={disabled}
aria-pressed={active}
onMouseDown={onMouseDown}
onClick={onClick}
/>
);
}
function activeBlockStyle(editor: Editor): BlockStyle {
if (editor.isActive("heading", { level: 1 })) return "heading-1";
if (editor.isActive("heading", { level: 2 })) return "heading-2";
if (editor.isActive("heading", { level: 3 })) return "heading-3";
return "paragraph";
}
function editorHtmlValue(editor: Editor): string {
if (editor.isEmpty) return "";
return restoreTokensFromEditor(editor.getHTML());
}
function tokenAttributes(value: string, label?: string): WysiwygEditorToken {
const normalizedValue = value.trim();
return {
value: normalizedValue,
label: label?.trim() || tokenLabel(normalizedValue)
};
}
function tokenLabel(value: string): string {
if ((value.startsWith("{{") && value.endsWith("}}")) || (value.startsWith("${") && value.endsWith("}"))) {
return value.slice(2, value.startsWith("{{") ? -2 : -1).trim();
}
return value;
}
function insertIntoSource(
insertedValue: string,
currentValue: string,
element: HTMLTextAreaElement | null,
onChange: (value: string) => void
): boolean {
const start = element?.selectionStart ?? currentValue.length;
const end = element?.selectionEnd ?? currentValue.length;
const nextValue = `${currentValue.slice(0, start)}${insertedValue}${currentValue.slice(end)}`;
onChange(nextValue);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + insertedValue.length;
element?.setSelectionRange(cursor, cursor);
});
return true;
}
function decorateTokensForEditor(html: string): string {
if (!html || typeof DOMParser === "undefined") return html;
const document = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let current = walker.nextNode();
while (current) {
const textNode = current as Text;
if (!textNode.parentElement?.closest("[data-govoplan-token], script, style")) textNodes.push(textNode);
current = walker.nextNode();
}
for (const textNode of textNodes) {
const text = textNode.nodeValue || "";
TOKEN_SCAN_PATTERN.lastIndex = 0;
if (!TOKEN_SCAN_PATTERN.test(text)) continue;
TOKEN_SCAN_PATTERN.lastIndex = 0;
const fragment = document.createDocumentFragment();
let offset = 0;
for (const match of text.matchAll(TOKEN_SCAN_PATTERN)) {
const index = match.index ?? 0;
if (index > offset) fragment.append(document.createTextNode(text.slice(offset, index)));
const value = match[0];
const token = document.createElement("span");
token.dataset.govoplanToken = value;
token.dataset.govoplanTokenLabel = tokenLabel(value);
token.textContent = tokenLabel(value);
fragment.append(token);
offset = index + value.length;
}
if (offset < text.length) fragment.append(document.createTextNode(text.slice(offset)));
textNode.replaceWith(fragment);
}
return document.body.innerHTML;
}
function restoreTokensFromEditor(html: string): string {
if (!html || typeof DOMParser === "undefined") return html;
const document = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
for (const token of document.body.querySelectorAll<HTMLElement>("[data-govoplan-token]")) {
token.replaceWith(document.createTextNode(token.dataset.govoplanToken || token.textContent || ""));
}
return document.body.innerHTML;
}
function hasUnsupportedVisualMarkup(html: string): boolean {
if (!html.trim() || typeof DOMParser === "undefined") return false;
const document = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
const allowedTags = new Set([
"A", "BLOCKQUOTE", "BR", "CODE", "DEL", "EM", "H1", "H2", "H3", "H4", "H5", "H6",
"HR", "I", "IMG", "LI", "OL", "P", "PRE", "S", "STRIKE", "STRONG", "U", "UL"
]);
const allowedAttributes: Record<string, Set<string>> = {
A: new Set(["href", "rel", "target", "title"]),
IMG: new Set(["alt", "height", "src", "title", "width"])
};
for (const element of document.body.querySelectorAll("*")) {
if (!allowedTags.has(element.tagName)) return true;
const allowedForTag = allowedAttributes[element.tagName] ?? new Set<string>();
for (const attribute of Array.from(element.attributes)) {
if (!allowedForTag.has(attribute.name.toLowerCase())) return true;
}
}
return false;
}
function translateLabels(labels: WysiwygEditorLabels, translateText: (value: string) => string): WysiwygEditorLabels {
return Object.fromEntries(
Object.entries(labels).map(([key, value]) => [key, translateText(value)])
) as WysiwygEditorLabels;
}