diff --git a/frontend/src/components/shared/markdown-editor/index.ts b/frontend/src/components/shared/markdown-editor/index.ts
index 4aa0cb0f..55784129 100644
--- a/frontend/src/components/shared/markdown-editor/index.ts
+++ b/frontend/src/components/shared/markdown-editor/index.ts
@@ -1,6 +1,8 @@
// Deliberately does NOT re-export the heavy MarkdownEditor value — that would statically pull the tiptap chunk
// into any route importing a light util from here. Consume the mode-switching MarkdownEditorField instead (it
// owns the lazy() boundary, so importing it is chunk-free until rich mode renders).
+// For the raw rich editor standalone, import './markdown-editor' by path — that eagerly bundles tiptap into the
+// route, so wrap it in lazy(() => import('./markdown-editor')) yourself if you need the chunk deferred.
export { MarkdownEditorField } from './markdown-editor-field';
export type { MarkdownEditorFieldHandle } from './markdown-editor-field';
export { findVariableUseRanges, VARIABLE_RE, variableUseRegex } from './markdown-editor-variable-syntax';
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts
index 1a1fd908..b0676311 100644
--- a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts
@@ -98,10 +98,12 @@ const TunedStarterKit = StarterKit.extend({
// • link autolink/linkOnPaste: true — a bare URL/email becomes a link on load, paste, AND typing, kept
// symmetric with the marked layer (which no longer neutralises autolink/url). Do NOT set false: it
// diverges typing from load and re-freezes bare URLs as text.
+// • link openOnClick: false — a click seats the caret in the link instead of navigating away, so LinkHandle
+// (markdown-editor-link-handle.tsx) can show the edit popover; opening still works via that popover's button.
export const createMarkdownExtensions = (placeholder?: string) => [
TunedStarterKit.configure({
codeBlock: { HTMLAttributes: { class: 'hljs' } },
- link: { autolink: true, linkOnPaste: true },
+ link: { autolink: true, linkOnPaste: true, openOnClick: false },
underline: false,
}),
TunedTable.configure({ resizable: true }),
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-image-edit-form.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-image-edit-form.tsx
new file mode 100644
index 00000000..36409112
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-image-edit-form.tsx
@@ -0,0 +1,139 @@
+import type { Editor } from '@tiptap/react';
+
+import { Check, Trash2 } from 'lucide-react';
+import { useId, useState } from 'react';
+
+import { Input } from '@/components/ui/input';
+import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
+import { Label } from '@/components/ui/label';
+
+import { normalizeImageSrc } from './markdown-editor-toolbar-url';
+
+interface ImageEditFormProps {
+ // Focus the URL input on mount. True when the user explicitly opened the form (toolbar button); false for the
+ // on-image popover, which appears when an image is selected and must not steal focus.
+ autoFocus?: boolean;
+ editor: Editor;
+ initialAlt: string;
+ initialSrc: string;
+ // true = editing the selected image (updateAttributes + Remove); false = inserting a new one (setImage).
+ isEditing: boolean;
+ onDone: () => void;
+}
+
+// Shared body of the image editor — the same src field + validation + alt used by the toolbar Insert-image popover
+// AND the on-image popover (markdown-editor-image-handle.tsx). Seeds its own state from the initial props on mount,
+// so consumers give it a fresh `key` per editing session.
+export function ImageEditForm({
+ autoFocus = true,
+ editor,
+ initialAlt,
+ initialSrc,
+ isEditing,
+ onDone,
+}: ImageEditFormProps) {
+ const [src, setSrc] = useState(initialSrc);
+ const [alt, setAlt] = useState(initialAlt);
+ const srcId = useId();
+ const altId = useId();
+ const errorId = useId();
+
+ const normalizedSrc = normalizeImageSrc(src);
+ const isInvalid = src !== '' && normalizedSrc === null;
+
+ const apply = () => {
+ if (!normalizedSrc) {
+ return;
+ }
+
+ if (isEditing) {
+ editor
+ .chain()
+ .focus()
+ .updateAttributes('image', { alt: alt || null, src: normalizedSrc })
+ .run();
+ } else {
+ editor
+ .chain()
+ .focus()
+ .setImage({ alt: alt || undefined, src: normalizedSrc })
+ .run();
+ }
+
+ onDone();
+ };
+
+ const remove = () => {
+ editor.chain().focus().deleteSelection().run();
+ onDone();
+ };
+
+ const applyOnEnter = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ apply();
+ }
+ };
+
+ return (
+
+
+ Image URL
+
+ setSrc(event.target.value)}
+ onKeyDown={applyOnEnter}
+ placeholder="https://example.com/image.png"
+ type="url"
+ value={src}
+ />
+
+
+
+
+ {isEditing ? (
+
+
+
+ ) : null}
+
+
+
+
+ Alt text (optional)
+ setAlt(event.target.value)}
+ onKeyDown={applyOnEnter}
+ placeholder="Describe the image"
+ value={alt}
+ />
+
+ {isInvalid ? (
+
+ Only http(s) or base64 raster image URLs are allowed.
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-image-handle.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-image-handle.tsx
new file mode 100644
index 00000000..731d9c04
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-image-handle.tsx
@@ -0,0 +1,113 @@
+import type { Editor } from '@tiptap/react';
+
+import { posToDOMRect } from '@tiptap/core';
+import { NodeSelection } from '@tiptap/pm/state';
+import { useEffect, useState } from 'react';
+
+import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
+
+import { ImageEditForm } from './markdown-editor-image-edit-form';
+
+interface ImageTarget {
+ alt: string;
+ key: number;
+ rect: { height: number; left: number; top: number; width: number };
+ src: string;
+}
+
+// Shows the shared image editor anchored to the selected image. Clicking an image makes it a NodeSelection (it
+// never navigates), so `selection instanceof NodeSelection && node.type.name === 'image'` detects "an image is
+// selected". Overlay-only — it never mutates the doc until a form action runs (like TableHandles / LinkHandle).
+export function ImageHandle({ editor }: { editor: Editor }) {
+ const { close, target } = useImageHandle(editor);
+
+ if (!target) {
+ return null;
+ }
+
+ const { alt, key, rect, src } = target;
+
+ return (
+ {
+ if (!next) {
+ close();
+ }
+ }}
+ open
+ >
+
+
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ onOpenAutoFocus={(event) => {
+ // The popover appears when an image is selected — keep focus in the doc so it doesn't steal it;
+ // the user clicks into the URL field only to actually edit.
+ event.preventDefault();
+ }}
+ side="bottom"
+ sideOffset={6}
+ >
+
+
+
+ );
+}
+
+function useImageHandle(editor: Editor) {
+ const [target, setTarget] = useState(null);
+
+ useEffect(() => {
+ const update = () => {
+ const { selection } = editor.state;
+
+ if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') {
+ setTarget(null);
+
+ return;
+ }
+
+ const rect = posToDOMRect(editor.view, selection.from, selection.to);
+ const { alt, src } = selection.node.attrs;
+
+ setTarget({ alt: (alt as string) ?? '', key: selection.from, rect, src: (src as string) ?? '' });
+ };
+
+ const clear = () => setTarget(null);
+
+ editor.on('selectionUpdate', update);
+
+ // Fixed-positioned anchor goes stale on scroll/resize — drop it (it reappears on the next selection).
+ const scrollParent = editor.view.dom.closest('.tiptap-content') ?? window;
+
+ scrollParent.addEventListener('scroll', clear, { passive: true });
+ window.addEventListener('resize', clear);
+
+ return () => {
+ editor.off('selectionUpdate', update);
+ scrollParent.removeEventListener('scroll', clear);
+ window.removeEventListener('resize', clear);
+ };
+ }, [editor]);
+
+ return { close: () => setTarget(null), target };
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts
index 792e3e61..99e26d97 100644
--- a/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts
@@ -1,20 +1,21 @@
import { describe, expect, it } from 'vitest';
-import { isSafeImageSrc, isSafeUrl } from './markdown-editor-toolbar';
+import { normalizeImageSrc, normalizeLinkUrl } from './markdown-editor-toolbar-url';
-// The Image extension stores whatever src it is handed; isSafeImageSrc is the allowlist that blocks dangerous
-// PROTOCOLS (javascript:, data:text/html, vbscript:, file:) before a src is saved. A bare relative string
-// resolves against the page origin (http/https) and is intentionally allowed — the guard validates the
-// protocol, not that the URL is absolute. data: URLs are limited to base64 raster formats: svg+xml is the one
-// image type that can carry script.
-describe('isSafeImageSrc — image-src protocol allowlist', () => {
+// normalizeImageSrc makes a scheme-less src absolute (https://) and validates the protocol before it is saved:
+// http(s) and base64 raster data: URLs pass through; data:image/svg+xml is rejected (SVG can carry script), as
+// are data:text/html, application/*, javascript:, vbscript:, and malformed input.
+describe('normalizeImageSrc — prepend https to scheme-less src, validate protocol', () => {
it.each([
- 'http://example.com/a.png',
- 'https://example.com/a.png',
- 'data:image/png;base64,AAAA',
- 'data:image/webp;base64,AAAA',
- ])('allows %s', (url) => {
- expect(isSafeImageSrc(url)).toBe(true);
+ ['example.com/a.png', 'https://example.com/a.png'],
+ ['//cdn.example.com/a.png', 'https://cdn.example.com/a.png'],
+ [' example.com/a.png ', 'https://example.com/a.png'],
+ ['http://example.com/a.png', 'http://example.com/a.png'],
+ ['https://example.com/a.png?w=1', 'https://example.com/a.png?w=1'],
+ ['data:image/png;base64,AAAA', 'data:image/png;base64,AAAA'],
+ ['data:image/webp;base64,AAAA', 'data:image/webp;base64,AAAA'],
+ ])('normalizes %s → %s', (input, expected) => {
+ expect(normalizeImageSrc(input)).toBe(expected);
});
it.each([
@@ -24,33 +25,42 @@ describe('isSafeImageSrc — image-src protocol allowlist', () => {
'data:image/svg+xml;utf8,',
'data:image/svg+xml;base64,AAAA',
'vbscript:msgbox(1)',
- 'file:///etc/passwd',
- 'http://', // malformed → URL constructor throws → rejected via the catch
- ])('rejects %s', (url) => {
- expect(isSafeImageSrc(url)).toBe(false);
+ 'http://', // malformed → no host
+ '',
+ ])('rejects %s', (input) => {
+ expect(normalizeImageSrc(input)).toBeNull();
});
});
-describe('isSafeUrl — link-href protocol allowlist', () => {
+// A manually-typed link must be made absolute — a scheme-less input like "example.com" would otherwise persist as
+// a relative href the browser resolves against the current origin. normalizeLinkUrl prepends https:// unless the
+// input already carries an allowed scheme, validates the protocol with no base URL, and returns null for
+// unsafe/malformed input. Already-schemed values pass through verbatim (case + query chars preserved).
+describe('normalizeLinkUrl — prepend https to scheme-less input, validate protocol', () => {
it.each([
- 'https://example.com/path?a=1|2',
- 'http://example.com',
- 'mailto:a@b.com',
- 'tel:+123',
- '/relative/path',
- '#anchor',
- './sibling',
- ])('allows %s', (url) => {
- expect(isSafeUrl(url)).toBe(true);
+ ['example.com', 'https://example.com'],
+ ['www.example.com', 'https://www.example.com'],
+ ['example.com:8080', 'https://example.com:8080'],
+ ['localhost:3000', 'https://localhost:3000'],
+ ['//evil.com', 'https://evil.com'],
+ [' example.com ', 'https://example.com'],
+ ['http://example.com', 'http://example.com'],
+ ['https://example.com/path?a=1|2', 'https://example.com/path?a=1|2'],
+ ['mailto:a@b.com', 'mailto:a@b.com'],
+ ['tel:+123', 'tel:+123'],
+ ])('normalizes %s → %s', (input, expected) => {
+ expect(normalizeLinkUrl(input)).toBe(expected);
});
it.each([
'javascript:alert(1)',
'data:text/html,',
'vbscript:msgbox(1)',
- 'file:///etc/passwd',
- 'http://', // malformed → rejected via the catch
- ])('rejects %s', (url) => {
- expect(isSafeUrl(url)).toBe(false);
+ '#anchor', // no host once prepended → rejected
+ 'https://', // malformed → no host
+ '',
+ ' ',
+ ])('rejects %s', (input) => {
+ expect(normalizeLinkUrl(input)).toBeNull();
});
});
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-link-edit-form.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-link-edit-form.tsx
new file mode 100644
index 00000000..4f394e45
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-link-edit-form.tsx
@@ -0,0 +1,122 @@
+import type { Editor } from '@tiptap/react';
+
+import { ArrowUpRight, Check, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+
+import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
+
+import { normalizeLinkUrl } from './markdown-editor-toolbar-url';
+
+interface LinkEditFormProps {
+ // Focus the URL input on mount. True when the user explicitly opened the form (toolbar button); false for the
+ // on-link popover, which appears whenever the caret enters a link and must not steal focus from the doc.
+ autoFocus?: boolean;
+ editor: Editor;
+ initialUrl: string;
+ isActive: boolean;
+ onDone: () => void;
+}
+
+// Shared body of the link editor — the same URL field + validation + Apply/Open/Remove used by the toolbar Link
+// popover AND the on-link popover (markdown-editor-link-handle.tsx). Seeds its own `url` from `initialUrl` on
+// mount, so consumers give it a fresh `key` per editing session.
+export function LinkEditForm({ autoFocus = true, editor, initialUrl, isActive, onDone }: LinkEditFormProps) {
+ const [url, setUrl] = useState(initialUrl);
+
+ // Normalized absolute href (scheme prepended, protocol validated) or null when the input is unsafe/empty.
+ const href = normalizeLinkUrl(url);
+ const isInvalid = url !== '' && href === null;
+
+ const applyLink = () => {
+ if (!href) {
+ return;
+ }
+
+ const { empty } = editor.state.selection;
+
+ if (empty && !isActive) {
+ // No selection to wrap → insert the URL as its own linked text (matches Docs/Notion). Shows what the
+ // user typed but links to the normalized href.
+ editor
+ .chain()
+ .focus()
+ .insertContent({ marks: [{ attrs: { href }, type: 'link' }], text: url.trim(), type: 'text' })
+ .run();
+ } else {
+ editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
+ }
+
+ onDone();
+ };
+
+ const removeLink = () => {
+ editor.chain().focus().extendMarkRange('link').unsetLink().run();
+ onDone();
+ };
+
+ const openInNewTab = () => {
+ if (href) {
+ window.open(href, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ return (
+
+
+ setUrl(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ applyLink();
+ }
+ }}
+ placeholder="https://example.com"
+ type="url"
+ value={url}
+ />
+
+
+
+
+
+
+
+ {isActive ? (
+
+
+
+ ) : null}
+
+
+ {isInvalid ? (
+
+ Only http, https, mailto and tel links are allowed.
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx
new file mode 100644
index 00000000..4fba7411
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx
@@ -0,0 +1,121 @@
+import type { Editor } from '@tiptap/react';
+
+import { getMarkRange, posToDOMRect } from '@tiptap/core';
+import { useEffect, useState } from 'react';
+
+import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
+
+import { LinkEditForm } from './markdown-editor-link-edit-form';
+
+interface LinkTarget {
+ href: string;
+ key: string;
+ rect: { height: number; left: number; top: number; width: number };
+}
+
+// Shows the shared link editor anchored to the link under the caret. openOnClick:false (markdown-editor-extensions)
+// seats the caret in a clicked link instead of navigating, so `selection.empty && isActive('link')` detects "the
+// caret is on a link" for both mouse and keyboard. Overlay-only — it never mutates the doc until a form action
+// runs, so it stays out of the byte round-trip (like TableHandles).
+export function LinkHandle({ editor }: { editor: Editor }) {
+ const { close, target } = useLinkHandle(editor);
+
+ if (!target) {
+ return null;
+ }
+
+ const { href, key, rect } = target;
+
+ return (
+ {
+ if (!next) {
+ close();
+ }
+ }}
+ open
+ >
+
+
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ onOpenAutoFocus={(event) => {
+ // The popover appears whenever the caret enters a link, so keep focus in the doc — stealing it
+ // would interrupt typing/navigation. The user clicks into the URL field only to actually edit.
+ event.preventDefault();
+ }}
+ side="bottom"
+ sideOffset={6}
+ >
+
+
+
+ );
+}
+
+function useLinkHandle(editor: Editor) {
+ const [target, setTarget] = useState(null);
+
+ useEffect(() => {
+ const linkType = editor.schema.marks.link;
+
+ const update = () => {
+ const { selection } = editor.state;
+
+ if (!linkType || !selection.empty || !editor.isActive('link')) {
+ setTarget(null);
+
+ return;
+ }
+
+ const range = getMarkRange(selection.$from, linkType);
+
+ if (!range) {
+ setTarget(null);
+
+ return;
+ }
+
+ const rect = posToDOMRect(editor.view, range.from, range.to);
+ const href = (editor.getAttributes('link').href as string | undefined) ?? '';
+
+ setTarget({ href, key: `${range.from}-${range.to}`, rect });
+ };
+
+ const clear = () => setTarget(null);
+
+ editor.on('selectionUpdate', update);
+
+ // Fixed-positioned anchor goes stale on scroll/resize — drop it (it reappears on the next caret entry).
+ const scrollParent = editor.view.dom.closest('.tiptap-content') ?? window;
+
+ scrollParent.addEventListener('scroll', clear, { passive: true });
+ window.addEventListener('resize', clear);
+
+ return () => {
+ editor.off('selectionUpdate', update);
+ scrollParent.removeEventListener('scroll', clear);
+ window.removeEventListener('resize', clear);
+ };
+ }, [editor]);
+
+ return { close: () => setTarget(null), target };
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts
new file mode 100644
index 00000000..2f2bde78
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts
@@ -0,0 +1,102 @@
+import type { Editor } from '@tiptap/react';
+import type { LucideIcon } from 'lucide-react';
+
+import { AlignCenter, AlignLeft, AlignRight } from 'lucide-react';
+
+export type ColumnAlign = 'center' | 'left' | 'right';
+
+export const ALIGN_OPTIONS: { icon: LucideIcon; label: string; value: ColumnAlign }[] = [
+ { icon: AlignLeft, label: 'Left', value: 'left' },
+ { icon: AlignCenter, label: 'Center', value: 'center' },
+ { icon: AlignRight, label: 'Right', value: 'right' },
+];
+
+// Empties every cell in the caret's row or column (Notion's "Clear contents"), leaving the structure intact.
+export function clearLineContents(editor: Editor, axis: 'column' | 'row'): void {
+ editor
+ .chain()
+ .focus()
+ .command(({ dispatch, editor: instance, tr }) => {
+ const paragraphType = instance.schema.nodes.paragraph;
+
+ if (dispatch && paragraphType) {
+ // Descending order so each replacement leaves earlier cell positions valid.
+ for (const pos of cellPositionsInLine(editor, axis).reverse()) {
+ const cell = tr.doc.nodeAt(pos);
+
+ if (cell) {
+ tr.replaceWith(pos + 1, pos + cell.nodeSize - 1, paragraphType.create());
+ }
+ }
+ }
+
+ return true;
+ })
+ .run();
+}
+
+// Whether a table uses a header row (its first row holds `tableHeader` cells). Defaults to the table around the
+// caret; pass `pos` to read a specific cell's table instead — the hover grips open without moving the selection,
+// so they must resolve the hovered table by position rather than the caret's last table.
+export function hasHeaderRow(editor: Editor, pos?: number): boolean {
+ const $pos = pos == null ? editor.state.selection.$from : editor.state.doc.resolve(pos);
+
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ if ($pos.node(depth).type.name === 'table') {
+ return $pos.node(depth).firstChild?.firstChild?.type.name === 'tableHeader';
+ }
+ }
+
+ return false;
+}
+
+// GFM aligns whole COLUMNS (the delimiter row), stored by @tiptap/extension-table as an `align` attr on every
+// cell — setCellAttribute only touches the caret cell, so set it on the whole column or only one cell aligns
+// until the doc reloads.
+export function setColumnAlign(editor: Editor, align: ColumnAlign): void {
+ editor
+ .chain()
+ .focus()
+ .command(({ dispatch, tr }) => {
+ if (dispatch) {
+ for (const pos of cellPositionsInLine(editor, 'column')) {
+ tr.setNodeAttribute(pos, 'align', align);
+ }
+ }
+
+ return true;
+ })
+ .run();
+}
+
+// Walks the table containing the caret and yields the doc position of every cell in the caret's column (or row).
+// colspan is always 1 here — GFM tables have no merged cells — so a cell's index within its row IS its column.
+function cellPositionsInLine(editor: Editor, axis: 'column' | 'row'): number[] {
+ const { $from } = editor.state.selection;
+ let depth = $from.depth;
+
+ while (depth > 0 && !['tableCell', 'tableHeader'].includes($from.node(depth).type.name)) {
+ depth--;
+ }
+
+ if (depth === 0) {
+ return [];
+ }
+
+ const targetIndex = axis === 'column' ? $from.index(depth - 1) : $from.index(depth - 2);
+ const tableDepth = depth - 2;
+ const tablePos = $from.before(tableDepth);
+ const positions: number[] = [];
+
+ $from.node(tableDepth).forEach((row, rowOffset, rowIndex) => {
+ row.forEach((cell, cellOffset, cellIndex) => {
+ const matches = axis === 'column' ? cellIndex === targetIndex : rowIndex === targetIndex;
+
+ if (matches) {
+ positions.push(tablePos + 1 + rowOffset + 1 + cellOffset);
+ }
+ });
+ });
+
+ return positions;
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx
new file mode 100644
index 00000000..3e5a7ffa
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx
@@ -0,0 +1,370 @@
+import type { Editor } from '@tiptap/react';
+
+import { useEditorState } from '@tiptap/react';
+import {
+ AlignLeft,
+ ArrowDown,
+ ArrowLeft,
+ ArrowRight,
+ ArrowUp,
+ Delete,
+ Eraser,
+ GripHorizontal,
+ GripVertical,
+ PanelTop,
+} from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Switch } from '@/components/ui/switch';
+
+import { ALIGN_OPTIONS, clearLineContents, hasHeaderRow, setColumnAlign } from './markdown-editor-table-commands';
+
+interface HoverTarget {
+ cellPos: number;
+ colLeft: number;
+ colWidth: number;
+ rowHeight: number;
+ rowTop: number;
+ tableLeft: number;
+ tableTop: number;
+}
+
+type OpenMenu = 'column' | 'row' | null;
+
+const GRIP = 18;
+const GRIP_CLASS =
+ 'bg-muted hover:bg-accent text-muted-foreground hover:text-accent-foreground flex items-center justify-center rounded border shadow-sm transition-colors';
+
+interface TableHandlesController {
+ onMenuChange: (menu: 'column' | 'row') => (isOpen: boolean) => void;
+ open: OpenMenu;
+ target: HoverTarget | null;
+}
+
+// Notion-style hover handles: a grip appears above the hovered column and left of the hovered row; clicking it
+// opens a shadcn menu of markdown-safe row/column operations. Overlay-only — it never mutates the document until
+// a menu item runs — so it stays out of the byte round-trip. Merged cells / colour / header-column are omitted
+// (not GFM-representable); the toolbar Table dropdown carries the same ops for keyboard/no-hover users.
+export function TableHandles({ editor }: { editor: Editor }) {
+ const { onMenuChange, open, target } = useTableHandles(editor);
+
+ if (!target) {
+ return null;
+ }
+
+ const focusTarget = () => editor.chain().focus().setTextSelection(target.cellPos);
+
+ return createPortal(
+ <>
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+ focusTarget().addColumnBefore().run()}>
+
+ Insert left
+
+ focusTarget().addColumnAfter().run()}>
+
+ Insert right
+
+
+
+
+ Align column
+
+
+ {ALIGN_OPTIONS.map((option) => (
+ {
+ focusTarget().run();
+ setColumnAlign(editor, option.value);
+ }}
+ >
+
+ {option.label}
+
+ ))}
+
+
+ {
+ focusTarget().run();
+ clearLineContents(editor, 'column');
+ }}
+ >
+
+ Clear contents
+
+
+ focusTarget().deleteColumn().run()}>
+
+ Delete column
+
+
+
+
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+
+
+ focusTarget().addRowBefore().run()}>
+
+ Insert above
+
+ focusTarget().addRowAfter().run()}>
+
+ Insert below
+
+ {
+ focusTarget().run();
+ clearLineContents(editor, 'row');
+ }}
+ >
+
+ Clear contents
+
+
+ focusTarget().deleteRow().run()}>
+
+ Delete row
+
+
+
+ >,
+ document.body,
+ );
+}
+
+// Its useEditorState subscription must stay inside the row-grip DropdownMenuContent: Radix Presence unmounts it
+// on close, so the deliberately non-reactive useTableHandles hover path pays no per-transaction cost while idle.
+// Reads header state by the hovered cell's position, not the caret — the grip opens without moving the selection.
+function RowHeaderToggleItem({ cellPos, editor }: { cellPos: number; editor: Editor }) {
+ const isHeaderRow = useEditorState({
+ editor,
+ selector: ({ editor }) => hasHeaderRow(editor, cellPos),
+ });
+
+ return (
+ {
+ // preventDefault keeps the menu open so the switch flips in place; setTextSelection (not .focus())
+ // seats the toggle in the hovered table without pulling DOM focus off the open menu.
+ event.preventDefault();
+ editor.chain().setTextSelection(cellPos).toggleHeaderRow().run();
+ }}
+ role="menuitemcheckbox"
+ >
+
+ Header row
+
+
+ );
+}
+
+// Headless controller: owns every imperative DOM touch — hover tracking over ProseMirror's (non-React) cells,
+// layout measurement, and the scroll/menu-freeze bookkeeping — and hands the view a plain declarative state.
+// Swapping the hover strategy (e.g. to a ProseMirror decoration plugin) means rewriting only this hook.
+function useTableHandles(editor: Editor): TableHandlesController {
+ const [target, setTarget] = useState(null);
+ const [open, setOpen] = useState(null);
+ const clearTimer = useRef>(null);
+ const openRef = useRef(null);
+ const cellPosRef = useRef(null);
+
+ useEffect(() => {
+ openRef.current = open;
+ }, [open]);
+
+ useEffect(() => {
+ const dom = editor.view.dom;
+
+ const cancelClear = () => {
+ if (clearTimer.current) {
+ clearTimeout(clearTimer.current);
+ clearTimer.current = null;
+ }
+ };
+
+ const clearSoon = () => {
+ cancelClear();
+ clearTimer.current = setTimeout(() => {
+ if (!openRef.current) {
+ cellPosRef.current = null;
+ setTarget(null);
+ }
+ }, 120);
+ };
+
+ // One document-level listener rather than the editor's own: the grip straddles the table border, so the
+ // editor's mouseleave fires the instant the cursor crosses onto it and it would flicker away. Here we
+ // classify each move — over a grip (keep), over a cell (reposition), over neither (hide) — so travelling
+ // from a cell onto the grip never clears. `cellPosRef` skips re-renders while the cursor stays in one cell.
+ const handleMove = (event: MouseEvent) => {
+ if (openRef.current) {
+ return;
+ }
+
+ const el = event.target as HTMLElement | null;
+
+ if (el?.closest?.('[data-table-grip]')) {
+ cancelClear();
+
+ return;
+ }
+
+ const cell = el?.closest?.('td, th');
+
+ if (!(cell instanceof HTMLElement) || !dom.contains(cell)) {
+ if (cellPosRef.current !== null) {
+ clearSoon();
+ }
+
+ return;
+ }
+
+ cancelClear();
+
+ let cellPos: number;
+
+ try {
+ cellPos = editor.view.posAtDOM(cell, 0);
+ } catch {
+ return;
+ }
+
+ if (cellPos === cellPosRef.current) {
+ return;
+ }
+
+ const row = cell.closest('tr');
+ const table = cell.closest('table');
+
+ if (!row || !table) {
+ return;
+ }
+
+ cellPosRef.current = cellPos;
+
+ const cellRect = cell.getBoundingClientRect();
+ const rowRect = row.getBoundingClientRect();
+ const tableRect = table.getBoundingClientRect();
+
+ setTarget({
+ cellPos,
+ colLeft: cellRect.left,
+ colWidth: cellRect.width,
+ rowHeight: rowRect.height,
+ rowTop: rowRect.top,
+ tableLeft: tableRect.left,
+ tableTop: tableRect.top,
+ });
+ };
+
+ document.addEventListener('mousemove', handleMove);
+
+ // Fixed-positioned grips go stale on scroll — drop them (they reappear on the next hover).
+ const scrollParent = dom.closest('.tiptap-content') ?? window;
+
+ const handleScroll = () => {
+ if (!openRef.current) {
+ cellPosRef.current = null;
+ setTarget(null);
+ }
+ };
+
+ scrollParent.addEventListener('scroll', handleScroll, { passive: true });
+
+ return () => {
+ cancelClear();
+ document.removeEventListener('mousemove', handleMove);
+ scrollParent.removeEventListener('scroll', handleScroll);
+ };
+ }, [editor]);
+
+ const onMenuChange = (menu: 'column' | 'row') => (isOpen: boolean) => {
+ setOpen(isOpen ? menu : null);
+
+ if (!isOpen) {
+ cellPosRef.current = null;
+ setTarget(null);
+ }
+ };
+
+ return { onMenuChange, open, target };
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-button.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-button.tsx
new file mode 100644
index 00000000..bb303c5a
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-button.tsx
@@ -0,0 +1,86 @@
+import type { ReactNode } from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Toggle } from '@/components/ui/toggle';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+
+interface ToolbarButtonProps {
+ children: ReactNode;
+ disabled?: boolean;
+ label: string;
+ onClick: () => void;
+ shortcut?: string;
+}
+
+interface ToolbarToggleProps {
+ children: ReactNode;
+ disabled?: boolean;
+ label: string;
+ onPressedChange: () => void;
+ pressed: boolean;
+ shortcut?: string;
+}
+
+interface ToolbarTooltipProps {
+ children: ReactNode;
+ label: string;
+ shortcut?: string;
+}
+
+// One-shot actions (undo, insert…). A plain button — no aria-pressed, unlike a Toggle.
+export function ToolbarButton({ children, disabled, label, onClick, shortcut }: ToolbarButtonProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// Marks/blocks with a genuine on/off state (bold, italic, lists…). Renders aria-pressed via Radix Toggle.
+export function ToolbarToggle({ children, disabled, label, onPressedChange, pressed, shortcut }: ToolbarToggleProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ToolbarTooltip({ children, label, shortcut }: ToolbarTooltipProps) {
+ return (
+
+ {children}
+
+ {label}
+ {shortcut ? (
+ {shortcut}
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-heading.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-heading.tsx
new file mode 100644
index 00000000..d7921be2
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-heading.tsx
@@ -0,0 +1,104 @@
+import type { Editor } from '@tiptap/react';
+import type { LucideIcon } from 'lucide-react';
+
+import { Check, ChevronDown, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Type } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+
+export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+interface HeadingOption {
+ icon: LucideIcon;
+ // Optical trim for the dropdown list ONLY (icons sit next to each other there): lucide's Type glyph is drawn
+ // taller (16u) than the Heading glyphs (12u), so at the same 16px box it reads bigger — scale it down to match
+ // without shrinking the box (which would misalign labels). The trigger shows one icon alone, so it stays full size.
+ iconClassName?: string;
+ label: string;
+ value: 'paragraph' | HeadingLevel;
+}
+
+const OPTIONS: HeadingOption[] = [
+ { icon: Heading1, label: 'Heading 1', value: 1 },
+ { icon: Heading2, label: 'Heading 2', value: 2 },
+ { icon: Heading3, label: 'Heading 3', value: 3 },
+ { icon: Heading4, label: 'Heading 4', value: 4 },
+ { icon: Heading5, label: 'Heading 5', value: 5 },
+ { icon: Heading6, label: 'Heading 6', value: 6 },
+ { icon: Type, iconClassName: 'scale-[0.75]', label: 'Text', value: 'paragraph' },
+];
+
+interface HeadingMenuProps {
+ // 0 = paragraph / any non-heading block; 1-6 = the active heading level.
+ activeLevel: 0 | HeadingLevel;
+ disabled?: boolean;
+ editor: Editor;
+}
+
+export function HeadingMenu({ activeLevel, disabled, editor }: HeadingMenuProps) {
+ const isSelected = (value: HeadingOption['value']) =>
+ value === 'paragraph' ? activeLevel === 0 : value === activeLevel;
+ const active = OPTIONS.find((option) => isSelected(option.value)) ?? OPTIONS[0];
+ const ActiveIcon = active?.icon ?? Type;
+
+ const applyOption = (value: HeadingOption['value']) => {
+ if (value === 'paragraph') {
+ editor.chain().focus().setParagraph().run();
+
+ return;
+ }
+
+ editor.chain().focus().toggleHeading({ level: value }).run();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Text style
+
+ {
+ // Return focus to the editor caret (not the trigger button) so the user keeps typing.
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+ {OPTIONS.map((option) => (
+ applyOption(option.value)}
+ >
+
+ {option.label}
+ {isSelected(option.value) ? : null}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-image.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-image.tsx
new file mode 100644
index 00000000..1064b392
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-image.tsx
@@ -0,0 +1,60 @@
+import type { Editor } from '@tiptap/react';
+
+import { ImagePlus } from 'lucide-react';
+import { useState } from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+
+import { ImageEditForm } from './markdown-editor-image-edit-form';
+
+interface ImagePopoverProps {
+ disabled?: boolean;
+ editor: Editor;
+}
+
+export function ImagePopover({ disabled, editor }: ImagePopoverProps) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Insert image
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+ setOpen(false)}
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-link.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-link.tsx
new file mode 100644
index 00000000..0970d585
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-link.tsx
@@ -0,0 +1,60 @@
+import type { Editor } from '@tiptap/react';
+
+import { Link as LinkIcon } from 'lucide-react';
+import { useState } from 'react';
+
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Toggle } from '@/components/ui/toggle';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+
+import { LinkEditForm } from './markdown-editor-link-edit-form';
+
+interface LinkPopoverProps {
+ disabled?: boolean;
+ editor: Editor;
+ isActive: boolean;
+}
+
+export function LinkPopover({ disabled, editor, isActive }: LinkPopoverProps) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Link
+
+ {
+ // Return focus to the editor caret (not the trigger) so the user keeps typing after apply/cancel.
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+ setOpen(false)}
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-list.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-list.tsx
new file mode 100644
index 00000000..20555c9f
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-list.tsx
@@ -0,0 +1,96 @@
+import type { Editor } from '@tiptap/react';
+import type { LucideIcon } from 'lucide-react';
+
+import { Check, ChevronDown, List, ListOrdered, ListTodo } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+
+export type ListType = 'bullet' | 'ordered' | 'task';
+
+interface ListOption {
+ icon: LucideIcon;
+ label: string;
+ value: ListType;
+}
+
+const OPTIONS: ListOption[] = [
+ { icon: List, label: 'Bullet list', value: 'bullet' },
+ { icon: ListOrdered, label: 'Ordered list', value: 'ordered' },
+ { icon: ListTodo, label: 'Task list', value: 'task' },
+];
+
+interface ListMenuProps {
+ activeType: ListType | null;
+ disabled?: boolean;
+ editor: Editor;
+}
+
+export function ListMenu({ activeType, disabled, editor }: ListMenuProps) {
+ const active = OPTIONS.find((option) => option.value === activeType);
+ // The bullet-list icon doubles as the resting affordance, so the active background — not the glyph — is what
+ // distinguishes "in a bullet list" from "no list".
+ const TriggerIcon = active?.icon ?? List;
+
+ const applyOption = (value: ListType) => {
+ const chain = editor.chain().focus();
+
+ if (value === 'bullet') {
+ chain.toggleBulletList().run();
+ } else if (value === 'ordered') {
+ chain.toggleOrderedList().run();
+ } else {
+ chain.toggleTaskList().run();
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Lists
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+ {OPTIONS.map((option) => (
+ applyOption(option.value)}
+ >
+
+ {option.label}
+ {activeType === option.value ? : null}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-table.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-table.tsx
new file mode 100644
index 00000000..9a48194a
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-table.tsx
@@ -0,0 +1,159 @@
+import type { Editor } from '@tiptap/react';
+
+import {
+ AlignLeft,
+ ArrowDown,
+ ArrowLeft,
+ ArrowRight,
+ ArrowUp,
+ Check,
+ ChevronDown,
+ Delete,
+ PanelTop,
+ Table as TableIcon,
+ Trash2,
+} from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Switch } from '@/components/ui/switch';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+
+import { ALIGN_OPTIONS, type ColumnAlign, setColumnAlign } from './markdown-editor-table-commands';
+
+export type { ColumnAlign };
+
+interface TableMenuProps {
+ columnAlign: ColumnAlign | null;
+ disabled?: boolean;
+ editor: Editor;
+ isActive: boolean;
+ isHeaderRow: boolean;
+}
+
+export function TableMenu({ columnAlign, disabled, editor, isActive, isHeaderRow }: TableMenuProps) {
+ const run = (fn: (chain: ReturnType) => ReturnType) =>
+ fn(editor.chain().focus()).run();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Table
+
+ {
+ event.preventDefault();
+ editor.commands.focus();
+ }}
+ >
+ {isActive ? (
+ <>
+ {
+ // Keep the menu open so the switch animates in place, like Notion's block menu.
+ event.preventDefault();
+ editor.chain().toggleHeaderRow().run();
+ }}
+ role="menuitemcheckbox"
+ >
+
+ Header row
+
+
+
+ run((chain) => chain.addRowBefore())}>
+
+ Insert row above
+
+ run((chain) => chain.addRowAfter())}>
+
+ Insert row below
+
+ run((chain) => chain.addColumnBefore())}>
+
+ Insert column left
+
+ run((chain) => chain.addColumnAfter())}>
+
+ Insert column right
+
+
+
+
+ Align column
+
+
+ {ALIGN_OPTIONS.map((option) => (
+ setColumnAlign(editor, option.value)}
+ >
+
+ {option.label}
+ {(columnAlign ?? 'left') === option.value ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ run((chain) => chain.deleteRow())}>
+
+ Delete row
+
+ run((chain) => chain.deleteColumn())}>
+
+ Delete column
+
+
+ run((chain) => chain.deleteTable())}>
+
+ Delete table
+
+ >
+ ) : (
+ run((chain) => chain.insertTable({ cols: 3, rows: 3, withHeaderRow: true }))}
+ >
+
+ Insert table
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-url.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-url.ts
new file mode 100644
index 00000000..109d97d4
--- /dev/null
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar-url.ts
@@ -0,0 +1,57 @@
+// Authoring-time protocol allowlists for the toolbar's Link and Image popovers. They keep javascript:/data:text
+// (and, for images, script-capable data:image/svg+xml) out of the persisted document so fidelity never depends on
+// every future render path sanitizing them. Load/paste bypass these — tiptap Link's isAllowedUri and the
+// read-only viewer sanitize protocols on render; an is inert regardless.
+
+const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
+
+const KNOWN_LINK_SCHEME = /^(?:https?:\/\/|mailto:|tel:)/i;
+
+// A scheme-less link ("example.com", "localhost:3000") must be made absolute, or the browser resolves it against
+// the current origin as a relative path. Prepend https:// unless the input already carries an allowed scheme, then
+// validate the protocol. Validate with NO base URL — passing window.location as base (as a plain protocol check
+// would) launders a relative value into https: and is exactly the bug this replaces. Returns the normalized href,
+// or null for unsafe/malformed input (javascript:/data:/file:, empty). Neither tiptap nor its UI normalize manual
+// entry — both persist the raw href verbatim — so we do it here.
+export const normalizeLinkUrl = (raw: string): null | string => {
+ const url = raw.trim();
+
+ if (!url) {
+ return null;
+ }
+
+ const candidate = KNOWN_LINK_SCHEME.test(url) ? url : `https://${url.replace(/^\/+/, '')}`;
+
+ try {
+ return SAFE_LINK_PROTOCOLS.has(new URL(candidate).protocol) ? candidate : null;
+ } catch {
+ return null;
+ }
+};
+
+const RASTER_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i;
+
+// Image analog of normalizeLinkUrl: a scheme-less src ("example.com/a.png") is made absolute with https://; an
+// already-schemed http(s) URL and a base64 raster data: URL pass through. data:image/svg+xml is rejected — SVG
+// can carry script. Returns the normalized src, or null for unsafe/malformed input.
+export const normalizeImageSrc = (raw: string): null | string => {
+ const src = raw.trim();
+
+ if (!src) {
+ return null;
+ }
+
+ const candidate = /^(?:https?:\/\/|data:)/i.test(src) ? src : `https://${src.replace(/^\/+/, '')}`;
+
+ if (/^data:/i.test(candidate)) {
+ return RASTER_IMAGE_DATA.test(candidate) ? candidate : null;
+ }
+
+ try {
+ const { protocol } = new URL(candidate);
+
+ return protocol === 'http:' || protocol === 'https:' ? candidate : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx
index 3889d1ff..0aea497c 100644
--- a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx
@@ -4,317 +4,374 @@ import { useEditorState } from '@tiptap/react';
import {
Bold,
Code,
- Code2,
- Heading1,
- Heading2,
- Heading3,
- ImagePlus,
Italic,
- Link as LinkIcon,
- List,
- ListOrdered,
- ListTodo,
Minus,
Quote,
Redo,
+ RemoveFormatting,
+ SquareCode,
Strikethrough,
- Table,
Undo,
} from 'lucide-react';
-import { memo, useCallback } from 'react';
+import { memo, useEffect, useRef } from 'react';
import { Separator } from '@/components/ui/separator';
-import { Toggle } from '@/components/ui/toggle';
+import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
+import type { HeadingLevel } from './markdown-editor-toolbar-heading';
+import type { ListType } from './markdown-editor-toolbar-list';
+import type { ColumnAlign } from './markdown-editor-toolbar-table';
+
+import { hasHeaderRow } from './markdown-editor-table-commands';
+import { ToolbarButton, ToolbarToggle } from './markdown-editor-toolbar-button';
+import { HeadingMenu } from './markdown-editor-toolbar-heading';
+import { ImagePopover } from './markdown-editor-toolbar-image';
+import { LinkPopover } from './markdown-editor-toolbar-link';
+import { ListMenu } from './markdown-editor-toolbar-list';
+import { TableMenu } from './markdown-editor-toolbar-table';
+
interface MarkdownEditorToolbarProps {
disabled?: boolean;
editor: Editor;
}
-// The Image extension doesn't validate the src protocol, so the toolbar Insert-image button rejects
-// non-http(s)/non-raster-data URLs (javascript:, data:text/html, data:image/svg+xml — SVG can carry script).
-// Guards ONLY that button — images entering via markdown load/paste bypass it (inert in an ; the
-// read-only viewer sanitizes protocols on render).
-export const isSafeImageSrc = (url: string): boolean => {
- try {
- const { protocol } = new URL(url, window.location.href);
+const HEADING_LEVELS: HeadingLevel[] = [1, 2, 3, 4, 5, 6];
- return (
- protocol === 'http:' || protocol === 'https:' || /^data:image\/(png|jpe?g|gif|webp|bmp);base64,/i.test(url)
- );
- } catch {
- return false;
- }
-};
+// A mouse wheel emits deltaY, which the browser applies to the nearest VERTICAL scroller — never to this
+// overflow-x strip, so a wheel-mouse user can't reach overflowed controls (trackpads emit deltaX and already
+// work). Translate a vertical-dominant wheel into horizontal scroll, releasing at the ends so the page can
+// still scroll past the toolbar. Attached non-passively — React's onWheel is passive, so it can't preventDefault.
+function useHorizontalWheelScroll(ref: React.RefObject) {
+ useEffect(() => {
+ const strip = ref.current;
-// Link-toolbar counterpart of isSafeImageSrc: only navigable protocols (relative/anchor URLs resolve to the
-// page's http/https). Keeps `javascript:` / `data:` out of the persisted document at authoring time so it
-// never depends on every future render path sanitizing them. Load/paste bypass this (tiptap Link's
-// isAllowedUri + the read-only viewer sanitize protocols on render).
-const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
+ if (!strip) {
+ return;
+ }
-export const isSafeUrl = (url: string): boolean => {
- try {
- return SAFE_LINK_PROTOCOLS.has(new URL(url, window.location.href).protocol);
- } catch {
- return false;
- }
-};
+ const handleWheel = (event: WheelEvent) => {
+ if (strip.scrollWidth <= strip.clientWidth || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
+ return;
+ }
-// memo: every keystroke re-renders the RHF-controlled parent; without it all ~20 Toggle subtrees re-render
+ const atStart = strip.scrollLeft <= 0 && event.deltaY < 0;
+ const atEnd = strip.scrollLeft + strip.clientWidth >= strip.scrollWidth && event.deltaY > 0;
+
+ if (atStart || atEnd) {
+ return;
+ }
+
+ strip.scrollLeft += event.deltaY;
+ event.preventDefault();
+ };
+
+ strip.addEventListener('wheel', handleWheel, { passive: false });
+
+ return () => strip.removeEventListener('wheel', handleWheel);
+ }, [ref]);
+}
+
+// WAI-ARIA toolbar pattern: one Tab stop for the whole bar, Arrow/Home/End move between controls. Managed
+// imperatively on `[data-toolbar-item]` so each control stays a dumb button; the set changes (the table
+// control swaps button↔menu, controls disable) so a MutationObserver re-seeds the single tab stop. When a
+// popover/dropdown is open its focus lives in a body portal outside the bar, so activeElement isn't an item
+// and Arrow keys fall through to that menu instead of being hijacked here.
+function useToolbarRovingFocus(ref: React.RefObject) {
+ useEffect(() => {
+ const toolbar = ref.current;
+
+ if (!toolbar) {
+ return;
+ }
+
+ const enabledItems = () =>
+ Array.from(toolbar.querySelectorAll('[data-toolbar-item]')).filter(
+ (item) => !item.hasAttribute('disabled'),
+ );
+
+ const seedTabStop = () => {
+ const items = enabledItems();
+ const active = items.find((item) => item.tabIndex === 0) ?? items[0] ?? null;
+
+ for (const item of toolbar.querySelectorAll('[data-toolbar-item]')) {
+ item.tabIndex = item === active ? 0 : -1;
+ }
+ };
+
+ seedTabStop();
+
+ const observer = new MutationObserver(seedTabStop);
+ observer.observe(toolbar, { attributeFilter: ['disabled'], childList: true, subtree: true });
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!['ArrowLeft', 'ArrowRight', 'End', 'Home'].includes(event.key)) {
+ return;
+ }
+
+ const items = enabledItems();
+ const index = items.indexOf(document.activeElement as HTMLElement);
+
+ if (index === -1) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const nextIndex =
+ event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? items.length - 1
+ : event.key === 'ArrowRight'
+ ? (index + 1) % items.length
+ : (index - 1 + items.length) % items.length;
+ const next = items[nextIndex];
+
+ if (!next) {
+ return;
+ }
+
+ for (const item of items) {
+ item.tabIndex = item === next ? 0 : -1;
+ }
+
+ next.focus();
+ };
+
+ toolbar.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ toolbar.removeEventListener('keydown', handleKeyDown);
+ observer.disconnect();
+ };
+ }, [ref]);
+}
+
+// memo: every keystroke re-renders the RHF-controlled parent; without it all Toggle/Button subtrees re-render
// per keystroke for referentially-stable props.
export const MarkdownEditorToolbar = memo(function MarkdownEditorToolbar({
disabled,
editor,
}: MarkdownEditorToolbarProps) {
- const handleSetLink = useCallback(() => {
- const previousUrl = editor.getAttributes('link').href as string | undefined;
- const url = window.prompt('URL', previousUrl ?? '');
-
- if (url === null) {
- return;
- }
-
- if (url === '') {
- editor.chain().focus().extendMarkRange('link').unsetLink().run();
-
- return;
- }
-
- if (!isSafeUrl(url)) {
- return;
- }
-
- editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
- }, [editor]);
-
// tiptap v3's useEditor does NOT re-render on every transaction, so reading editor.isActive/can() inline
// would leave the toolbar stale on selection-only moves (click into a bold word → Bold stays unlit).
- // useEditorState re-runs this selector per transaction and re-renders only when a button's state flips.
+ // useEditorState re-runs this selector per transaction and re-renders only when a value flips.
const state = useEditorState({
editor,
selector: ({ editor }) => ({
+ activeListType: (editor.isActive('bulletList')
+ ? 'bullet'
+ : editor.isActive('orderedList')
+ ? 'ordered'
+ : editor.isActive('taskList')
+ ? 'task'
+ : null) as ListType | null,
canRedo: editor.can().redo(),
canUndo: editor.can().undo(),
+ columnAlign: (editor.getAttributes('tableHeader').align ??
+ editor.getAttributes('tableCell').align ??
+ null) as ColumnAlign | null,
+ headingLevel: (HEADING_LEVELS.find((level) => editor.isActive('heading', { level })) ?? 0) as
+ | 0
+ | HeadingLevel,
isBlockquote: editor.isActive('blockquote'),
isBold: editor.isActive('bold'),
- isBulletList: editor.isActive('bulletList'),
isCode: editor.isActive('code'),
isCodeBlock: editor.isActive('codeBlock'),
- isH1: editor.isActive('heading', { level: 1 }),
- isH2: editor.isActive('heading', { level: 2 }),
- isH3: editor.isActive('heading', { level: 3 }),
+ isHeaderRow: hasHeaderRow(editor),
isItalic: editor.isActive('italic'),
isLink: editor.isActive('link'),
- isOrderedList: editor.isActive('orderedList'),
isStrike: editor.isActive('strike'),
isTable: editor.isActive('table'),
- isTaskList: editor.isActive('taskList'),
}),
});
+ const toolbarRef = useRef(null);
+ const stripRef = useRef(null);
+
+ useToolbarRovingFocus(toolbarRef);
+ useHorizontalWheelScroll(stripRef);
+
return (
-
-
editor.chain().focus().toggleBold().run()}
- pressed={state.isBold}
- size="sm"
- title="Bold (Ctrl+B)"
+
+
-
-
-
editor.chain().focus().toggleItalic().run()}
- pressed={state.isItalic}
- size="sm"
- title="Italic (Ctrl+I)"
- >
-
-
-
editor.chain().focus().toggleStrike().run()}
- pressed={state.isStrike}
- size="sm"
- title="Strikethrough"
- >
-
-
-
editor.chain().focus().toggleCode().run()}
- pressed={state.isCode}
- size="sm"
- title="Inline code"
- >
-
-
-
-
-
-
editor.chain().focus().toggleHeading({ level: 1 }).run()}
- pressed={state.isH1}
- size="sm"
- title="Heading 1"
- >
-
-
-
editor.chain().focus().toggleHeading({ level: 2 }).run()}
- pressed={state.isH2}
- size="sm"
- title="Heading 2"
- >
-
-
-
editor.chain().focus().toggleHeading({ level: 3 }).run()}
- pressed={state.isH3}
- size="sm"
- title="Heading 3"
- >
-
-
-
-
-
-
editor.chain().focus().toggleBulletList().run()}
- pressed={state.isBulletList}
- size="sm"
- title="Bullet list"
- >
-
-
-
editor.chain().focus().toggleOrderedList().run()}
- pressed={state.isOrderedList}
- size="sm"
- title="Ordered list"
- >
-
-
-
editor.chain().focus().toggleTaskList().run()}
- pressed={state.isTaskList}
- size="sm"
- title="Task list"
- >
-
-
-
-
-
-
editor.chain().focus().toggleBlockquote().run()}
- pressed={state.isBlockquote}
- size="sm"
- title="Blockquote"
- >
-
-
-
editor.chain().focus().toggleCodeBlock().run()}
- pressed={state.isCodeBlock}
- size="sm"
- title="Code block"
- >
-
-
-
-
-
-
{
- const url = window.prompt('Image URL');
-
- if (url && isSafeImageSrc(url)) {
- editor.chain().focus().setImage({ src: url }).run();
- }
- }}
- pressed={false}
- size="sm"
- title="Insert image"
- >
-
-
-
editor.chain().focus().setHorizontalRule().run()}
- pressed={false}
- size="sm"
- title="Horizontal rule"
- >
-
-
-
- editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: true }).run()
- }
- pressed={state.isTable}
- size="sm"
- title="Insert table"
- >
-
-
-
-
-
editor.chain().focus().undo().run()}
- pressed={false}
- size="sm"
- title="Undo (Ctrl+Z)"
+ {/* Scroll strip: controls never wrap — they scroll horizontally. min-w-0 lets this flex child
+ shrink below its content so the overflow actually engages; Undo/Redo live OUTSIDE it (below) so
+ they stay pinned right instead of scrolling away (ml-auto collapses to 0 once a flex row overflows). */}
+
-
-
-
editor.chain().focus().redo().run()}
- pressed={false}
- size="sm"
- title="Redo (Ctrl+Shift+Z)"
+
+
+
+
+
+ editor.chain().focus().toggleBold().run()}
+ pressed={state.isBold}
+ shortcut="⌘B"
+ >
+
+
+ editor.chain().focus().toggleItalic().run()}
+ pressed={state.isItalic}
+ shortcut="⌘I"
+ >
+
+
+ editor.chain().focus().toggleStrike().run()}
+ pressed={state.isStrike}
+ >
+
+
+ editor.chain().focus().toggleCode().run()}
+ pressed={state.isCode}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ editor.chain().focus().toggleBlockquote().run()}
+ pressed={state.isBlockquote}
+ >
+
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ pressed={state.isCodeBlock}
+ >
+
+
+
+ editor.chain().focus().setHorizontalRule().run()}
+ >
+
+
+
+
+
+
+ editor.chain().focus().unsetAllMarks().clearNodes().run()}
+ >
+
+
+
+
+
+
+
-
-
+ editor.chain().focus().undo().run()}
+ shortcut="⌘Z"
+ >
+
+
+ editor.chain().focus().redo().run()}
+ shortcut="⇧⌘Z"
+ >
+
+
+
-
+
);
});
diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor.tsx
index e4acc966..f85d8c46 100644
--- a/frontend/src/components/shared/markdown-editor/markdown-editor.tsx
+++ b/frontend/src/components/shared/markdown-editor/markdown-editor.tsx
@@ -10,7 +10,10 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react
import { cn } from '@/lib/utils';
import { createMarkdownExtensions } from './markdown-editor-extensions';
+import { ImageHandle } from './markdown-editor-image-handle';
+import { LinkHandle } from './markdown-editor-link-handle';
import { MARKDOWN_EDITOR_WRAPPER_CLASS as WRAPPER_CLASS } from './markdown-editor-styles';
+import { TableHandles } from './markdown-editor-table-handles';
import { MarkdownEditorToolbar } from './markdown-editor-toolbar';
import { findVariableOccurrences } from './markdown-editor-variable-highlight';
import { nextVariableRange } from './markdown-editor-variable-syntax';
@@ -33,6 +36,16 @@ interface MarkdownEditorProps {
value: string;
}
+interface UseMarkdownEditorOptions extends Pick {
+ disabled?: boolean;
+ handleRef?: Ref;
+ id?: string;
+ onBlur?: () => void;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ value: string;
+}
+
export const resetUndoHistory = (editor: Editor): void => {
const { state, view } = editor;
// A fresh history() shares prosemirror-history's module-level singleton PluginKey, so match the
@@ -77,10 +90,81 @@ function MarkdownEditor({
id,
onBlur,
onChange,
- placeholder = 'Write something…',
+ placeholder,
ref,
value,
}: MarkdownEditorProps & { ref?: Ref }) {
+ const editor = useMarkdownEditor({
+ 'aria-describedby': ariaDescribedby,
+ 'aria-invalid': ariaInvalid,
+ disabled,
+ handleRef: ref,
+ id,
+ onBlur,
+ onChange,
+ placeholder,
+ value,
+ });
+
+ if (!editor) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {!disabled &&
}
+ {!disabled &&
}
+ {!disabled &&
}
+
+ );
+}
+
+// Owns the whole tiptap lifecycle for a markdown-controlled editor: instance creation, the RHF value-sync loop
+// (echo suppression + undo reset), disabled toggling, a11y-attribute forwarding, and the imperative handle. The
+// view below just renders whatever editor this returns — the lifecycle can be reworked here without touching it.
+function useMarkdownEditor({
+ 'aria-describedby': ariaDescribedby,
+ 'aria-invalid': ariaInvalid,
+ disabled,
+ handleRef,
+ id,
+ onBlur,
+ onChange,
+ placeholder = 'Write something…',
+ value,
+}: UseMarkdownEditorOptions): Editor | null {
// Suppress echoes of our own output: the markdown round-trip re-serializes slightly (whitespace/list
// markers/blank lines), and those normalizations must not flip RHF's isDirty as if the user had edited.
const lastEmittedRef = useRef(value);
@@ -131,7 +215,7 @@ function MarkdownEditor({
});
useImperativeHandle(
- ref,
+ handleRef,
() => ({
focus: () => {
editor?.commands.focus();
@@ -246,46 +330,7 @@ function MarkdownEditor({
}
}, [editor, ariaDescribedby, ariaInvalid, id]);
- if (!editor) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
- );
+ return editor;
}
export { MarkdownEditor };
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
index b51928a5..514b69c6 100644
--- a/frontend/src/components/ui/select.tsx
+++ b/frontend/src/components/ui/select.tsx
@@ -81,7 +81,7 @@ function SelectScrollDownButton({
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
-
+
);
}
@@ -117,7 +117,7 @@ function SelectTrigger({ children, className, ...props }: React.ComponentProps
{children}
-
+
);