+
+
+ {formattedBoardUpdatedAt && (
+
+ {formattedBoardUpdatedAt}
- }
- ErrorBoundary={LexicalErrorBoundary}
- />
- {anchorElem &&
}
+ )}
+
handleTitleChange(event.target.value)}
+ onKeyDown={handleTitleKeyDown}
+ placeholder="Untitled"
+ title={displayTitle}
+ aria-label="Board title"
+ className="w-full text-2xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground"
+ />
+
+
+
+ }
+ placeholder={
+
+ Type '/' to insert blocks...
+
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ {anchorElem &&
}
+
-
+
+
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/components/MarkdownTableComponent.tsx b/frontend/pluto_duck_frontend/components/editor/components/MarkdownTableComponent.tsx
new file mode 100644
index 00000000..b82c3fc8
--- /dev/null
+++ b/frontend/pluto_duck_frontend/components/editor/components/MarkdownTableComponent.tsx
@@ -0,0 +1,285 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
+import { $getNodeByKey, NodeKey } from 'lexical';
+
+import { buildMarkdownTable, parseMarkdownTable, type ParsedMarkdownTable } from '../markdownTableUtils';
+import { $isMarkdownTableNode } from '../nodes/MarkdownTableNode';
+
+interface MarkdownTableComponentProps {
+ markdown: string;
+ nodeKey: NodeKey;
+}
+
+type TableState = ParsedMarkdownTable;
+
+function cloneTableState(state: TableState): TableState {
+ return {
+ hasHeader: state.hasHeader,
+ columns: [...state.columns],
+ rows: state.rows.map((row) => [...row]),
+ };
+}
+
+function buildCellKey(rowIndex: number, columnIndex: number): string {
+ return `r:${rowIndex}:c:${columnIndex}`;
+}
+
+function buildHeaderKey(columnIndex: number): string {
+ return `h:${columnIndex}`;
+}
+
+function getCellValue(state: TableState, key: string): string | null {
+ if (key.startsWith('h:')) {
+ const columnIndex = Number(key.slice(2));
+ if (Number.isNaN(columnIndex)) {
+ return null;
+ }
+ return state.columns[columnIndex] ?? '';
+ }
+
+ const match = /^r:(\d+):c:(\d+)$/.exec(key);
+ if (!match) {
+ return null;
+ }
+
+ const rowIndex = Number(match[1]);
+ const columnIndex = Number(match[2]);
+ if (Number.isNaN(rowIndex) || Number.isNaN(columnIndex)) {
+ return null;
+ }
+
+ return state.rows[rowIndex]?.[columnIndex] ?? '';
+}
+
+function updateCellValue(state: TableState, key: string, nextValue: string): TableState {
+ const next = cloneTableState(state);
+ if (key.startsWith('h:')) {
+ const columnIndex = Number(key.slice(2));
+ if (!Number.isNaN(columnIndex) && next.columns[columnIndex] !== undefined) {
+ next.columns[columnIndex] = nextValue;
+ }
+ return next;
+ }
+
+ const match = /^r:(\d+):c:(\d+)$/.exec(key);
+ if (!match) {
+ return next;
+ }
+
+ const rowIndex = Number(match[1]);
+ const columnIndex = Number(match[2]);
+ if (
+ Number.isNaN(rowIndex) ||
+ Number.isNaN(columnIndex) ||
+ next.rows[rowIndex]?.[columnIndex] === undefined
+ ) {
+ return next;
+ }
+
+ next.rows[rowIndex][columnIndex] = nextValue;
+ return next;
+}
+
+export function MarkdownTableComponent({ markdown, nodeKey }: MarkdownTableComponentProps) {
+ const [editor] = useLexicalComposerContext();
+ const parsedFromMarkdown = useMemo(() => parseMarkdownTable(markdown), [markdown]);
+ const [tableState, setTableState] = useState
(() => {
+ return parsedFromMarkdown ?? { hasHeader: false, columns: [], rows: [] };
+ });
+
+ const cellRefs = useRef
- }
- targetLineComponent={
-
- }
- isOnMenu={(element) => {
- return element.closest('.draggable-block-menu') !== null;
- }}
- />
- );
+ return useDraggableBlockMenu({
+ editor,
+ anchorElem,
+ menuRef,
+ targetLineRef,
+ isEditable,
+ menuComponent: (
+
+
+
+ ),
+ targetLineComponent: (
+
+ ),
+ isOnMenu: (element) => {
+ return element.closest('.draggable-block-menu') !== null;
+ },
+ });
}
-
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..4756dab0 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';
@@ -15,16 +16,20 @@ import {
Heading1,
Heading2,
Heading3,
+ MessageSquare,
List,
ListOrdered,
Quote,
Code,
+ Minus,
Image as ImageIcon,
Database,
} 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';
+import { filterSlashOptionsByQuery } from './slashMenuFilter';
// Context for Asset Embed integration
export interface AssetEmbedContextType {
@@ -53,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);
@@ -136,6 +115,17 @@ 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
@@ -180,7 +170,7 @@ export default function SlashCommandPlugin({ projectId }: { projectId: string })
);
const filteredOptions = useMemo(
- () => filterSlashOptions(options, queryString),
+ () => filterSlashOptionsByQuery(options, queryString),
[options, queryString],
);
@@ -289,4 +279,3 @@ function SlashMenuItem({
);
}
-
diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/blockPositioning.ts b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/blockPositioning.ts
new file mode 100644
index 00000000..f94f4104
--- /dev/null
+++ b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/blockPositioning.ts
@@ -0,0 +1,218 @@
+// Forked from @lexical/react@0.18.0 LexicalDraggableBlockPlugin.dev.mjs.
+
+import { calculateZoomLevel } from '@lexical/utils';
+import { $getRoot, type LexicalEditor } from 'lexical';
+
+import {
+ Downward,
+ Indeterminate,
+ SPACE,
+ TARGET_LINE_HALF_HEIGHT,
+ TEXT_BOX_HORIZONTAL_PADDING,
+ Upward,
+} from './constants.ts';
+import { Point, Rectangle, type PointContainResult } from './geometry.ts';
+
+let prevIndex = Infinity;
+
+type PositionEvent = Pick;
+
+export function getCurrentIndex(keysLength: number): number {
+ if (keysLength === 0) {
+ return Infinity;
+ }
+
+ if (prevIndex >= 0 && prevIndex < keysLength) {
+ return prevIndex;
+ }
+
+ return Math.floor(keysLength / 2);
+}
+
+export function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
+ return editor.getEditorState().read(() => $getRoot().getChildrenKeys());
+}
+
+export function getCollapsedMargins(elem: HTMLElement): { marginBottom: number; marginTop: number } {
+ const getMargin = (element: Element | null, margin: 'marginTop' | 'marginBottom'): number =>
+ element ? parseFloat(window.getComputedStyle(element)[margin]) : 0;
+
+ const { marginTop, marginBottom } = window.getComputedStyle(elem);
+ const prevElemSiblingMarginBottom = getMargin(elem.previousElementSibling, 'marginBottom');
+ const nextElemSiblingMarginTop = getMargin(elem.nextElementSibling, 'marginTop');
+ const collapsedTopMargin = Math.max(parseFloat(marginTop), prevElemSiblingMarginBottom);
+ const collapsedBottomMargin = Math.max(parseFloat(marginBottom), nextElemSiblingMarginTop);
+
+ return {
+ marginBottom: collapsedBottomMargin,
+ marginTop: collapsedTopMargin,
+ };
+}
+
+export function getBlockElement(
+ anchorElem: HTMLElement,
+ editor: LexicalEditor,
+ event: PositionEvent,
+ useEdgeAsDefault = false,
+): HTMLElement | null {
+ const anchorElementRect = anchorElem.getBoundingClientRect();
+ const topLevelNodeKeys = getTopLevelNodeKeys(editor);
+ let blockElem: HTMLElement | null = null;
+
+ editor.getEditorState().read(() => {
+ if (useEdgeAsDefault) {
+ const [firstNode, lastNode] = [
+ editor.getElementByKey(topLevelNodeKeys[0]),
+ editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]),
+ ];
+ const [firstNodeRect, lastNodeRect] = [
+ firstNode != null ? firstNode.getBoundingClientRect() : undefined,
+ lastNode != null ? lastNode.getBoundingClientRect() : undefined,
+ ];
+
+ if (firstNodeRect && lastNodeRect) {
+ const firstNodeZoom = calculateZoomLevel(firstNode);
+ const lastNodeZoom = calculateZoomLevel(lastNode);
+
+ if (event.clientY / firstNodeZoom < firstNodeRect.top) {
+ blockElem = firstNode;
+ } else if (event.clientY / lastNodeZoom > lastNodeRect.bottom) {
+ blockElem = lastNode;
+ }
+
+ if (blockElem) {
+ return;
+ }
+ }
+ }
+
+ let index = getCurrentIndex(topLevelNodeKeys.length);
+ let direction = Indeterminate;
+
+ while (index >= 0 && index < topLevelNodeKeys.length) {
+ const key = topLevelNodeKeys[index];
+ const elem = editor.getElementByKey(key);
+
+ if (elem === null) {
+ break;
+ }
+
+ const zoom = calculateZoomLevel(elem);
+ const point = new Point(event.clientX / zoom, event.clientY / zoom);
+ const domRect = Rectangle.fromDOM(elem);
+ const { marginTop, marginBottom } = getCollapsedMargins(elem);
+ const rect = domRect.generateNewRect({
+ bottom: domRect.bottom + marginBottom,
+ left: anchorElementRect.left,
+ right: anchorElementRect.right,
+ top: domRect.top - marginTop,
+ });
+
+ const {
+ result,
+ reason: { isOnTopSide, isOnBottomSide },
+ } = rect.contains(point) as PointContainResult;
+
+ if (result) {
+ blockElem = elem;
+ prevIndex = index;
+ break;
+ }
+
+ if (useEdgeAsDefault && !isOnTopSide && !isOnBottomSide) {
+ // Drag handle can be slightly outside text box on the horizontal axis.
+ // In drag mode, prioritize vertical proximity to keep target line visible.
+ blockElem = elem;
+ prevIndex = index;
+ break;
+ }
+
+ if (direction === Indeterminate) {
+ if (isOnTopSide) {
+ direction = Upward;
+ } else if (isOnBottomSide) {
+ direction = Downward;
+ } else {
+ // Stop searching if pointer is neither above nor below current block.
+ direction = Infinity;
+ }
+ }
+
+ index += direction;
+ }
+ });
+
+ return blockElem;
+}
+
+export function setMenuPosition(
+ targetElem: HTMLElement | null,
+ floatingElem: HTMLElement,
+ anchorElem: HTMLElement,
+): void {
+ if (!targetElem) {
+ floatingElem.style.opacity = '0';
+ floatingElem.style.transform = 'translate(-10000px, -10000px)';
+ return;
+ }
+
+ const targetRect = targetElem.getBoundingClientRect();
+ const targetStyle = window.getComputedStyle(targetElem);
+ const floatingElemRect = floatingElem.getBoundingClientRect();
+ const anchorElementRect = anchorElem.getBoundingClientRect();
+ const top =
+ targetRect.top + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - anchorElementRect.top;
+ const left = SPACE;
+
+ floatingElem.style.opacity = '1';
+ floatingElem.style.transform = `translate(${left}px, ${top}px)`;
+}
+
+export function setDragImage(dataTransfer: DataTransfer, draggableBlockElem: HTMLElement): void {
+ const { transform } = draggableBlockElem.style;
+
+ // Remove dragImage borders.
+ draggableBlockElem.style.transform = 'translateZ(0)';
+ dataTransfer.setDragImage(draggableBlockElem, 0, 0);
+
+ setTimeout(() => {
+ draggableBlockElem.style.transform = transform;
+ });
+}
+
+export function setTargetLine(
+ targetLineElem: HTMLElement,
+ targetBlockElem: HTMLElement,
+ mouseY: number,
+ anchorElem: HTMLElement,
+): void {
+ const { top: targetBlockElemTop, height: targetBlockElemHeight } = targetBlockElem.getBoundingClientRect();
+ const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect();
+ const { marginTop, marginBottom } = getCollapsedMargins(targetBlockElem);
+
+ let lineTop = targetBlockElemTop;
+
+ if (shouldInsertAfterBlock(mouseY, targetBlockElemTop, targetBlockElemHeight)) {
+ lineTop += targetBlockElemHeight + marginBottom / 2;
+ } else {
+ lineTop -= marginTop / 2;
+ }
+
+ const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
+ const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;
+
+ targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
+ targetLineElem.style.width = `${anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2}px`;
+ targetLineElem.style.opacity = '.4';
+}
+
+export function shouldInsertAfterBlock(pointerY: number, targetTop: number, targetHeight: number): boolean {
+ return pointerY >= targetTop + targetHeight / 2;
+}
+
+export function hideTargetLine(targetLineElem: HTMLElement | null): void {
+ if (targetLineElem) {
+ targetLineElem.style.opacity = '0';
+ targetLineElem.style.transform = 'translate(-10000px, -10000px)';
+ }
+}
diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/constants.ts b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/constants.ts
new file mode 100644
index 00000000..3dcff89f
--- /dev/null
+++ b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/constants.ts
@@ -0,0 +1,10 @@
+// Forked from @lexical/react@0.18.0 LexicalDraggableBlockPlugin.dev.mjs.
+
+export const SPACE = 4;
+export const TARGET_LINE_HALF_HEIGHT = 2;
+export const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
+export const TEXT_BOX_HORIZONTAL_PADDING = 28;
+
+export const Downward = 1;
+export const Upward = -1;
+export const Indeterminate = 0;
diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/geometry.ts b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/geometry.ts
new file mode 100644
index 00000000..edb6fa22
--- /dev/null
+++ b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/geometry.ts
@@ -0,0 +1,198 @@
+// Forked from @lexical/react@0.18.0 LexicalDraggableBlockPlugin.dev.mjs.
+
+export interface PointLike {
+ x: number;
+ y: number;
+}
+
+export interface RectangleLike {
+ top: number;
+ left: number;
+ bottom: number;
+ right: number;
+}
+
+interface PointContainReason {
+ isOnBottomSide: boolean;
+ isOnLeftSide: boolean;
+ isOnRightSide: boolean;
+ isOnTopSide: boolean;
+}
+
+export interface PointContainResult {
+ reason: PointContainReason;
+ result: boolean;
+}
+
+export class Point implements PointLike {
+ private _x: number;
+ private _y: number;
+
+ constructor(x: number, y: number) {
+ this._x = x;
+ this._y = y;
+ }
+
+ get x(): number {
+ return this._x;
+ }
+
+ get y(): number {
+ return this._y;
+ }
+
+ equals({ x, y }: PointLike): boolean {
+ return this.x === x && this.y === y;
+ }
+
+ calcDeltaXTo({ x }: PointLike): number {
+ return this.x - x;
+ }
+
+ calcDeltaYTo({ y }: PointLike): number {
+ return this.y - y;
+ }
+
+ calcHorizontalDistanceTo(point: PointLike): number {
+ return Math.abs(this.calcDeltaXTo(point));
+ }
+
+ calcVerticalDistance(point: PointLike): number {
+ return Math.abs(this.calcDeltaYTo(point));
+ }
+
+ calcDistanceTo(point: PointLike): number {
+ return Math.sqrt(Math.pow(this.calcDeltaXTo(point), 2) + Math.pow(this.calcDeltaYTo(point), 2));
+ }
+}
+
+export function isPoint(value: unknown): value is Point {
+ return value instanceof Point;
+}
+
+export class Rectangle {
+ private _top: number;
+ private _right: number;
+ private _left: number;
+ private _bottom: number;
+
+ constructor(left: number, top: number, right: number, bottom: number) {
+ const [physicTop, physicBottom] = top <= bottom ? [top, bottom] : [bottom, top];
+ const [physicLeft, physicRight] = left <= right ? [left, right] : [right, left];
+
+ this._top = physicTop;
+ this._right = physicRight;
+ this._left = physicLeft;
+ this._bottom = physicBottom;
+ }
+
+ get top(): number {
+ return this._top;
+ }
+
+ get right(): number {
+ return this._right;
+ }
+
+ get bottom(): number {
+ return this._bottom;
+ }
+
+ get left(): number {
+ return this._left;
+ }
+
+ get width(): number {
+ return Math.abs(this._left - this._right);
+ }
+
+ get height(): number {
+ return Math.abs(this._bottom - this._top);
+ }
+
+ equals({ top, left, bottom, right }: RectangleLike): boolean {
+ return top === this._top && bottom === this._bottom && left === this._left && right === this._right;
+ }
+
+ contains(target: Point): PointContainResult;
+ contains(target: Rectangle): boolean;
+ contains(target: Point | Rectangle): PointContainResult | boolean {
+ if (isPoint(target)) {
+ const { x, y } = target;
+ const isOnTopSide = y < this._top;
+ const isOnBottomSide = y > this._bottom;
+ const isOnLeftSide = x < this._left;
+ const isOnRightSide = x > this._right;
+ const result = !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
+
+ return {
+ reason: {
+ isOnBottomSide,
+ isOnLeftSide,
+ isOnRightSide,
+ isOnTopSide,
+ },
+ result,
+ };
+ }
+
+ const { top, left, bottom, right } = target;
+
+ return (
+ top >= this._top &&
+ top <= this._bottom &&
+ bottom >= this._top &&
+ bottom <= this._bottom &&
+ left >= this._left &&
+ left <= this._right &&
+ right >= this._left &&
+ right <= this._right
+ );
+ }
+
+ intersectsWith(rect: Rectangle): boolean {
+ const { left: x1, top: y1, width: w1, height: h1 } = rect;
+ const { left: x2, top: y2, width: w2, height: h2 } = this;
+ const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
+ const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
+ const minX = x1 <= x2 ? x1 : x2;
+ const minY = y1 <= y2 ? y1 : y2;
+
+ return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
+ }
+
+ generateNewRect({
+ left = this.left,
+ top = this.top,
+ right = this.right,
+ bottom = this.bottom,
+ }: {
+ left?: number;
+ top?: number;
+ right?: number;
+ bottom?: number;
+ }): Rectangle {
+ return new Rectangle(left, top, right, bottom);
+ }
+
+ static fromLTRB(left: number, top: number, right: number, bottom: number): Rectangle {
+ return new Rectangle(left, top, right, bottom);
+ }
+
+ static fromLWTH(left: number, width: number, top: number, height: number): Rectangle {
+ return new Rectangle(left, top, left + width, top + height);
+ }
+
+ static fromPoints(startPoint: PointLike, endPoint: PointLike): Rectangle {
+ const { y: top, x: left } = startPoint;
+ const { y: bottom, x: right } = endPoint;
+
+ return Rectangle.fromLTRB(left, top, right, bottom);
+ }
+
+ static fromDOM(dom: HTMLElement): Rectangle {
+ const { top, width, left, height } = dom.getBoundingClientRect();
+
+ return Rectangle.fromLWTH(left, width, top, height);
+ }
+}
diff --git a/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/useDraggableBlockMenu.tsx b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/useDraggableBlockMenu.tsx
new file mode 100644
index 00000000..fb5c73e0
--- /dev/null
+++ b/frontend/pluto_duck_frontend/components/editor/plugins/draggable-block/useDraggableBlockMenu.tsx
@@ -0,0 +1,252 @@
+// Forked from @lexical/react@0.18.0 LexicalDraggableBlockPlugin.dev.mjs.
+
+import { eventFiles } from '@lexical/rich-text';
+import { calculateZoomLevel, isHTMLElement, mergeRegister } from '@lexical/utils';
+import {
+ $getNearestNodeFromDOMNode,
+ $getNodeByKey,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ DRAGOVER_COMMAND,
+ DROP_COMMAND,
+ type LexicalEditor,
+} from 'lexical';
+import {
+ type DragEvent as ReactDragEvent,
+ type ReactNode,
+ type ReactPortal,
+ type RefObject,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+
+import { DRAG_DATA_FORMAT } from './constants';
+import {
+ getBlockElement,
+ hideTargetLine,
+ shouldInsertAfterBlock,
+ setDragImage,
+ setMenuPosition,
+ setTargetLine,
+} from './blockPositioning';
+
+interface UseDraggableBlockMenuParams {
+ editor: LexicalEditor;
+ anchorElem: HTMLElement;
+ menuRef: RefObject;
+ targetLineRef: RefObject;
+ isEditable: boolean;
+ menuComponent: ReactNode;
+ targetLineComponent: ReactNode;
+ isOnMenu: (element: HTMLElement) => boolean;
+}
+
+function resolveEventTargetElement(target: EventTarget | null): HTMLElement | null {
+ if (target == null) {
+ return null;
+ }
+
+ if (isHTMLElement(target)) {
+ return target;
+ }
+
+ if (target instanceof Node && target.parentElement && isHTMLElement(target.parentElement)) {
+ return target.parentElement;
+ }
+
+ return null;
+}
+
+export function useDraggableBlockMenu({
+ editor,
+ anchorElem,
+ menuRef,
+ targetLineRef,
+ isEditable,
+ menuComponent,
+ targetLineComponent,
+ isOnMenu,
+}: UseDraggableBlockMenuParams): ReactPortal {
+ const scrollerElem = anchorElem.parentElement;
+ const isDraggingBlockRef = useRef(false);
+ const [draggableBlockElem, setDraggableBlockElem] = useState(null);
+
+ useEffect(() => {
+ function onMouseMove(event: MouseEvent): void {
+ const target = event.target;
+
+ if (target != null && !isHTMLElement(target)) {
+ setDraggableBlockElem(null);
+ return;
+ }
+
+ if (target != null && isOnMenu(target)) {
+ return;
+ }
+
+ const nextDraggableBlockElem = getBlockElement(anchorElem, editor, event);
+ setDraggableBlockElem(nextDraggableBlockElem);
+ }
+
+ function onMouseLeave(): void {
+ setDraggableBlockElem(null);
+ }
+
+ if (scrollerElem != null) {
+ scrollerElem.addEventListener('mousemove', onMouseMove);
+ scrollerElem.addEventListener('mouseleave', onMouseLeave);
+ }
+
+ return () => {
+ if (scrollerElem != null) {
+ scrollerElem.removeEventListener('mousemove', onMouseMove);
+ scrollerElem.removeEventListener('mouseleave', onMouseLeave);
+ }
+ };
+ }, [scrollerElem, anchorElem, editor, isOnMenu]);
+
+ useEffect(() => {
+ if (menuRef.current) {
+ setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
+ }
+ }, [anchorElem, draggableBlockElem, menuRef]);
+
+ useEffect(() => {
+ function onDragover(event: DragEvent): boolean {
+ if (!isDraggingBlockRef.current) {
+ return false;
+ }
+
+ const [isFileTransfer] = eventFiles(event);
+ if (isFileTransfer) {
+ return false;
+ }
+
+ const { clientY, target } = event;
+ const targetElement = resolveEventTargetElement(target);
+ if (targetElement === null) {
+ return false;
+ }
+
+ const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
+ const targetLineElem = targetLineRef.current;
+
+ if (targetBlockElem === null || targetLineElem === null) {
+ return false;
+ }
+
+ setTargetLine(targetLineElem, targetBlockElem, clientY / calculateZoomLevel(targetElement), anchorElem);
+
+ // Prevent default event to be able to trigger onDrop events.
+ event.preventDefault();
+ return true;
+ }
+
+ function onDrop(event: DragEvent): boolean {
+ if (!isDraggingBlockRef.current) {
+ return false;
+ }
+
+ const [isFileTransfer] = eventFiles(event);
+ if (isFileTransfer) {
+ return false;
+ }
+
+ const { target, dataTransfer, clientY } = event;
+ const targetElement = resolveEventTargetElement(target);
+ if (targetElement === null) {
+ return false;
+ }
+ const dragData = dataTransfer != null ? dataTransfer.getData(DRAG_DATA_FORMAT) : '';
+ const draggedNode = $getNodeByKey(dragData);
+
+ if (!draggedNode) {
+ return false;
+ }
+
+ const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
+ if (!targetBlockElem) {
+ return false;
+ }
+
+ const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
+ if (!targetNode) {
+ return false;
+ }
+
+ if (targetNode === draggedNode) {
+ return true;
+ }
+
+ const { top: targetBlockElemTop, height: targetBlockElemHeight } = targetBlockElem.getBoundingClientRect();
+ if (
+ shouldInsertAfterBlock(
+ clientY / calculateZoomLevel(targetElement),
+ targetBlockElemTop,
+ targetBlockElemHeight,
+ )
+ ) {
+ targetNode.insertAfter(draggedNode);
+ } else {
+ targetNode.insertBefore(draggedNode);
+ }
+
+ setDraggableBlockElem(null);
+ return true;
+ }
+
+ return mergeRegister(
+ editor.registerCommand(
+ DRAGOVER_COMMAND,
+ (event) => {
+ return onDragover(event);
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ DROP_COMMAND,
+ (event) => {
+ return onDrop(event);
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+ }, [anchorElem, editor, targetLineRef]);
+
+ function onDragStart(event: ReactDragEvent): void {
+ const { dataTransfer } = event;
+ if (!dataTransfer || !draggableBlockElem) {
+ return;
+ }
+
+ setDragImage(dataTransfer, draggableBlockElem);
+
+ let nodeKey = '';
+ editor.update(() => {
+ const node = $getNearestNodeFromDOMNode(draggableBlockElem);
+ if (node) {
+ nodeKey = node.getKey();
+ }
+ });
+
+ isDraggingBlockRef.current = true;
+ dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
+ }
+
+ function onDragEnd(): void {
+ isDraggingBlockRef.current = false;
+ hideTargetLine(targetLineRef.current);
+ }
+
+ return createPortal(
+ <>
+
+ {isEditable && menuComponent}
+
+ {targetLineComponent}
+ >,
+ anchorElem,
+ );
+}
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/theme.ts b/frontend/pluto_duck_frontend/components/editor/theme.ts
index e2b8bcbe..8cf0afd8 100644
--- a/frontend/pluto_duck_frontend/components/editor/theme.ts
+++ b/frontend/pluto_duck_frontend/components/editor/theme.ts
@@ -1,5 +1,5 @@
export const editorTheme = {
- paragraph: 'mb-2 text-base',
+ paragraph: 'mt-0 mb-3 text-base',
quote: 'border-l-4 border-gray-300 pl-4 italic my-2',
heading: {
h1: 'text-3xl font-bold mb-4 mt-6',
@@ -10,8 +10,8 @@ export const editorTheme = {
nested: {
listitem: 'list-none',
},
- ol: 'list-decimal ml-4 my-2',
- ul: 'list-disc ml-4 my-2',
+ ol: 'list-decimal ml-0 pl-5 my-2',
+ ul: 'list-disc ml-0 pl-5 my-2',
listitem: 'mb-1',
},
code: 'bg-muted px-1.5 py-0.5 rounded font-mono text-sm',
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
new file mode 100644
index 00000000..15730a47
--- /dev/null
+++ b/frontend/pluto_duck_frontend/components/editor/transformers.ts
@@ -0,0 +1,139 @@
+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';
+import {
+ $createMarkdownTableNode,
+ $isMarkdownTableNode,
+ MarkdownTableNode,
+} from './nodes/MarkdownTableNode';
+import {
+ CALLOUT_BLOCK_START_REGEXP,
+ CALLOUT_INLINE_REGEXP,
+ HORIZONTAL_RULE_REGEXP,
+ resolveCalloutType,
+ stripCalloutQuotePrefix,
+} from './transformerUtils';
+import { parseMarkdownTable } from './markdownTableUtils';
+
+const HORIZONTAL_RULE: ElementTransformer = {
+ dependencies: [HorizontalRuleNode],
+ export: (node) => ($isHorizontalRuleNode(node) ? '---' : null),
+ regExp: HORIZONTAL_RULE_REGEXP,
+ replace: (parentNode) => {
+ parentNode.replace($createHorizontalRuleNode());
+ },
+ type: 'element',
+};
+
+const CALLOUT_MARKER_BY_TYPE: Record = {
+ info: 'NOTE',
+ warning: 'WARNING',
+ success: 'TIP',
+ error: 'CAUTION',
+};
+
+const CALLOUT_INLINE: ElementTransformer = {
+ dependencies: [CalloutNode],
+ export: () => null,
+ regExp: CALLOUT_INLINE_REGEXP,
+ replace: (parentNode, _children, match) => {
+ const type = resolveCalloutType(match[1]);
+ 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: CALLOUT_BLOCK_START_REGEXP,
+ regExpEnd: {
+ optional: true,
+ regExp: /^$/,
+ },
+ replace: (rootNode, children, startMatch, _endMatch, linesInBetween) => {
+ const type = resolveCalloutType(startMatch[1]);
+ 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) => stripCalloutQuotePrefix(line));
+ const text = lines.join('\n').trim();
+ rootNode.append($createCalloutNode(type, text));
+ },
+ type: 'multiline-element',
+};
+
+export const MARKDOWN_TABLE: MultilineElementTransformer = {
+ dependencies: [MarkdownTableNode],
+ export: (node) => ($isMarkdownTableNode(node) ? node.getMarkdown() : null),
+ regExpStart: /^\|(.+)\|$/,
+ regExpEnd: {
+ optional: true,
+ regExp: /^(?!\|)/,
+ },
+ replace: (rootNode, children, startMatch, _endMatch, linesInBetween, isImport) => {
+ if (!isImport) {
+ return false;
+ }
+
+ const markdownLines = children
+ ? children
+ .map((node) => node.getTextContent())
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ : [startMatch[0], ...(linesInBetween || [])];
+
+ const markdown = markdownLines.join('\n').trim();
+ const parsed = parseMarkdownTable(markdown);
+ if (!parsed) {
+ return false;
+ }
+
+ rootNode.append($createMarkdownTableNode(markdown));
+ },
+ type: 'multiline-element',
+};
+
+export const BOARD_TRANSFORMERS = [
+ MARKDOWN_TABLE,
+ CALLOUT_BLOCK,
+ CALLOUT_INLINE,
+ HORIZONTAL_RULE,
+ ...TRANSFORMERS,
+];
diff --git a/frontend/pluto_duck_frontend/hooks/useBoards.ts b/frontend/pluto_duck_frontend/hooks/useBoards.ts
index 3a33a809..11e50e47 100644
--- a/frontend/pluto_duck_frontend/hooks/useBoards.ts
+++ b/frontend/pluto_duck_frontend/hooks/useBoards.ts
@@ -118,6 +118,11 @@ export function useBoards({ projectId, enabled = true }: UseBoardsOptions) {
}
}, []);
+ const applyBoardUpdate = useCallback((updatedBoard: Board) => {
+ setBoards(prev => prev.map(board => board.id === updatedBoard.id ? updatedBoard : board));
+ setActiveBoard(prev => prev?.id === updatedBoard.id ? updatedBoard : prev);
+ }, []);
+
// Reset state when projectId changes (before loadBoards runs)
useEffect(() => {
// Clear previous project's data immediately when project changes
@@ -144,5 +149,6 @@ export function useBoards({ projectId, enabled = true }: UseBoardsOptions) {
updateBoard,
deleteBoard,
selectBoard,
+ applyBoardUpdate,
};
}
diff --git a/frontend/pluto_duck_frontend/lib/__tests__/boardTitle.test.ts b/frontend/pluto_duck_frontend/lib/__tests__/boardTitle.test.ts
new file mode 100644
index 00000000..c7897435
--- /dev/null
+++ b/frontend/pluto_duck_frontend/lib/__tests__/boardTitle.test.ts
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { formatBoardUpdatedAt, getDisplayTabTitle } from '../boardTitle.ts';
+
+test('getDisplayTabTitle returns original value when not blank', () => {
+ assert.equal(getDisplayTabTitle('Hello'), 'Hello');
+});
+
+test('getDisplayTabTitle returns Untitled for blank values', () => {
+ assert.equal(getDisplayTabTitle(' '), 'Untitled');
+});
+
+test('formatBoardUpdatedAt returns formatted text for valid ISO', () => {
+ const iso = '2025-10-25T20:20:00.000Z';
+ const expected = new Date(iso).toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+ assert.equal(formatBoardUpdatedAt(iso), expected);
+});
+
+test('formatBoardUpdatedAt returns null for undefined/null/invalid values', () => {
+ assert.equal(formatBoardUpdatedAt(undefined), null);
+ assert.equal(formatBoardUpdatedAt(null), null);
+ assert.equal(formatBoardUpdatedAt('invalid-date'), null);
+});
diff --git a/frontend/pluto_duck_frontend/lib/__tests__/draggableBlockPositioning.test.ts b/frontend/pluto_duck_frontend/lib/__tests__/draggableBlockPositioning.test.ts
new file mode 100644
index 00000000..be872cd8
--- /dev/null
+++ b/frontend/pluto_duck_frontend/lib/__tests__/draggableBlockPositioning.test.ts
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { shouldInsertAfterBlock } from '../../components/editor/plugins/draggable-block/blockPositioning.ts';
+
+test('clientY 기준 판정은 스크롤 오프셋과 무관하게 일관된다', () => {
+ const targetTop = 620;
+ const targetHeight = 120;
+ const clientY = 610;
+ const syntheticPageY = 1610;
+
+ assert.equal(shouldInsertAfterBlock(clientY, targetTop, targetHeight), false);
+ assert.equal(shouldInsertAfterBlock(syntheticPageY, targetTop, targetHeight), true);
+});
+
+test('경계값 mouseY === targetTop 에서는 위쪽 삽입(상단 라인)으로 판정한다', () => {
+ const targetTop = 400;
+ const targetHeight = 100;
+
+ assert.equal(shouldInsertAfterBlock(targetTop, targetTop, targetHeight), false);
+});
+
+test('긴 블록 상단 근처에서는 뒤(아래)로 밀리지 않고 상단 삽입으로 판정한다', () => {
+ const targetTop = 100;
+ const targetHeight = 200;
+ const pointerNearTop = 120;
+
+ assert.equal(shouldInsertAfterBlock(pointerNearTop, targetTop, targetHeight), false);
+});
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__/markdownTableUtils.test.ts b/frontend/pluto_duck_frontend/lib/__tests__/markdownTableUtils.test.ts
new file mode 100644
index 00000000..7465d902
--- /dev/null
+++ b/frontend/pluto_duck_frontend/lib/__tests__/markdownTableUtils.test.ts
@@ -0,0 +1,62 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { buildMarkdownTable, parseMarkdownTable } from '../../components/editor/markdownTableUtils.ts';
+
+test('parseMarkdownTable parses header table with separator', () => {
+ const result = parseMarkdownTable('| A | B |\n| --- | --- |\n| 1 | 2 |');
+
+ assert.deepEqual(result, {
+ hasHeader: true,
+ columns: ['A', 'B'],
+ rows: [['1', '2']],
+ });
+});
+
+test('parseMarkdownTable parses headerless table when columns are consistent', () => {
+ const result = parseMarkdownTable('| 1 | 2 |\n| 3 | 4 |');
+
+ assert.deepEqual(result, {
+ hasHeader: false,
+ columns: [],
+ rows: [
+ ['1', '2'],
+ ['3', '4'],
+ ],
+ });
+});
+
+test('parseMarkdownTable keeps empty cells', () => {
+ const result = parseMarkdownTable('| A | B |\n| --- | --- |\n| 1 | |');
+
+ assert.deepEqual(result, {
+ hasHeader: true,
+ columns: ['A', 'B'],
+ rows: [['1', '']],
+ });
+});
+
+test('parseMarkdownTable returns null when headerless table has inconsistent columns', () => {
+ const result = parseMarkdownTable('| 1 | 2 |\n| 3 |');
+ assert.equal(result, null);
+});
+
+test('parseMarkdownTable returns null when only one pipe row exists', () => {
+ const result = parseMarkdownTable('| 1 | 2 |');
+ assert.equal(result, null);
+});
+
+test('parseMarkdownTable returns null on empty input', () => {
+ const result = parseMarkdownTable('');
+ assert.equal(result, null);
+});
+
+test('buildMarkdownTable serializes header table', () => {
+ const markdown = buildMarkdownTable(['A', 'B'], [['1', '2']], true);
+ assert.equal(markdown, '| A | B |\n| --- | --- |\n| 1 | 2 |');
+});
+
+test('buildMarkdownTable serializes headerless table', () => {
+ const markdown = buildMarkdownTable([], [['1', '2'], ['3', '4']], false);
+ assert.equal(markdown, '| 1 | 2 |\n| 3 | 4 |');
+});
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']);
+});
diff --git a/frontend/pluto_duck_frontend/lib/boardTitle.ts b/frontend/pluto_duck_frontend/lib/boardTitle.ts
new file mode 100644
index 00000000..fa82246d
--- /dev/null
+++ b/frontend/pluto_duck_frontend/lib/boardTitle.ts
@@ -0,0 +1,27 @@
+export function getDisplayTabTitle(name: string): string {
+ if (name.trim().length === 0) {
+ return 'Untitled';
+ }
+
+ return name;
+}
+
+export function formatBoardUpdatedAt(updatedAt: string | null | undefined): string | null {
+ if (!updatedAt) {
+ return null;
+ }
+
+ const parsed = new Date(updatedAt);
+ if (Number.isNaN(parsed.getTime())) {
+ return null;
+ }
+
+ return parsed.toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+}