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; 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(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(() => { 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(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) { 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) { event.preventDefault(); } const rootClassName = [ "wysiwyg-editor", disabled ? "is-disabled" : "", mode === "source" ? "is-source-mode" : "", className ].filter(Boolean).join(" "); return (
{mode === "visual" && ( } disabled={disabled || !toolbarState.canUndo} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().undo().run()} /> } disabled={disabled || !toolbarState.canRedo} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().redo().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBold().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleItalic().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleUnderline().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleStrike().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleCode().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBulletList().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleOrderedList().run()} /> } onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBlockquote().run()} /> } disabled={disabled} onMouseDown={preserveSelection} onClick={openLinkDialog} /> } disabled={disabled || !toolbarState.link} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetLink().run()} /> } disabled={disabled} onMouseDown={preserveSelection} onClick={openImageDialog} /> } disabled={disabled} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} /> )} {allowSourceMode && ( )}
{mode === "visual" ? ( ) : ( <> {unsupportedSource &&

{translatedLabels.sourceWarning}

}