diff --git a/tsunami/frontend/src/element/editablediv.tsx b/tsunami/frontend/src/element/editablediv.tsx new file mode 100644 index 0000000000..6a2228d0be --- /dev/null +++ b/tsunami/frontend/src/element/editablediv.tsx @@ -0,0 +1,68 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useEffect, useRef } from "react"; +import { twMerge } from "tailwind-merge"; + +interface EditableDivProps extends Omit, 'onChange'> { + className?: string; + text: string; + onChange: (newText: string) => void; + placeholder?: string; +} + +export function EditableDiv({ className, text, onChange, placeholder, ...otherProps }: EditableDivProps) { + const divRef = useRef(null); + const textRef = useRef(text); + + // Update DOM when text prop changes + useEffect(() => { + if (divRef.current && divRef.current.textContent !== text) { + divRef.current.textContent = text; + textRef.current = text; + } + }, [text]); + + const handleBlur = () => { + const newText = divRef.current?.textContent || ""; + if (newText !== textRef.current) { + textRef.current = newText; + onChange(newText); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + // Submit the edit - stop editing and fire onChange + divRef.current?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + // Revert to original contents and stop editing + if (divRef.current) { + divRef.current.textContent = textRef.current; + divRef.current.blur(); + } + } + + // Call original onKeyDown if provided + if (otherProps.onKeyDown) { + otherProps.onKeyDown(e); + } + }; + + return ( +
+ {text} +
+ ); +} diff --git a/tsunami/frontend/src/tailwind.css b/tsunami/frontend/src/tailwind.css index 945398cd53..f93cbf14c7 100644 --- a/tsunami/frontend/src/tailwind.css +++ b/tsunami/frontend/src/tailwind.css @@ -108,3 +108,12 @@ html, body { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.15) rgba(0, 0, 0, 0.1); } + +/* EditableDiv placeholder styling */ +[contenteditable][data-placeholder]:empty:before { + content: attr(data-placeholder); + color: var(--color-muted); + pointer-events: none; + position: absolute; +} + diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 37de4c0f1c..571f986d1d 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge"; import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { EditableDiv } from "@/element/editablediv"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; import { RechartsTag } from "@/recharts/recharts"; @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, + "wave:editablediv": WaveEditableDiv, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { @@ -278,6 +280,22 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +function WaveEditableDiv({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + // Extract EditableDiv specific props + const { text, onChange, placeholder, className, style, ...otherProps } = props; + return ( + + ); +} + function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const styleText = getTextChildren(elem); if (styleText == null) {