770 lines
28 KiB
TypeScript
770 lines
28 KiB
TypeScript
import { FormGrid } from "./ContentGrid";
|
|
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 ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
|
|
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" && (
|
|
<ActionToolbar className="wysiwyg-editor-toolbar" label={translatedLabels.editor}>
|
|
<ToolbarGroup 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()}
|
|
/>
|
|
</ToolbarGroup>
|
|
<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>
|
|
<ToolbarGroup 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()} />
|
|
</ToolbarGroup>
|
|
<ToolbarGroup 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()} />
|
|
</ToolbarGroup>
|
|
<ToolbarGroup 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()} />
|
|
</ToolbarGroup>
|
|
</ActionToolbar>
|
|
)}
|
|
{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>
|
|
</>
|
|
)}
|
|
>
|
|
<FormGrid columns={1} collapseAt="standard" className="">
|
|
{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>}
|
|
</FormGrid>
|
|
</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>
|
|
</>
|
|
)}
|
|
>
|
|
<FormGrid columns={1} collapseAt="standard" className="">
|
|
<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>}
|
|
</FormGrid>
|
|
</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;
|
|
}
|