Add shared automation and WebUI editing primitives
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user