From f1cd1a27798a855f37120ebc23d2081355423f7e Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Wed, 18 Feb 2026 22:55:24 +0900 Subject: [PATCH 01/23] fix(frontend): keep board add-tab button fixed outside scroll --- .../components/boards/BoardToolbar.tsx | 144 +++++++++--------- 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx index 402518ef..c4f4affb 100644 --- a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx +++ b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx @@ -71,80 +71,84 @@ export function BoardToolbar({
{/* Tab List - aligned with editor content */}
-
- {tabs.map((tab) => ( -
- {editingTabId === tab.id ? ( - setEditingName(e.target.value)} - onBlur={handleFinishRename} - onKeyDown={handleKeyDown} - className="w-20 bg-transparent text-sm outline-none" - /> - ) : ( - <> - - - {/* Tab Actions (visible on hover or when active) */} - - - - - - handleStartRename(tab)}> - - Rename - - {tabs.length > 1 && ( - onDeleteTab(tab.id)} + {editingTabId === tab.id ? ( + setEditingName(e.target.value)} + onBlur={handleFinishRename} + onKeyDown={handleKeyDown} + className="w-20 bg-transparent text-sm outline-none" + /> + ) : ( + <> + + + {/* Tab Actions (visible on hover or when active) */} + + + + + + handleStartRename(tab)}> + + Rename + + {tabs.length > 1 && ( + onDeleteTab(tab.id)} + > + + Delete + + )} + + + + )} +
+ ))} +
- ))} - {/* Add Tab Button */} - + {/* Add Tab Button */} +
From 8a91015fa87ccde198db228b0693dbb5e235f3d0 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Wed, 18 Feb 2026 23:00:28 +0900 Subject: [PATCH 02/23] fix(frontend): keep add-tab button adjacent until tab overflow --- frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx index c4f4affb..cc6cea23 100644 --- a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx +++ b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx @@ -72,7 +72,7 @@ export function BoardToolbar({ {/* Tab List - aligned with editor content */}
-
+
{tabs.map((tab) => (
Date: Wed, 18 Feb 2026 23:05:55 +0900 Subject: [PATCH 03/23] fix(frontend): adjust board toolbar and editor horizontal padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce toolbar padding (pl-6 → pl-4 pr-2) and editor content padding (px-8 → px-6) for tighter board layout alignment. Co-Authored-By: Claude Opus 4.6 --- .../pluto_duck_frontend/components/boards/BoardToolbar.tsx | 2 +- .../pluto_duck_frontend/components/editor/BoardEditor.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx index cc6cea23..23a4ab81 100644 --- a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx +++ b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx @@ -70,7 +70,7 @@ export function BoardToolbar({ return (
{/* Tab List - aligned with editor content */} -
+
diff --git a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx index 39e5ecfc..e8bea660 100644 --- a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx +++ b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx @@ -241,10 +241,10 @@ export const BoardEditor = forwardRef(
+ } placeholder={ -
+
Type '/' to insert blocks...
} From afc6156b39966221b18e19e76ed85e6059b2647e Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Wed, 18 Feb 2026 23:09:22 +0900 Subject: [PATCH 04/23] fix(frontend): darken board editor body text to match theme foreground Override --tw-prose-body with the project's --foreground color so body text appears darker without affecting headings, links, or code styles. Co-Authored-By: Claude Opus 4.6 --- frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx index e8bea660..36191865 100644 --- a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx +++ b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx @@ -241,7 +241,7 @@ export const BoardEditor = forwardRef(
+ } placeholder={
From 9bade650c14fdaa788b5bf0a486e0fb521096e79 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Thu, 19 Feb 2026 13:42:51 +0900 Subject: [PATCH 05/23] fix(frontend): add bottom padding to board toolbar container Co-Authored-By: Claude Opus 4.6 --- frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx index 23a4ab81..45ea4a3d 100644 --- a/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx +++ b/frontend/pluto_duck_frontend/components/boards/BoardToolbar.tsx @@ -68,7 +68,7 @@ export function BoardToolbar({ if (!board) return null; return ( -
+
{/* Tab List - aligned with editor content */}
From bf30d4bfe4091242d7d55865938156b45064a1f8 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Thu, 19 Feb 2026 14:32:20 +0900 Subject: [PATCH 06/23] feat(frontend): add board editor divider support --- frontend/pluto_duck_frontend/app/globals.css | 6 ++++++ .../components/editor/BoardEditor.tsx | 10 +++++++--- .../editor/plugins/InsertMarkdownPlugin.tsx | 5 +++-- .../editor/plugins/SlashCommandPlugin.tsx | 6 +++++- .../components/editor/transformers.ts | 18 ++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 frontend/pluto_duck_frontend/components/editor/transformers.ts diff --git a/frontend/pluto_duck_frontend/app/globals.css b/frontend/pluto_duck_frontend/app/globals.css index 5e228c18..bb6b23e4 100644 --- a/frontend/pluto_duck_frontend/app/globals.css +++ b/frontend/pluto_duck_frontend/app/globals.css @@ -217,6 +217,12 @@ margin-bottom: 0; } +.board-editor-prose hr { + border: 0; + border-top: 1px solid hsl(var(--border)); + margin: 1.25rem 0; +} + /* Chat prose typography: table overrides */ [role="log"] .prose :where([data-streamdown="table-wrapper"]) { margin: 8px 0; diff --git a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx index 36191865..194fb4d3 100644 --- a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx +++ b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx @@ -7,7 +7,8 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; -import { TRANSFORMERS } from '@lexical/markdown'; +import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; +import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; import { ListItemNode, ListNode } from '@lexical/list'; import { CodeNode, CodeHighlightNode } from '@lexical/code'; @@ -28,6 +29,7 @@ import { InsertAssetEmbedPlugin, type InsertAssetEmbedHandle } from './plugins/I import { AssetPicker } from './components/AssetPicker'; import { DisplayConfigModal } from './components/DisplayConfigModal'; import { ConfigModalContext } from './components/AssetEmbedComponent'; +import { BOARD_TRANSFORMERS } from './transformers'; interface BoardEditorProps { board: Board; @@ -92,6 +94,7 @@ export const BoardEditor = forwardRef( CodeHighlightNode, LinkNode, AutoLinkNode, + HorizontalRuleNode, ImageNode, AssetEmbedNode, ], @@ -241,7 +244,7 @@ export const BoardEditor = forwardRef(
+ } placeholder={
@@ -256,7 +259,8 @@ export const BoardEditor = forwardRef( - + + diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/InsertMarkdownPlugin.tsx b/frontend/pluto_duck_frontend/components/editor/plugins/InsertMarkdownPlugin.tsx index c6c5e105..1edc0a83 100644 --- a/frontend/pluto_duck_frontend/components/editor/plugins/InsertMarkdownPlugin.tsx +++ b/frontend/pluto_duck_frontend/components/editor/plugins/InsertMarkdownPlugin.tsx @@ -2,8 +2,9 @@ import { useImperativeHandle, forwardRef } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $convertFromMarkdownString, TRANSFORMERS } from '@lexical/markdown'; +import { $convertFromMarkdownString } from '@lexical/markdown'; import { $getRoot, $createParagraphNode } from 'lexical'; +import { BOARD_TRANSFORMERS } from '../transformers'; export interface InsertMarkdownHandle { insertMarkdown: (content: string) => void; @@ -25,7 +26,7 @@ export const InsertMarkdownPlugin = forwardRef( } // 2. Convert markdown (root is empty, so clear() has no effect) - $convertFromMarkdownString(content, TRANSFORMERS); + $convertFromMarkdownString(content, BOARD_TRANSFORMERS); // 3. Save new children and detach from root const newChildren = root.getChildren(); diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx b/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx index dff42158..62899319 100644 --- a/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx +++ b/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx @@ -8,6 +8,7 @@ import { TextNode, $createParagraphNode, $getSelection, $isRangeSelection, $inse import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text'; import { $createCodeNode } from '@lexical/code'; import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'; +import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; import { useCallback, useMemo, useRef, useState, useContext, createContext } from 'react'; import * as React from 'react'; import { createPortal } from 'react-dom'; @@ -19,6 +20,7 @@ import { ListOrdered, Quote, Code, + Minus, Image as ImageIcon, Database, } from 'lucide-react'; @@ -136,6 +138,9 @@ export default function SlashCommandPlugin({ projectId }: { projectId: string }) } }); }), + new SlashMenuOption('Divider', , ['divider', 'hr', 'horizontal', 'line', '---'], (editor) => { + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); + }), new SlashMenuOption('Image', , ['image', 'photo', 'picture'], (editor) => { editor.update(() => { // Mock Image Insertion @@ -289,4 +294,3 @@ function SlashMenuItem({ ); } - diff --git a/frontend/pluto_duck_frontend/components/editor/transformers.ts b/frontend/pluto_duck_frontend/components/editor/transformers.ts new file mode 100644 index 00000000..784be5e1 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/editor/transformers.ts @@ -0,0 +1,18 @@ +import { TRANSFORMERS, type ElementTransformer } from '@lexical/markdown'; +import { + $createHorizontalRuleNode, + $isHorizontalRuleNode, + HorizontalRuleNode, +} from '@lexical/react/LexicalHorizontalRuleNode'; + +const HORIZONTAL_RULE: ElementTransformer = { + dependencies: [HorizontalRuleNode], + export: (node) => ($isHorizontalRuleNode(node) ? '---' : null), + regExp: /^(---|\*\*\*|___)\s?$/, + replace: (parentNode) => { + parentNode.replace($createHorizontalRuleNode()); + }, + type: 'element', +}; + +export const BOARD_TRANSFORMERS = [HORIZONTAL_RULE, ...TRANSFORMERS]; From d4ec9478e4a1c71b6b50f5fdeffec9595faf38e9 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Thu, 19 Feb 2026 15:06:43 +0900 Subject: [PATCH 07/23] feat(frontend): add board editor callout block support --- frontend/pluto_duck_frontend/app/globals.css | 41 +++++ .../components/editor/BoardEditor.tsx | 3 +- .../editor/components/CalloutComponent.tsx | 152 ++++++++++++++++++ .../components/editor/nodes/CalloutNode.tsx | 107 ++++++++++++ .../components/editor/nodes/index.ts | 1 + .../editor/plugins/SlashCommandPlugin.tsx | 10 ++ .../components/editor/transformers.ts | 87 +++++++++- 7 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 frontend/pluto_duck_frontend/components/editor/components/CalloutComponent.tsx create mode 100644 frontend/pluto_duck_frontend/components/editor/nodes/CalloutNode.tsx diff --git a/frontend/pluto_duck_frontend/app/globals.css b/frontend/pluto_duck_frontend/app/globals.css index bb6b23e4..27d39ebd 100644 --- a/frontend/pluto_duck_frontend/app/globals.css +++ b/frontend/pluto_duck_frontend/app/globals.css @@ -223,6 +223,47 @@ margin: 1.25rem 0; } +.board-editor-prose .callout-node { + margin: 1rem 0; +} + +.board-editor-prose .callout-node-shell { + border-radius: 0.5rem; + padding: 0.625rem 0.75rem; +} + +.board-editor-prose .callout-node[data-callout-type="info"] .callout-node-shell { + background: hsl(210 100% 96%); +} + +.board-editor-prose .callout-node[data-callout-type="warning"] .callout-node-shell { + background: hsl(42 100% 94%); +} + +.board-editor-prose .callout-node[data-callout-type="success"] .callout-node-shell { + background: hsl(141 58% 94%); +} + +.board-editor-prose .callout-node[data-callout-type="error"] .callout-node-shell { + background: hsl(0 87% 95%); +} + +.dark .board-editor-prose .callout-node[data-callout-type="info"] .callout-node-shell { + background: hsl(210 35% 22%); +} + +.dark .board-editor-prose .callout-node[data-callout-type="warning"] .callout-node-shell { + background: hsl(40 30% 24%); +} + +.dark .board-editor-prose .callout-node[data-callout-type="success"] .callout-node-shell { + background: hsl(140 28% 24%); +} + +.dark .board-editor-prose .callout-node[data-callout-type="error"] .callout-node-shell { + background: hsl(0 28% 24%); +} + /* Chat prose typography: table overrides */ [role="log"] .prose :where([data-streamdown="table-wrapper"]) { margin: 8px 0; diff --git a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx index 194fb4d3..2ef43cf3 100644 --- a/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx +++ b/frontend/pluto_duck_frontend/components/editor/BoardEditor.tsx @@ -20,7 +20,7 @@ import { useRef, useState, useCallback, forwardRef, useImperativeHandle } from ' import { editorTheme } from './theme'; import type { Board } from '../../lib/boardsApi'; -import { ImageNode, AssetEmbedNode, type AssetEmbedConfig } from './nodes'; +import { ImageNode, AssetEmbedNode, CalloutNode, type AssetEmbedConfig } from './nodes'; import SlashCommandPlugin, { AssetEmbedContext } from './plugins/SlashCommandPlugin'; import DraggableBlockPlugin from './plugins/DraggableBlockPlugin'; import { InitialContentPlugin } from './plugins/InitialContentPlugin'; @@ -95,6 +95,7 @@ export const BoardEditor = forwardRef( LinkNode, AutoLinkNode, HorizontalRuleNode, + CalloutNode, ImageNode, AssetEmbedNode, ], diff --git a/frontend/pluto_duck_frontend/components/editor/components/CalloutComponent.tsx b/frontend/pluto_duck_frontend/components/editor/components/CalloutComponent.tsx new file mode 100644 index 00000000..4677889d --- /dev/null +++ b/frontend/pluto_duck_frontend/components/editor/components/CalloutComponent.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNodeByKey, NodeKey } from 'lexical'; +import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react'; + +import { $isCalloutNode, type CalloutType } from '../nodes/CalloutNode'; + +interface CalloutComponentProps { + calloutType: CalloutType; + text: string; + nodeKey: NodeKey; +} + +const CALLOUT_OPTIONS: Array<{ + label: string; + type: CalloutType; + icon: typeof Info; +}> = [ + { label: 'Info', type: 'info', icon: Info }, + { label: 'Warning', type: 'warning', icon: AlertTriangle }, + { label: 'Success', type: 'success', icon: CheckCircle2 }, + { label: 'Error', type: 'error', icon: AlertCircle }, +]; + +export function CalloutComponent({ calloutType, text, nodeKey }: CalloutComponentProps) { + const [editor] = useLexicalComposerContext(); + const [menuOpen, setMenuOpen] = useState(false); + const containerRef = useRef(null); + const textRef = useRef(null); + + useEffect(() => { + const element = textRef.current; + if (!element) { + return; + } + + // Keep contentEditable uncontrolled while syncing external updates + // to avoid caret reset on each keystroke. + if (document.activeElement !== element && element.innerText !== text) { + element.innerText = text; + } + }, [text]); + + useEffect(() => { + if (!menuOpen) { + return; + } + + const onPointerDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (target && containerRef.current && !containerRef.current.contains(target)) { + setMenuOpen(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMenuOpen(false); + } + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [menuOpen]); + + const updateNodeText = useCallback( + (nextText: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isCalloutNode(node)) { + node.setText(nextText); + } + }); + }, + [editor, nodeKey], + ); + + const updateNodeType = useCallback( + (nextType: CalloutType) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isCalloutNode(node)) { + node.setCalloutType(nextType); + } + }); + }, + [editor, nodeKey], + ); + + const activeOption = useMemo( + () => CALLOUT_OPTIONS.find((option) => option.type === calloutType) ?? CALLOUT_OPTIONS[0], + [calloutType], + ); + + return ( +
+
+ + {menuOpen && ( +
+ {CALLOUT_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+ )} +
{ + const nextText = event.currentTarget.innerText; + updateNodeText(nextText); + }} + onPaste={(event) => { + event.preventDefault(); + const pastedText = event.clipboardData.getData('text/plain'); + document.execCommand('insertText', false, pastedText); + }} + /> +
+
+ ); +} diff --git a/frontend/pluto_duck_frontend/components/editor/nodes/CalloutNode.tsx b/frontend/pluto_duck_frontend/components/editor/nodes/CalloutNode.tsx new file mode 100644 index 00000000..2c028919 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/editor/nodes/CalloutNode.tsx @@ -0,0 +1,107 @@ +import { DecoratorNode, LexicalNode, NodeKey, SerializedLexicalNode, Spread } from 'lexical'; +import { ReactNode } from 'react'; +import { CalloutComponent } from '../components/CalloutComponent'; + +export type CalloutType = 'info' | 'warning' | 'success' | 'error'; + +export type SerializedCalloutNode = Spread< + { + calloutType: CalloutType; + text: string; + }, + SerializedLexicalNode +>; + +export class CalloutNode extends DecoratorNode { + __calloutType: CalloutType; + __text: string; + + static getType(): string { + return 'callout'; + } + + static clone(node: CalloutNode): CalloutNode { + return new CalloutNode(node.__calloutType, node.__text, node.__key); + } + + static importJSON(serializedNode: SerializedCalloutNode): CalloutNode { + return $createCalloutNode(serializedNode.calloutType, serializedNode.text); + } + + constructor(calloutType: CalloutType, text: string, key?: NodeKey) { + super(key); + this.__calloutType = calloutType; + this.__text = text; + } + + exportJSON(): SerializedCalloutNode { + return { + calloutType: this.__calloutType, + text: this.__text, + type: 'callout', + version: 1, + }; + } + + createDOM(): HTMLElement { + const div = document.createElement('div'); + div.className = 'callout-node'; + return div; + } + + updateDOM(): false { + return false; + } + + isInline(): boolean { + return false; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + getCalloutType(): CalloutType { + return this.getLatest().__calloutType; + } + + setCalloutType(calloutType: CalloutType): void { + const writable = this.getWritable(); + writable.__calloutType = calloutType; + } + + getText(): string { + return this.getLatest().__text; + } + + setText(text: string): void { + const writable = this.getWritable(); + writable.__text = text; + } + + getTextContent(): string { + return this.__text; + } + + decorate(): ReactNode { + return ( + + ); + } +} + +export function $createCalloutNode(calloutType: CalloutType = 'info', text = ''): CalloutNode { + return new CalloutNode(calloutType, text); +} + +export function $isCalloutNode(node: LexicalNode | null | undefined): node is CalloutNode { + return node instanceof CalloutNode; +} diff --git a/frontend/pluto_duck_frontend/components/editor/nodes/index.ts b/frontend/pluto_duck_frontend/components/editor/nodes/index.ts index 7da7cb25..2eb28e39 100644 --- a/frontend/pluto_duck_frontend/components/editor/nodes/index.ts +++ b/frontend/pluto_duck_frontend/components/editor/nodes/index.ts @@ -1,2 +1,3 @@ export * from './ImageNode'; export * from './AssetEmbedNode'; +export * from './CalloutNode'; diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx b/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx index 62899319..8a7e2efe 100644 --- a/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx +++ b/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx @@ -16,6 +16,7 @@ import { Heading1, Heading2, Heading3, + MessageSquare, List, ListOrdered, Quote, @@ -26,6 +27,7 @@ import { } from 'lucide-react'; import { $createImageNode } from '../nodes/ImageNode'; import { $createAssetEmbedNode, type AssetEmbedConfig } from '../nodes/AssetEmbedNode'; +import { $createCalloutNode } from '../nodes/CalloutNode'; import { $setBlocksType } from '@lexical/selection'; // Context for Asset Embed integration @@ -141,6 +143,14 @@ export default function SlashCommandPlugin({ projectId }: { projectId: string }) new SlashMenuOption('Divider', , ['divider', 'hr', 'horizontal', 'line', '---'], (editor) => { editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); }), + new SlashMenuOption('Callout', , ['callout', 'note', 'warning', 'tip', 'important', 'caution'], (editor) => { + editor.update(() => { + const calloutNode = $createCalloutNode('info', ''); + const paragraphNode = $createParagraphNode(); + $insertNodes([calloutNode, paragraphNode]); + paragraphNode.select(); + }); + }), new SlashMenuOption('Image', , ['image', 'photo', 'picture'], (editor) => { editor.update(() => { // Mock Image Insertion diff --git a/frontend/pluto_duck_frontend/components/editor/transformers.ts b/frontend/pluto_duck_frontend/components/editor/transformers.ts index 784be5e1..84c3544b 100644 --- a/frontend/pluto_duck_frontend/components/editor/transformers.ts +++ b/frontend/pluto_duck_frontend/components/editor/transformers.ts @@ -1,9 +1,15 @@ -import { TRANSFORMERS, type ElementTransformer } from '@lexical/markdown'; +import { TRANSFORMERS, type ElementTransformer, type MultilineElementTransformer } from '@lexical/markdown'; import { $createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode, } from '@lexical/react/LexicalHorizontalRuleNode'; +import { + $createCalloutNode, + $isCalloutNode, + CalloutNode, + type CalloutType, +} from './nodes/CalloutNode'; const HORIZONTAL_RULE: ElementTransformer = { dependencies: [HorizontalRuleNode], @@ -15,4 +21,81 @@ const HORIZONTAL_RULE: ElementTransformer = { type: 'element', }; -export const BOARD_TRANSFORMERS = [HORIZONTAL_RULE, ...TRANSFORMERS]; +const CALLOUT_TYPE_BY_MARKER: Record = { + NOTE: 'info', + IMPORTANT: 'info', + WARNING: 'warning', + TIP: 'success', + CAUTION: 'error', +}; + +const CALLOUT_MARKER_BY_TYPE: Record = { + info: 'NOTE', + warning: 'WARNING', + success: 'TIP', + error: 'CAUTION', +}; + +const CALLOUT_INLINE: ElementTransformer = { + dependencies: [CalloutNode], + export: () => null, + regExp: /^>\s*\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]\s*(.*)$/i, + replace: (parentNode, _children, match) => { + const marker = match[1]?.toUpperCase(); + const type = CALLOUT_TYPE_BY_MARKER[marker]; + if (!type) { + return false; + } + const text = (match[2] || '').trim(); + parentNode.replace($createCalloutNode(type, text)); + }, + type: 'element', +}; + +const CALLOUT_BLOCK: MultilineElementTransformer = { + dependencies: [CalloutNode], + export: (node) => { + if (!$isCalloutNode(node)) { + return null; + } + + const marker = CALLOUT_MARKER_BY_TYPE[node.getCalloutType()]; + const text = node.getText().trim(); + if (!text) { + return `> [!${marker}]`; + } + + const body = text + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + return `> [!${marker}]\n${body}`; + }, + regExpStart: /^>\s*\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]\s*$/i, + regExpEnd: { + optional: true, + regExp: /^$/, + }, + replace: (rootNode, children, startMatch, _endMatch, linesInBetween) => { + const marker = startMatch[1]?.toUpperCase(); + const type = CALLOUT_TYPE_BY_MARKER[marker]; + if (!type) { + return false; + } + + if (children) { + const childText = children.map((node) => node.getTextContent()).join('\n').trim(); + rootNode.append($createCalloutNode(type, childText)); + return; + } + + const lines = (linesInBetween || []) + .map((line) => line.replace(/^>\s?/, '')) + .map((line) => line.trimEnd()); + const text = lines.join('\n').trim(); + rootNode.append($createCalloutNode(type, text)); + }, + type: 'multiline-element', +}; + +export const BOARD_TRANSFORMERS = [CALLOUT_BLOCK, CALLOUT_INLINE, HORIZONTAL_RULE, ...TRANSFORMERS]; From d866edadf35c79e22bc7011fe9c6ded72af16be7 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Thu, 19 Feb 2026 15:23:34 +0900 Subject: [PATCH 08/23] test(frontend): add board editor callout/slash regressions --- .../editor/plugins/SlashCommandPlugin.tsx | 29 +----------- .../editor/plugins/slashMenuFilter.ts | 33 +++++++++++++ .../components/editor/transformerUtils.ts | 27 +++++++++++ .../components/editor/transformers.ts | 31 ++++++------- .../lib/__tests__/editorTransformers.test.ts | 46 +++++++++++++++++++ .../slashMenuKeywordCollision.test.ts | 33 +++++++++++++ 6 files changed, 154 insertions(+), 45 deletions(-) create mode 100644 frontend/pluto_duck_frontend/components/editor/plugins/slashMenuFilter.ts create mode 100644 frontend/pluto_duck_frontend/components/editor/transformerUtils.ts create mode 100644 frontend/pluto_duck_frontend/lib/__tests__/editorTransformers.test.ts create mode 100644 frontend/pluto_duck_frontend/lib/__tests__/slashMenuKeywordCollision.test.ts diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx b/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx index 8a7e2efe..4756dab0 100644 --- a/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx +++ b/frontend/pluto_duck_frontend/components/editor/plugins/SlashCommandPlugin.tsx @@ -29,6 +29,7 @@ import { $createImageNode } from '../nodes/ImageNode'; import { $createAssetEmbedNode, type AssetEmbedConfig } from '../nodes/AssetEmbedNode'; import { $createCalloutNode } from '../nodes/CalloutNode'; import { $setBlocksType } from '@lexical/selection'; +import { filterSlashOptionsByQuery } from './slashMenuFilter'; // Context for Asset Embed integration export interface AssetEmbedContextType { @@ -57,32 +58,6 @@ class SlashMenuOption extends MenuOption { } } -function filterSlashOptions(options: SlashMenuOption[], queryString: string | null): SlashMenuOption[] { - const raw = (queryString || '').trim().toLowerCase(); - if (!raw) return options; - - const scored: Array<{ option: SlashMenuOption; score: number }> = []; - - for (const option of options) { - const title = option.title.toLowerCase(); - const keywords = option.keywords.map((k) => k.toLowerCase()); - - // Prefer prefix matches (Notion-like feel), then fallback to contains. - let score = -1; - if (title.startsWith(raw)) score = 300; - else if (keywords.some((k) => k.startsWith(raw))) score = 200; - else if (title.includes(raw)) score = 100; - else if (keywords.some((k) => k.includes(raw))) score = 50; - - if (score >= 0) { - scored.push({ option, score }); - } - } - - scored.sort((a, b) => b.score - a.score); - return scored.map((s) => s.option); -} - export default function SlashCommandPlugin({ projectId }: { projectId: string }): JSX.Element | null { const [editor] = useLexicalComposerContext(); const [queryString, setQueryString] = useState(null); @@ -195,7 +170,7 @@ export default function SlashCommandPlugin({ projectId }: { projectId: string }) ); const filteredOptions = useMemo( - () => filterSlashOptions(options, queryString), + () => filterSlashOptionsByQuery(options, queryString), [options, queryString], ); diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/slashMenuFilter.ts b/frontend/pluto_duck_frontend/components/editor/plugins/slashMenuFilter.ts new file mode 100644 index 00000000..abf95b92 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/editor/plugins/slashMenuFilter.ts @@ -0,0 +1,33 @@ +export interface SlashFilterOption { + title: string; + keywords: ReadonlyArray; +} + +export function filterSlashOptionsByQuery( + options: T[], + queryString: string | null +): T[] { + const raw = (queryString || '').trim().toLowerCase(); + if (!raw) return options; + + const scored: Array<{ option: T; score: number }> = []; + + for (const option of options) { + const title = option.title.toLowerCase(); + const keywords = option.keywords.map((keyword) => keyword.toLowerCase()); + + // Prefer prefix matches, then fallback to contains. + let score = -1; + if (title.startsWith(raw)) score = 300; + else if (keywords.some((keyword) => keyword.startsWith(raw))) score = 200; + else if (title.includes(raw)) score = 100; + else if (keywords.some((keyword) => keyword.includes(raw))) score = 50; + + if (score >= 0) { + scored.push({ option, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.map((item) => item.option); +} diff --git a/frontend/pluto_duck_frontend/components/editor/transformerUtils.ts b/frontend/pluto_duck_frontend/components/editor/transformerUtils.ts new file mode 100644 index 00000000..96dead35 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/editor/transformerUtils.ts @@ -0,0 +1,27 @@ +export type ResolvedCalloutType = 'info' | 'warning' | 'success' | 'error'; + +export const HORIZONTAL_RULE_REGEXP = /^(---|\*\*\*|___)\s?$/; + +const CALLOUT_TYPE_BY_MARKER: Record = { + NOTE: 'info', + IMPORTANT: 'info', + WARNING: 'warning', + TIP: 'success', + CAUTION: 'error', +}; + +export const CALLOUT_INLINE_REGEXP = /^>\s*\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]\s*(.*)$/i; + +export const CALLOUT_BLOCK_START_REGEXP = /^>\s*\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]\s*$/i; + +export function resolveCalloutType(marker: string | undefined): ResolvedCalloutType | undefined { + const normalized = marker?.toUpperCase(); + if (!normalized) { + return undefined; + } + return CALLOUT_TYPE_BY_MARKER[normalized]; +} + +export function stripCalloutQuotePrefix(line: string): string { + return line.replace(/^>\s?/, '').trimEnd(); +} diff --git a/frontend/pluto_duck_frontend/components/editor/transformers.ts b/frontend/pluto_duck_frontend/components/editor/transformers.ts index 84c3544b..f4dfaee9 100644 --- a/frontend/pluto_duck_frontend/components/editor/transformers.ts +++ b/frontend/pluto_duck_frontend/components/editor/transformers.ts @@ -10,25 +10,24 @@ import { CalloutNode, type CalloutType, } from './nodes/CalloutNode'; +import { + CALLOUT_BLOCK_START_REGEXP, + CALLOUT_INLINE_REGEXP, + HORIZONTAL_RULE_REGEXP, + resolveCalloutType, + stripCalloutQuotePrefix, +} from './transformerUtils'; const HORIZONTAL_RULE: ElementTransformer = { dependencies: [HorizontalRuleNode], export: (node) => ($isHorizontalRuleNode(node) ? '---' : null), - regExp: /^(---|\*\*\*|___)\s?$/, + regExp: HORIZONTAL_RULE_REGEXP, replace: (parentNode) => { parentNode.replace($createHorizontalRuleNode()); }, type: 'element', }; -const CALLOUT_TYPE_BY_MARKER: Record = { - NOTE: 'info', - IMPORTANT: 'info', - WARNING: 'warning', - TIP: 'success', - CAUTION: 'error', -}; - const CALLOUT_MARKER_BY_TYPE: Record = { info: 'NOTE', warning: 'WARNING', @@ -39,10 +38,9 @@ const CALLOUT_MARKER_BY_TYPE: Record = { const CALLOUT_INLINE: ElementTransformer = { dependencies: [CalloutNode], export: () => null, - regExp: /^>\s*\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]\s*(.*)$/i, + regExp: CALLOUT_INLINE_REGEXP, replace: (parentNode, _children, match) => { - const marker = match[1]?.toUpperCase(); - const type = CALLOUT_TYPE_BY_MARKER[marker]; + const type = resolveCalloutType(match[1]); if (!type) { return false; } @@ -71,14 +69,13 @@ const CALLOUT_BLOCK: MultilineElementTransformer = { .join('\n'); return `> [!${marker}]\n${body}`; }, - regExpStart: /^>\s*\[!(NOTE|WARNING|TIP|IMPORTANT|CAUTION)\]\s*$/i, + regExpStart: CALLOUT_BLOCK_START_REGEXP, regExpEnd: { optional: true, regExp: /^$/, }, replace: (rootNode, children, startMatch, _endMatch, linesInBetween) => { - const marker = startMatch[1]?.toUpperCase(); - const type = CALLOUT_TYPE_BY_MARKER[marker]; + const type = resolveCalloutType(startMatch[1]); if (!type) { return false; } @@ -89,9 +86,7 @@ const CALLOUT_BLOCK: MultilineElementTransformer = { return; } - const lines = (linesInBetween || []) - .map((line) => line.replace(/^>\s?/, '')) - .map((line) => line.trimEnd()); + const lines = (linesInBetween || []).map((line) => stripCalloutQuotePrefix(line)); const text = lines.join('\n').trim(); rootNode.append($createCalloutNode(type, text)); }, diff --git a/frontend/pluto_duck_frontend/lib/__tests__/editorTransformers.test.ts b/frontend/pluto_duck_frontend/lib/__tests__/editorTransformers.test.ts new file mode 100644 index 00000000..fefd6e68 --- /dev/null +++ b/frontend/pluto_duck_frontend/lib/__tests__/editorTransformers.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CALLOUT_BLOCK_START_REGEXP, + CALLOUT_INLINE_REGEXP, + HORIZONTAL_RULE_REGEXP, + resolveCalloutType, + stripCalloutQuotePrefix, +} from '../../components/editor/transformerUtils.ts'; + +test('callout marker is mapped to lexical callout types', () => { + assert.equal(resolveCalloutType('NOTE'), 'info'); + assert.equal(resolveCalloutType('IMPORTANT'), 'info'); + assert.equal(resolveCalloutType('warning'), 'warning'); + assert.equal(resolveCalloutType('Tip'), 'success'); + assert.equal(resolveCalloutType('CAUTION'), 'error'); + assert.equal(resolveCalloutType('UNKNOWN'), undefined); +}); + +test('inline callout regexp captures marker and trailing text', () => { + const match = '> [!WARNING] keep this'.match(CALLOUT_INLINE_REGEXP); + + assert.ok(match); + assert.equal(match?.[1], 'WARNING'); + assert.equal(match?.[2], 'keep this'); +}); + +test('block callout start regexp accepts marker-only line', () => { + assert.ok(CALLOUT_BLOCK_START_REGEXP.test('> [!NOTE]')); + assert.equal(CALLOUT_BLOCK_START_REGEXP.test('> [!NOTE] title'), false); +}); + +test('callout quote prefix stripper keeps inner text as plain text', () => { + assert.equal(stripCalloutQuotePrefix('> hello world'), 'hello world'); + assert.equal(stripCalloutQuotePrefix('> trailing '), ' trailing'); + assert.equal(stripCalloutQuotePrefix('plain line'), 'plain line'); +}); + +test('horizontal rule regexp keeps markdown hr variants only', () => { + assert.ok(HORIZONTAL_RULE_REGEXP.test('---')); + assert.ok(HORIZONTAL_RULE_REGEXP.test('***')); + assert.ok(HORIZONTAL_RULE_REGEXP.test('___')); + assert.equal(HORIZONTAL_RULE_REGEXP.test('--'), false); + assert.equal(HORIZONTAL_RULE_REGEXP.test('----'), false); +}); diff --git a/frontend/pluto_duck_frontend/lib/__tests__/slashMenuKeywordCollision.test.ts b/frontend/pluto_duck_frontend/lib/__tests__/slashMenuKeywordCollision.test.ts new file mode 100644 index 00000000..4c1a8fcf --- /dev/null +++ b/frontend/pluto_duck_frontend/lib/__tests__/slashMenuKeywordCollision.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { filterSlashOptionsByQuery } from '../../components/editor/plugins/slashMenuFilter.ts'; + +const OPTIONS = [ + { title: 'Heading 1', keywords: ['h1', 'heading', 'large'] }, + { title: 'Divider', keywords: ['divider', 'hr', 'horizontal', 'line', '---'] }, + { title: 'Callout', keywords: ['callout', 'note', 'warning', 'tip', 'important', 'caution'] }, + // Phase 2(table) is intentionally skipped, so `table` stays as Asset keyword. + { title: 'Asset', keywords: ['asset', 'analysis', 'data', 'query', 'table', 'chart'] }, +] as const; + +test('empty query keeps original slash option order', () => { + const filtered = filterSlashOptionsByQuery([...OPTIONS], ''); + + assert.deepEqual( + filtered.map((item) => item.title), + ['Heading 1', 'Divider', 'Callout', 'Asset'] + ); +}); + +test('table query resolves to asset while table block is not enabled', () => { + const filtered = filterSlashOptionsByQuery([...OPTIONS], 'table'); + + assert.deepEqual(filtered.map((item) => item.title), ['Asset']); +}); + +test('prefix search wins over contains search', () => { + const filtered = filterSlashOptionsByQuery([...OPTIONS], 'ca'); + + assert.deepEqual(filtered.map((item) => item.title), ['Callout']); +}); From 15e42c6058a80796601a9019d794954767daa16e Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Thu, 19 Feb 2026 15:33:37 +0900 Subject: [PATCH 09/23] chore(frontend): add temporary Figma HTML-to-Design capture script Co-Authored-By: Claude Opus 4.6 --- frontend/pluto_duck_frontend/app/layout.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/pluto_duck_frontend/app/layout.tsx b/frontend/pluto_duck_frontend/app/layout.tsx index 01913511..0cc6b6e1 100644 --- a/frontend/pluto_duck_frontend/app/layout.tsx +++ b/frontend/pluto_duck_frontend/app/layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import Script from 'next/script'; import './globals.css'; import { Providers } from './providers'; @@ -13,6 +14,11 @@ export default function RootLayout({ children }: { children: ReactNode }) { {children} + {/* Temporary: Figma capture script - REMOVE after capture */} +