From 64f831871e165939063311f2121a692c4dab540b Mon Sep 17 00:00:00 2001 From: martian56 Date: Sun, 5 Jul 2026 10:56:48 +0400 Subject: [PATCH 1/2] feat(pages): add slash commands and @mentions to the page editor The editor's placeholder promised a "/" menu that never existed, and there was no way to mention people. Both work now. - Slash menu: type "/" to insert text, headings, bulleted/numbered/to-do lists, a quote, a code block, a 3x3 table, an image, or a divider. Filter by typing. - @mentions: type "@" to mention a workspace member; the chip stores the member id. Members are loaded on the page and read lazily so ones that arrive after the editor mounts still show up. Both menus share a small suggestion-popup helper that renders a body-level menu under the caret with arrow-key navigation and Enter to pick, so there's no extra popup dependency. Closes #188 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/page-editor/MentionMenu.tsx | 35 ++++ apps/web/src/components/page-editor/index.ts | 1 + .../components/page-editor/mentionTypes.ts | 5 + .../src/components/page-editor/mentions.ts | 23 +++ .../components/page-editor/slashCommands.tsx | 194 ++++++++++++++++++ .../page-editor/suggestionPopup.tsx | 85 ++++++++ .../components/page-editor/usePageEditor.ts | 23 ++- apps/web/src/index.css | 7 + apps/web/src/pages/PageDetailPage.tsx | 25 +++ 9 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/page-editor/MentionMenu.tsx create mode 100644 apps/web/src/components/page-editor/mentionTypes.ts create mode 100644 apps/web/src/components/page-editor/mentions.ts create mode 100644 apps/web/src/components/page-editor/slashCommands.tsx create mode 100644 apps/web/src/components/page-editor/suggestionPopup.tsx diff --git a/apps/web/src/components/page-editor/MentionMenu.tsx b/apps/web/src/components/page-editor/MentionMenu.tsx new file mode 100644 index 00000000..bb568fc4 --- /dev/null +++ b/apps/web/src/components/page-editor/MentionMenu.tsx @@ -0,0 +1,35 @@ +import { Avatar } from '../ui'; +import { getImageUrl } from '../../lib/utils'; +import type { SuggestionMenuProps } from './suggestionPopup'; +import type { MentionItem } from './mentionTypes'; + +export function MentionMenu({ items, selectedIndex, onSelect }: SuggestionMenuProps) { + if (items.length === 0) { + return ( +
+ No members found +
+ ); + } + return ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/page-editor/index.ts b/apps/web/src/components/page-editor/index.ts index ee0d633c..1cef5941 100644 --- a/apps/web/src/components/page-editor/index.ts +++ b/apps/web/src/components/page-editor/index.ts @@ -1,4 +1,5 @@ export { usePageEditor, type UsePageEditorOptions } from './usePageEditor'; +export { type MentionItem } from './mentionTypes'; export { PageEditorToolbar } from './PageEditorToolbar'; export { PageEditorContent } from './PageEditorContent'; export { PageOutline } from './PageOutline'; diff --git a/apps/web/src/components/page-editor/mentionTypes.ts b/apps/web/src/components/page-editor/mentionTypes.ts new file mode 100644 index 00000000..48a9a48f --- /dev/null +++ b/apps/web/src/components/page-editor/mentionTypes.ts @@ -0,0 +1,5 @@ +export interface MentionItem { + id: string; + label: string; + avatarUrl?: string | null; +} diff --git a/apps/web/src/components/page-editor/mentions.ts b/apps/web/src/components/page-editor/mentions.ts new file mode 100644 index 00000000..1ef4b06a --- /dev/null +++ b/apps/web/src/components/page-editor/mentions.ts @@ -0,0 +1,23 @@ +import Mention from '@tiptap/extension-mention'; +import { createSuggestionRenderer } from './suggestionPopup'; +import { MentionMenu } from './MentionMenu'; +import type { MentionItem } from './mentionTypes'; + +/** + * @mention of workspace members. `getItems` is read at suggestion time so the + * editor picks up members loaded after it mounts. + */ +export const createMention = (getItems: () => MentionItem[]) => + Mention.configure({ + HTMLAttributes: { class: 'page-mention' }, + suggestion: { + char: '@', + items: ({ query }) => { + const q = query.trim().toLowerCase(); + const list = getItems(); + const matched = q ? list.filter((i) => i.label.toLowerCase().includes(q)) : list; + return matched.slice(0, 8); + }, + render: createSuggestionRenderer(MentionMenu), + }, + }); diff --git a/apps/web/src/components/page-editor/slashCommands.tsx b/apps/web/src/components/page-editor/slashCommands.tsx new file mode 100644 index 00000000..dbc6d2a4 --- /dev/null +++ b/apps/web/src/components/page-editor/slashCommands.tsx @@ -0,0 +1,194 @@ +import { Extension, type Range } from '@tiptap/core'; +import Suggestion from '@tiptap/suggestion'; +import type { Editor } from '@tiptap/react'; +import { + Code2, + Heading1, + Heading2, + Heading3, + Image as ImageIcon, + List, + ListOrdered, + ListTodo, + Minus, + Table as TableIcon, + TextQuote, + Type, + type LucideIcon, +} from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { createSuggestionRenderer, type SuggestionMenuProps } from './suggestionPopup'; + +interface SlashItem { + title: string; + subtitle: string; + icon: LucideIcon; + keywords: string[]; + run: (editor: Editor, range: Range) => void; +} + +const ITEMS: SlashItem[] = [ + { + title: 'Text', + subtitle: 'Plain paragraph', + icon: Type, + keywords: ['paragraph', 'body'], + run: (editor, range) => editor.chain().focus().deleteRange(range).setParagraph().run(), + }, + { + title: 'Heading 1', + subtitle: 'Large section heading', + icon: Heading1, + keywords: ['h1', 'title'], + run: (editor, range) => + editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(), + }, + { + title: 'Heading 2', + subtitle: 'Medium section heading', + icon: Heading2, + keywords: ['h2', 'subtitle'], + run: (editor, range) => + editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(), + }, + { + title: 'Heading 3', + subtitle: 'Small section heading', + icon: Heading3, + keywords: ['h3'], + run: (editor, range) => + editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run(), + }, + { + title: 'Bulleted list', + subtitle: 'Unordered list', + icon: List, + keywords: ['ul', 'unordered', 'bullet'], + run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBulletList().run(), + }, + { + title: 'Numbered list', + subtitle: 'Ordered list', + icon: ListOrdered, + keywords: ['ol', 'ordered', 'number'], + run: (editor, range) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(), + }, + { + title: 'To-do list', + subtitle: 'Checklist', + icon: ListTodo, + keywords: ['task', 'checkbox', 'todo'], + run: (editor, range) => editor.chain().focus().deleteRange(range).toggleTaskList().run(), + }, + { + title: 'Quote', + subtitle: 'Block quote', + icon: TextQuote, + keywords: ['blockquote', 'citation'], + run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(), + }, + { + title: 'Code block', + subtitle: 'Formatted code', + icon: Code2, + keywords: ['pre', 'snippet'], + run: (editor, range) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: 'Table', + subtitle: '3x3 table', + icon: TableIcon, + keywords: ['grid'], + run: (editor, range) => + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(), + }, + { + title: 'Image', + subtitle: 'Embed by URL', + icon: ImageIcon, + keywords: ['picture', 'photo', 'embed'], + run: (editor, range) => { + const url = window.prompt('Image URL'); + const chain = editor.chain().focus().deleteRange(range); + if (url) chain.setImage({ src: url }).run(); + else chain.run(); + }, + }, + { + title: 'Divider', + subtitle: 'Horizontal rule', + icon: Minus, + keywords: ['hr', 'separator', 'line'], + run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, +]; + +function filterItems(query: string): SlashItem[] { + const q = query.trim().toLowerCase(); + if (!q) return ITEMS; + return ITEMS.filter( + (i) => i.title.toLowerCase().includes(q) || i.keywords.some((k) => k.includes(q)), + ); +} + +function SlashMenu({ items, selectedIndex, onSelect }: SuggestionMenuProps) { + if (items.length === 0) { + return ( +
+ No matching blocks +
+ ); + } + return ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} + +/** + * Slash-command menu: type "/" to insert headings, lists, quotes, code, tables, + * images, and dividers. + */ +export const SlashCommand = Extension.create({ + name: 'slashCommand', + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '/', + allowSpaces: false, + startOfLine: false, + items: ({ query }) => filterItems(query), + command: ({ editor, range, props }) => props.run(editor, range), + render: createSuggestionRenderer(SlashMenu), + }), + ]; + }, +}); diff --git a/apps/web/src/components/page-editor/suggestionPopup.tsx b/apps/web/src/components/page-editor/suggestionPopup.tsx new file mode 100644 index 00000000..bbdf6b56 --- /dev/null +++ b/apps/web/src/components/page-editor/suggestionPopup.tsx @@ -0,0 +1,85 @@ +import { ReactRenderer } from '@tiptap/react'; +import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'; +import type { ComponentType } from 'react'; + +export interface SuggestionMenuProps { + items: T[]; + selectedIndex: number; + onSelect: (index: number) => void; +} + +/** + * Builds a TipTap suggestion `render` that shows `Menu` in a body-level popup + * positioned under the caret, with arrow-key navigation and Enter to pick. All + * mutable state lives in the returned closure so the menu component stays pure. + */ +export function createSuggestionRenderer( + Menu: ComponentType>, +): NonNullable['render']> { + return () => { + let renderer: ReactRenderer | null = null; + let popup: HTMLDivElement | null = null; + let items: T[] = []; + let selectedIndex = 0; + let choose: (item: T) => void = () => {}; + + const paint = () => { + renderer?.updateProps({ items, selectedIndex, onSelect: (i: number) => choose(items[i]) }); + }; + const place = (rect?: DOMRect | null) => { + if (!popup || !rect) return; + popup.style.top = `${rect.bottom + window.scrollY + 4}px`; + popup.style.left = `${rect.left + window.scrollX}px`; + }; + + return { + onStart: (props: SuggestionProps) => { + items = props.items; + selectedIndex = 0; + choose = (item) => props.command(item); + renderer = new ReactRenderer(Menu, { + props: { items, selectedIndex, onSelect: (i: number) => choose(items[i]) }, + editor: props.editor, + }); + popup = document.createElement('div'); + popup.style.position = 'absolute'; + popup.style.zIndex = '10200'; + popup.appendChild(renderer.element); + document.body.appendChild(popup); + place(props.clientRect?.()); + }, + onUpdate: (props: SuggestionProps) => { + items = props.items; + selectedIndex = 0; + choose = (item) => props.command(item); + paint(); + place(props.clientRect?.()); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === 'Escape') return false; + if (items.length === 0) return false; + if (props.event.key === 'ArrowUp') { + selectedIndex = (selectedIndex + items.length - 1) % items.length; + paint(); + return true; + } + if (props.event.key === 'ArrowDown') { + selectedIndex = (selectedIndex + 1) % items.length; + paint(); + return true; + } + if (props.event.key === 'Enter') { + choose(items[selectedIndex]); + return true; + } + return false; + }, + onExit: () => { + popup?.remove(); + renderer?.destroy(); + popup = null; + renderer = null; + }, + }; + }; +} diff --git a/apps/web/src/components/page-editor/usePageEditor.ts b/apps/web/src/components/page-editor/usePageEditor.ts index 908a0ada..b24039c5 100644 --- a/apps/web/src/components/page-editor/usePageEditor.ts +++ b/apps/web/src/components/page-editor/usePageEditor.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useEditor, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; @@ -15,6 +15,9 @@ import { Table } from '@tiptap/extension-table'; import TableRow from '@tiptap/extension-table-row'; import TableHeader from '@tiptap/extension-table-header'; import TableCell from '@tiptap/extension-table-cell'; +import { SlashCommand } from './slashCommands'; +import { createMention } from './mentions'; +import type { MentionItem } from './mentionTypes'; export interface UsePageEditorOptions { /** Initial HTML to seed the editor with on mount. */ @@ -27,6 +30,8 @@ export interface UsePageEditorOptions { onUpdate?: (html: string) => void; /** Optional Ctrl/Cmd+S handler — also prevents the browser save dialog. */ onSaveShortcut?: () => void; + /** Workspace members offered by the @-mention menu. */ + mentionItems?: MentionItem[]; } /** @@ -40,7 +45,13 @@ export interface UsePageEditorOptions { * a sticky toolbar can live above the scrollable page body. */ export function usePageEditor(opts: UsePageEditorOptions): Editor | null { - const { initialHtml, placeholder, readOnly, onUpdate, onSaveShortcut } = opts; + const { initialHtml, placeholder, readOnly, onUpdate, onSaveShortcut, mentionItems } = opts; + + // The mention suggestion reads members through this ref so members that load + // after the editor mounts still appear in the menu. The getter is only invoked + // by the ProseMirror suggestion plugin on user input, never during render. + const mentionItemsRef = useRef([]); + const getMentionItems = () => mentionItemsRef.current; const editor = useEditor({ extensions: [ @@ -66,6 +77,9 @@ export function usePageEditor(opts: UsePageEditorOptions): Editor | null { TableRow, TableHeader, TableCell, + SlashCommand, + // eslint-disable-next-line react-hooks/refs -- getter is invoked by the suggestion plugin on input, not during render + createMention(getMentionItems), Placeholder.configure({ placeholder: placeholder ?? 'Start writing… or press “/” for commands', }), @@ -92,6 +106,11 @@ export function usePageEditor(opts: UsePageEditorOptions): Editor | null { }, }); + // Keep the mention getter's data current without recreating the editor. + useEffect(() => { + mentionItemsRef.current = mentionItems ?? []; + }, [mentionItems]); + // Sync incoming initialHtml when the page changes (e.g. version restore or // route param swap). We avoid forcing a reset on every render to preserve // selection state during autosave round-trips. diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ee75d93c..32b6ddbf 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -240,6 +240,13 @@ border-top: 1px solid var(--border-subtle); margin: 1rem 0; } +.page-editor-prose .page-mention { + border-radius: 4px; + padding: 0 0.25rem; + font-weight: 500; + color: var(--txt-accent-primary); + background: var(--bg-accent-subtle); +} /* Tables — use --tableCellMinWidth for resizable column behaviour. */ .page-editor-prose table.page-table, diff --git a/apps/web/src/pages/PageDetailPage.tsx b/apps/web/src/pages/PageDetailPage.tsx index d1ecc752..acb9a1f4 100644 --- a/apps/web/src/pages/PageDetailPage.tsx +++ b/apps/web/src/pages/PageDetailPage.tsx @@ -27,6 +27,7 @@ import { PageOutline, usePageEditor, type PageLogo, + type MentionItem, } from '../components/page-editor'; import { useAuth } from '../contexts/AuthContext'; import { useSetPageDetailHeader } from '../contexts/PageDetailHeaderContext'; @@ -107,6 +108,7 @@ export function PageDetailPage() { const [moveLoading, setMoveLoading] = useState(false); const [moveSubmitting, setMoveSubmitting] = useState(false); const [moveError, setMoveError] = useState(null); + const [mentionMembers, setMentionMembers] = useState([]); const titleSaveTimer = useRef(null); const bodySaveTimer = useRef(null); @@ -173,6 +175,7 @@ export function PageDetailPage() { readOnly: editorReadOnly, onUpdate: onEditorUpdate, onSaveShortcut, + mentionItems: mentionMembers, }); // Mirror the latest editor instance into the ref so non-render code (key // handlers, save flushes) can reach it without re-deriving callback identity. @@ -180,6 +183,28 @@ export function PageDetailPage() { editorRef.current = editor; }, [editor]); + // Load workspace members for the @-mention menu. + useEffect(() => { + if (!workspaceSlug) return; + let cancelled = false; + void workspaceService + .listMembers(workspaceSlug) + .then((members) => { + if (cancelled) return; + setMentionMembers( + members.map((m) => ({ + id: m.member_id, + label: m.member_display_name || m.member_email || 'Member', + avatarUrl: m.member_avatar ?? null, + })), + ); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); + // ----- Initial load ------------------------------------------------------ useEffect(() => { // No route params? React Router shouldn't allow this for the From 77a81f239876bcade29c499c4dea901919d4f0ae Mon Sep 17 00:00:00 2001 From: martian56 Date: Sun, 5 Jul 2026 11:17:58 +0400 Subject: [PATCH 2/2] fix(pages): address review on slash commands and mentions CodeRabbit + Copilot on PR #251: - validate the slash "Image" URL through safeUrl so an http(s)/relative URL is required, blocking javascript:/data: payloads in the img src. - seed the mention ref from the initial members so an already-cached list works on the very first "@". - allow spaces in mention queries so multi-word names keep filtering. - reposition the suggestion popup on scroll/resize so it stays pinned to the caret while an editor with a scrollable body moves. - scroll the keyboard-highlighted item into view in both menus. - clear cached members when the workspace changes so the menu can't briefly offer, or insert, a member from the previous workspace. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/page-editor/MentionMenu.tsx | 8 +++-- .../src/components/page-editor/mentions.ts | 2 ++ .../components/page-editor/slashCommands.tsx | 20 ++++++++++--- .../page-editor/suggestionPopup.tsx | 30 +++++++++++++++++-- .../components/page-editor/usePageEditor.ts | 7 +++-- apps/web/src/pages/PageDetailPage.tsx | 16 ++++++---- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/page-editor/MentionMenu.tsx b/apps/web/src/components/page-editor/MentionMenu.tsx index bb568fc4..09de6a70 100644 --- a/apps/web/src/components/page-editor/MentionMenu.tsx +++ b/apps/web/src/components/page-editor/MentionMenu.tsx @@ -1,9 +1,10 @@ import { Avatar } from '../ui'; import { getImageUrl } from '../../lib/utils'; -import type { SuggestionMenuProps } from './suggestionPopup'; +import { useActiveItemScroll, type SuggestionMenuProps } from './suggestionPopup'; import type { MentionItem } from './mentionTypes'; export function MentionMenu({ items, selectedIndex, onSelect }: SuggestionMenuProps) { + const listRef = useActiveItemScroll(selectedIndex); if (items.length === 0) { return (
@@ -12,7 +13,10 @@ export function MentionMenu({ items, selectedIndex, onSelect }: SuggestionMenuPr ); } return ( -
+
{items.map((item, i) => (