Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions tsunami/frontend/src/element/editablediv.tsx
Original file line number Diff line number Diff line change
@@ -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<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
className?: string;
text: string;
onChange: (newText: string) => void;
placeholder?: string;
}

export function EditableDiv({ className, text, onChange, placeholder, ...otherProps }: EditableDivProps) {
const divRef = useRef<HTMLDivElement>(null);
const textRef = useRef<string>(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<HTMLDivElement>) => {
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 (
<div
ref={divRef}
contentEditable
suppressContentEditableWarning
className={twMerge(className)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
data-placeholder={placeholder}
{...otherProps}
>
{text}
</div>
);
}
9 changes: 9 additions & 0 deletions tsunami/frontend/src/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

18 changes: 18 additions & 0 deletions tsunami/frontend/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac

const WaveTagMap: Record<string, VDomReactTagType> = {
"wave:markdown": WaveMarkdown,
"wave:editablediv": WaveEditableDiv,
};

const AllowedSimpleTags: { [tagName: string]: boolean } = {
Expand Down Expand Up @@ -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 (
<EditableDiv
text={text || ""}
onChange={onChange}
placeholder={placeholder}
className={className}
style={style}
{...otherProps}
/>
);
}

function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
const styleText = getTextChildren(elem);
if (styleText == null) {
Expand Down