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 ( +
+
+ + + setSrc(event.target.value)} + onKeyDown={applyOnEnter} + placeholder="https://example.com/image.png" + type="url" + value={src} + /> + + + + + {isEditing ? ( + + + + ) : null} + + +
+
+ + setAlt(event.target.value)} + onKeyDown={applyOnEnter} + placeholder="Describe the image" + value={alt} + /> +
+ {isInvalid ? ( + + ) : 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 ( + + + + ); +} + +// 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} - + );