Files
TX/artifacts/tx-os/src/components/editable-cell.tsx
T
riyadhafraa d359c4e602 #607: make inline edit toolbar usable on mobile (wrap + viewport clamp)
Problem: On phone-width viewports (~375-430px), the floating
FormattingToolbar in EditableCell rendered as a single row with
17+ controls (B/I/U + 7 color swatches + 3 align buttons + font +
size + Save/Cancel). The row was wider than the screen, so users
only saw the edges (X, ✓, two "default" dropdowns) and could not
reach Bold/Italic/Underline, color swatches, alignment, or font
controls. The horizontal clamp also relied on the table's
scroll-container bounds, which on mobile extend beyond the viewport
because the table is wider than the screen — so the clamp could
park the toolbar partially off-screen.

Fix in artifacts/tx-os/src/components/editable-cell.tsx:
- Toolbar container: `flex` → `flex flex-wrap` with `gap-x-1 gap-y-1`
  and `max-w-[calc(100vw-16px)]` so it wraps onto multiple rows
  whenever a single row would exceed the viewport. Bumped `py` to
  `py-1` for breathing room between wrapped rows.
- Horizontal clamp: in addition to the scroll-container bounds,
  also clamp against the viewport (`[8, window.innerWidth - tbWidth
  - 8]`) so the toolbar always lands fully on-screen even when the
  scroll container is wider than the screen.

Scope: visual / positioning only. No changes to TipTap config,
toolbar buttons, or save/cancel logic. Above-vs-below placement
(#581 iOS keyboard handling) is preserved.
2026-05-19 08:12:56 +00:00

807 lines
27 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { createPortal } from "react-dom";
import { useEditor, EditorContent, type Editor, Extension } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Underline } from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { FontFamily } from "@tiptap/extension-font-family";
import { TextAlign } from "@tiptap/extension-text-align";
import { safeHtml } from "@/lib/safe-html";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import {
Bold as BoldIcon,
Italic as ItalicIcon,
Underline as UnderlineIcon,
AlignLeft as AlignLeftIcon,
AlignCenter as AlignCenterIcon,
AlignRight as AlignRightIcon,
Check,
X as XIcon,
} from "lucide-react";
const COLOR_SWATCHES = [
{ value: "", label: "default" },
{ value: "#0B1E3F", label: "navy" },
{ value: "#dc2626", label: "red" },
{ value: "#ea580c", label: "orange" },
{ value: "#ca8a04", label: "amber" },
{ value: "#16a34a", label: "green" },
{ value: "#2563eb", label: "blue" },
{ value: "#7c3aed", label: "violet" },
{ value: "#6b7280", label: "gray" },
];
const FONT_OPTIONS = [
{ value: "", label: "default" },
{ value: "DIN Next LT Arabic", label: "DIN Next LT Arabic" },
{ value: "Tajawal", label: "Tajawal" },
{ value: "Helvetica Neue LT Arabic", label: "Helvetica Neue Arabic" },
{ value: "Helvetica Neue", label: "Helvetica Neue" },
{ value: "Majalla", label: "Majalla" },
{ value: "IBM Plex Sans Arabic", label: "IBM Plex" },
{ value: "system-ui", label: "system-ui" },
];
const FONT_SIZE_OPTIONS = [
{ value: "", label: "default" },
{ value: "10px", label: "10" },
{ value: "12px", label: "12" },
{ value: "14px", label: "14" },
{ value: "16px", label: "16" },
{ value: "18px", label: "18" },
{ value: "20px", label: "20" },
{ value: "24px", label: "24" },
{ value: "28px", label: "28" },
];
declare module "@tiptap/core" {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
const FontSize = Extension.create({
name: "fontSize",
addOptions() {
return { types: ["textStyle"] };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element: HTMLElement) =>
element.style.fontSize?.replace(/['"]+/g, "") || null,
renderHTML: (attrs: { fontSize?: string | null }) => {
if (!attrs.fontSize) return {};
return { style: `font-size: ${attrs.fontSize}` };
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(size: string) =>
({ chain }) =>
chain().setMark("textStyle", { fontSize: size }).run(),
unsetFontSize:
() =>
({ chain }) =>
chain()
.setMark("textStyle", { fontSize: null })
.removeEmptyTextStyle()
.run(),
};
},
});
/**
* In-place rich text editor used in the executive-meetings schedule for
* Word-like editing of meeting names + attendee names.
*
* - When not editing, renders the saved sanitized HTML via dangerouslySetInnerHTML.
* - Click (or focus + Enter) enters edit mode with a floating toolbar.
* - Saves on blur outside the editor+toolbar or Ctrl/Cmd+Enter.
* - Esc cancels.
*
* The server is the source of sanitization. This component only sends what
* Tiptap produces; the server strips anything outside the allowlist.
*/
export function EditableCell({
value,
onSave,
placeholder,
ariaLabel,
dir,
className,
multiline = false,
disabled = false,
fontStyle,
testId,
startInEditMode = false,
onStartedEditing,
forceSaveOnBlur = false,
onCancel,
onTabNext,
onTabPrev,
}: {
value: string;
onSave: (html: string) => Promise<void> | void;
placeholder?: string;
ariaLabel?: string;
dir?: "rtl" | "ltr" | "auto";
className?: string;
multiline?: boolean;
disabled?: boolean;
fontStyle?: CSSProperties;
testId?: string;
startInEditMode?: boolean;
onStartedEditing?: () => void;
/**
* When true, `onSave` is invoked on blur even if the content is unchanged.
* Used by the schedule's "+ Add attendee" ghost row so the caller can
* commit a new attendee or discard an empty add when the user blurs out
* of a freshly-opened cell.
*/
forceSaveOnBlur?: boolean;
/**
* Called when the user explicitly cancels (Escape). Used by the ghost
* "+ Add attendee" row so the caller can clear pending state and remove
* the synthetic row from the cell.
*/
onCancel?: () => void;
/**
* If provided, plain `Tab` (no Shift) inside the editor commits the
* current value and then invokes this callback. The callback is
* responsible for what should happen "next" — typically opening a
* new pending attendee row immediately after this one. We swallow
* the default Tab behaviour so the editor never inserts a tab
* character.
*/
onTabNext?: () => void;
/**
* If provided, `Shift+Tab` inside the editor commits the current
* value and then invokes this callback. Mirror of `onTabNext` — used
* by the attendee flow to focus the previous person row in the same
* section. May be invoked even when there is nothing to focus
* (callback may no-op); we still preventDefault so the editor never
* inserts a tab character.
*/
onTabPrev?: () => void;
}) {
const [editing, setEditing] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// `useEditor` below is created once (deps `[]`), so its `handleKeyDown`
// closes over the first render's `onTabNext` / `onTabPrev`. Stash the
// latest in refs so Tab / Shift+Tab always invoke the current callback.
const onTabNextRef = useRef(onTabNext);
const onTabPrevRef = useRef(onTabPrev);
useEffect(() => {
onTabNextRef.current = onTabNext;
onTabPrevRef.current = onTabPrev;
}, [onTabNext, onTabPrev]);
useEffect(() => {
if (startInEditMode && !editing && !disabled) {
setEditing(true);
onStartedEditing?.();
}
}, [startInEditMode, editing, disabled, onStartedEditing]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
}),
Underline,
TextStyle,
Color,
FontFamily.configure({ types: ["textStyle"] }),
FontSize,
TextAlign.configure({
types: ["paragraph", "heading"],
alignments: ["left", "center", "right"],
}),
],
content: value || "",
editable: editing && !disabled,
editorProps: {
attributes: {
class:
"outline-none focus:outline-none min-h-[1.5em] " +
(multiline ? "" : "whitespace-nowrap overflow-hidden"),
dir: dir ?? "auto",
},
handleKeyDown: (_view, event) => {
if (event.key === "Escape") {
cancelEdit();
return true;
}
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
saveEdit();
return true;
}
if (event.key === "Enter" && !multiline && !event.shiftKey) {
event.preventDefault();
saveEdit();
return true;
}
// Tab / Shift+Tab → commit and hand off to the parent's
// navigation callback (typically: open the next pending
// attendee row, or focus the previous attendee in the same
// section). Without a callback we leave Tab to the browser
// so focus moves naturally to the next focusable control.
if (event.key === "Tab") {
const cb = event.shiftKey
? onTabPrevRef.current
: onTabNextRef.current;
if (cb) {
event.preventDefault();
void Promise.resolve(saveEdit()).then(() => cb());
return true;
}
}
return false;
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (!editing && editor.getHTML() !== (value || "<p></p>")) {
editor.commands.setContent(value || "", { emitUpdate: false });
}
}, [editor, value, editing]);
useEffect(() => {
if (!editor) return;
editor.setEditable(editing && !disabled);
if (editing) {
const id = setTimeout(() => editor.commands.focus("end"), 0);
return () => clearTimeout(id);
}
return undefined;
}, [editor, editing, disabled]);
// If the host disables this cell while we're mid-edit (e.g. the
// schedule's global Edit toggle is flipped back to view mode), drop
// any unsaved draft and exit edit mode immediately. Without this the
// cell stays in editing state with a frozen toolbar even though the
// editor itself has gone read-only via setEditable above.
useEffect(() => {
if (disabled && editing && editor) {
editor.commands.setContent(value || "", { emitUpdate: false });
setEditing(false);
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
}
}, [disabled, editing, editor, value]);
const enterEdit = () => {
if (disabled) return;
setEditing(true);
};
const cancelEdit = useCallback(() => {
if (!editor) return;
editor.commands.setContent(value || "", { emitUpdate: false });
setEditing(false);
onCancel?.();
}, [editor, value, onCancel]);
// #317: re-entrancy guard. The outside-pointerdown flush, the blur
// timer, the unmount cleanup, and explicit Enter/Tab handlers can
// all race to call saveEdit for the same interaction. Without this
// guard a click-outside followed by the focusout's 100 ms timer
// would issue two PATCH/PUT requests for one edit.
const savingRef = useRef(false);
const saveEdit = useCallback(async () => {
if (savingRef.current) return;
if (!editor) return;
const html = editor.getHTML();
const stripped = html === "<p></p>" ? "" : html;
setEditing(false);
if (stripped !== value || forceSaveOnBlur) {
savingRef.current = true;
try {
await onSave(stripped);
} catch {
// #317: failure UX matches the schedule time editor (#316) —
// re-open the editor with the user's typed draft intact so
// they can fix the error and retry without retyping. The
// parent's optimistic cache write is rolled back in its own
// catch branch, so the read-only `value` we close over here
// already reflects the original pre-edit content. We
// intentionally do NOT call `editor.commands.setContent(value)`
// — the editor still holds the typed HTML, and flipping
// `editing` back to true makes it visible and focusable.
setEditing(true);
} finally {
savingRef.current = false;
}
}
}, [editor, onSave, value, forceSaveOnBlur]);
// #317: stash the latest saveEdit so the unmount cleanup below can
// call it without capturing a stale closure. We only need this for
// the "flush on unmount" path; normal blur calls saveEdit directly.
const saveEditRef = useRef(saveEdit);
useEffect(() => {
saveEditRef.current = saveEdit;
}, [saveEdit]);
// #317: if the cell unmounts (e.g. the row is replaced by a refetch
// landing while the 100 ms blur timer is still pending) fire the
// save synchronously so the user's edit is never silently dropped.
// Without this, a fast Tab into the next cell that triggers the
// parent's optimistic refetch can tear down the editor before the
// setTimeout fires, losing the typed change. Fire-and-forget is
// safe because the parent's saveTitle / saveAttendeeName / etc.
// already write the optimistic value into the React Query cache.
useEffect(() => {
return () => {
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
void saveEditRef.current();
}
};
}, []);
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
if (!editing) return;
const next = e.relatedTarget as Node | null;
if (
next &&
(wrapperRef.current?.contains(next) ||
toolbarRef.current?.contains(next))
)
return;
if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
blurTimerRef.current = setTimeout(() => {
saveEdit();
}, 100);
};
// When the toolbar is portalled outside wrapperRef, native pointerdown on
// the toolbar would still trigger our onFocusOut path on some browsers.
// We watch document-level pointerdown and ignore taps inside the toolbar.
// #317: pointerdown OUTSIDE the wrapper+toolbar synchronously flushes
// the save instead of waiting for the 100 ms blur timer. This closes
// the timing window where a fast click into another cell would
// re-render this row before the blur timer fired and silently drop
// the typed change. The unmount cleanup above is the second safety
// net for the case where a refetch tears down the row before any
// pointer event fires.
useEffect(() => {
if (!editing) return;
const onPointerDownDoc = (e: PointerEvent) => {
const target = e.target as Node | null;
if (!target) return;
const inside =
wrapperRef.current?.contains(target) ||
toolbarRef.current?.contains(target);
if (inside) {
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
return;
}
// Outside tap → cancel any pending blur timer and commit now.
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
void saveEditRef.current();
};
document.addEventListener("pointerdown", onPointerDownDoc, true);
return () => {
document.removeEventListener("pointerdown", onPointerDownDoc, true);
};
}, [editing]);
const onFocusIn = () => {
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
};
if (!editor) {
return (
<div
className={className}
style={fontStyle}
dangerouslySetInnerHTML={{
__html: value ? safeHtml(value) : (placeholder ?? ""),
}}
/>
);
}
if (!editing) {
return (
<div
role={disabled ? undefined : "button"}
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
className={
(className ?? "") +
(disabled
? ""
: " cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors")
}
style={fontStyle}
data-testid={testId}
onClick={enterEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
enterEdit();
}
}}
>
{value ? (
<span dangerouslySetInnerHTML={{ __html: safeHtml(value) }} />
) : (
<span className="text-muted-foreground italic">
{placeholder ?? ""}
</span>
)}
</div>
);
}
return (
<div
ref={wrapperRef}
className={"relative " + (className ?? "")}
onFocus={onFocusIn}
onBlur={onFocusOut}
data-testid={testId}
data-editing="true"
style={fontStyle}
>
<FormattingToolbar
editor={editor}
anchorRef={wrapperRef}
toolbarRef={toolbarRef}
onSave={saveEdit}
onCancel={cancelEdit}
/>
<div
className="border border-blue-400 rounded bg-white px-1.5 py-1 shadow-sm focus-within:border-blue-600"
style={fontStyle}
>
<EditorContent editor={editor} />
</div>
</div>
);
}
function FormattingToolbar({
editor,
anchorRef,
toolbarRef,
onSave,
onCancel,
}: {
editor: Editor;
anchorRef: React.RefObject<HTMLElement | null>;
toolbarRef: React.MutableRefObject<HTMLDivElement | null>;
onSave: () => void;
onCancel: () => void;
}) {
const [, setTick] = useState(0);
useEffect(() => {
const update = () => setTick((n) => n + 1);
editor.on("selectionUpdate", update);
editor.on("transaction", update);
return () => {
editor.off("selectionUpdate", update);
editor.off("transaction", update);
};
}, [editor]);
const isActive = (mark: string, attrs?: Record<string, unknown>) =>
editor.isActive(mark, attrs);
const btn = (active: boolean) =>
"p-1 rounded text-xs " +
(active
? "bg-[#0B1E3F] text-white"
: "bg-white text-[#0B1E3F] hover:bg-gray-100 border border-gray-200");
// Position the toolbar via a portal so it escapes table cells with
// overflow:hidden. Default placement is BELOW the editor; we only
// flip ABOVE when staying below would hide the toolbar under the iOS
// soft keyboard or off the bottom of the visible viewport (#581).
// We also clamp horizontally to keep it inside the table's scroll
// container (falling back to the viewport).
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [toolbarEl, setToolbarEl] = useState<HTMLDivElement | null>(null);
const setToolbarNode = useCallback(
(node: HTMLDivElement | null) => {
toolbarRef.current = node;
setToolbarEl(node);
},
[toolbarRef],
);
// Track the visible viewport so the toolbar can dodge the iOS soft
// keyboard. On iPad / iOS Safari, when the keyboard opens it overlays
// the bottom of the layout viewport — `visualViewport.height` shrinks
// accordingly. We use that to decide when to flip the toolbar above
// the cell instead of below, so the color swatches / Save / Cancel
// stay reachable instead of being hidden under the keyboard.
const vv = useVisualViewport();
useLayoutEffect(() => {
// Walk up to find the nearest horizontally-scrolling ancestor
// (the table's `overflow-x-auto` wrapper). The toolbar must stay
// inside that container's bounds so it doesn't visually escape the
// table.
const findScrollContainer = (el: HTMLElement | null): HTMLElement | null => {
let cur: HTMLElement | null = el?.parentElement ?? null;
while (cur && cur !== document.body) {
const style = window.getComputedStyle(cur);
const ox = style.overflowX;
if (ox === "auto" || ox === "scroll") return cur;
cur = cur.parentElement;
}
return null;
};
const update = () => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const tbHeight = toolbarEl?.offsetHeight ?? 36;
const tbWidth = toolbarEl?.offsetWidth ?? 320;
// Prefer placing the toolbar BELOW the editing cell (keeps it
// from sitting on top of the row above the active cell, which is
// what the schedule spec wants). We only flip ABOVE when staying
// below would put the toolbar under the iOS soft keyboard or off
// the bottom of the visible viewport — otherwise the color
// swatches / Save / Cancel become unreachable on iPad. See #581.
const vvTop = vv.offsetTop;
const vvBottom =
vv.offsetTop + (vv.height ?? window.innerHeight);
let top = rect.bottom + 4;
if (top + tbHeight > vvBottom - 4) {
const above = rect.top - tbHeight - 4;
top = Math.max(vvTop + 4, above);
}
// Clamp horizontally to the table's scroll container if we can
// find one; fall back to the viewport otherwise. This keeps the
// toolbar visually anchored inside the table.
// #607: also clamp against the viewport in case the table's
// scroll container is wider than the screen (mobile/iPad), so
// the toolbar never lands off-screen and stays usable when it
// wraps to multiple rows.
const container = findScrollContainer(anchor);
const containerRect = container?.getBoundingClientRect();
const vpMinLeft = 8;
const vpMaxLeft = window.innerWidth - tbWidth - 8;
const minLeft = Math.max(
vpMinLeft,
containerRect ? containerRect.left + 4 : vpMinLeft,
);
const maxLeft = Math.min(
vpMaxLeft,
containerRect ? containerRect.right - tbWidth - 4 : vpMaxLeft,
);
let left = rect.left;
if (left > maxLeft) left = maxLeft;
if (left < minLeft) left = minLeft;
setPos({ top, left });
};
update();
const raf = requestAnimationFrame(update);
let ro: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(update);
if (anchorRef.current) ro.observe(anchorRef.current);
if (toolbarEl) ro.observe(toolbarEl);
}
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(raf);
ro?.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [anchorRef, toolbarEl, vv.height, vv.offsetTop, vv.keyboardInset]);
const node = (
<div
ref={setToolbarNode}
style={{
position: "fixed",
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
zIndex: 1000,
visibility: pos ? "visible" : "hidden",
}}
className="flex flex-wrap items-center gap-x-1 gap-y-1 bg-white border border-gray-200 rounded shadow-md px-1 py-1 max-w-[calc(100vw-16px)]"
onMouseDown={(e) => e.preventDefault()}
data-testid="em-edit-toolbar"
>
<button
type="button"
className={btn(isActive("bold"))}
onClick={() => editor.chain().focus().toggleBold().run()}
aria-label="Bold"
data-testid="em-edit-bold"
>
<BoldIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
className={btn(isActive("italic"))}
onClick={() => editor.chain().focus().toggleItalic().run()}
aria-label="Italic"
data-testid="em-edit-italic"
>
<ItalicIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
className={btn(isActive("underline"))}
onClick={() => editor.chain().focus().toggleUnderline().run()}
aria-label="Underline"
data-testid="em-edit-underline"
>
<UnderlineIcon className="w-3.5 h-3.5" />
</button>
<span className="w-px h-4 bg-gray-200 mx-0.5" />
{COLOR_SWATCHES.map((c) => (
<button
key={c.value || "default"}
type="button"
className={
"w-4 h-4 rounded-sm border " +
(c.value
? "border-gray-300 hover:scale-110"
: "border-gray-400 bg-[repeating-linear-gradient(45deg,#fff,#fff_3px,#e5e7eb_3px,#e5e7eb_6px)]")
}
style={c.value ? { backgroundColor: c.value } : undefined}
onClick={() => {
if (c.value) {
editor.chain().focus().setColor(c.value).run();
} else {
editor.chain().focus().unsetColor().run();
}
}}
aria-label={`color ${c.label}`}
data-testid={`em-edit-color-${c.label}`}
/>
))}
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<button
type="button"
className={btn(isActive("paragraph", { textAlign: "right" }))}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
aria-label="Align right"
data-testid="em-edit-align-right"
>
<AlignRightIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
className={btn(isActive("paragraph", { textAlign: "center" }))}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
aria-label="Align center"
data-testid="em-edit-align-center"
>
<AlignCenterIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
className={btn(isActive("paragraph", { textAlign: "left" }))}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
aria-label="Align left"
data-testid="em-edit-align-left"
>
<AlignLeftIcon className="w-3.5 h-3.5" />
</button>
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<select
className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
value={(editor.getAttributes("textStyle").fontFamily as string) || ""}
onChange={(e) => {
const v = e.target.value;
if (v) editor.chain().focus().setFontFamily(v).run();
else editor.chain().focus().unsetFontFamily().run();
}}
aria-label="Font"
data-testid="em-edit-font"
>
{FONT_OPTIONS.map((f) => (
// No `style={{ fontFamily }}` here on purpose: previewing each
// option in its own family forces the browser to fetch every
// custom font as soon as the dropdown is opened, defeating the
// `font-display: swap` lazy-load. We keep labels in the host
// UI font so a font is downloaded only when actually applied
// to editor content.
<option key={f.value || "default"} value={f.value}>
{f.label}
</option>
))}
</select>
<select
className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
value={(editor.getAttributes("textStyle").fontSize as string) || ""}
onChange={(e) => {
const v = e.target.value;
if (v) editor.chain().focus().setFontSize(v).run();
else editor.chain().focus().unsetFontSize().run();
}}
aria-label="Font size"
data-testid="em-edit-font-size"
>
{FONT_SIZE_OPTIONS.map((f) => (
<option key={f.value || "default"} value={f.value}>
{f.label}
</option>
))}
</select>
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<button
type="button"
className="p-1 rounded bg-green-600 text-white hover:bg-green-700"
onClick={onSave}
aria-label="Save"
data-testid="em-edit-save"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="p-1 rounded bg-gray-200 text-[#0B1E3F] hover:bg-gray-300"
onClick={onCancel}
aria-label="Cancel"
data-testid="em-edit-cancel"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
);
if (typeof document === "undefined") return node;
return createPortal(node, document.body);
}