From 427c236dce36a41aaa72be649ebc17716849c633 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Sun, 18 Oct 2026 15:46:12 +0200 Subject: [PATCH 001/142] =?UTF-8?q?feat(editor):=20block=20Insert=20menu?= =?UTF-8?q?=20=E2=80=94=20gutter=20+=20opens=20the=20slash=20catalogue=20(?= =?UTF-8?q?B11.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gutter + now opens the host's slash catalogue as a filterable picker through the shared search picker, anchored to the button (the gutter is pinned while it is open), and inserts the chosen block below. The block action menu gains Insert above / Insert below rows, and Mod+Shift+Enter opens the same picker below the caret's block. Dismissing the picker leaves the document untouched; without a menu host + falls back to an empty paragraph. Co-Authored-By: Claude Opus 5.5 --- apps/notes/src/editor/commands.tsx | 2 + apps/notes/src/editor/editor.tsx | 1 + apps/notes/src/keyboard/action-ids.ts | 3 + apps/notes/src/keyboard/default-chords.ts | 1 + packages/editor/src/block-command.ts | 5 + packages/editor/src/i18n.tsx | 15 + packages/editor/src/icons.tsx | 24 ++ packages/editor/src/index.ts | 16 ++ .../editor/src/plugins/block-action-menu.tsx | 4 + .../src/plugins/block-gutter-plugin.tsx | 113 +++++++- .../editor/src/plugins/insert-menu.test.tsx | 263 ++++++++++++++++++ packages/editor/src/plugins/insert-menu.tsx | 203 ++++++++++++++ .../src/plugins/standard-editing-plugins.tsx | 7 +- 13 files changed, 641 insertions(+), 16 deletions(-) create mode 100644 packages/editor/src/plugins/insert-menu.test.tsx create mode 100644 packages/editor/src/plugins/insert-menu.tsx diff --git a/apps/notes/src/editor/commands.tsx b/apps/notes/src/editor/commands.tsx index b06af5ec..d13407b9 100644 --- a/apps/notes/src/editor/commands.tsx +++ b/apps/notes/src/editor/commands.tsx @@ -30,6 +30,7 @@ import { applySwatchToBlocks, createEditorT, createEntityEmbedCommand, + createInsertBlockActions, createMediaBlockCommands, createStandardBlockActions, createStandardBlockCommands, @@ -471,6 +472,7 @@ const deleteActionIndex = sharedActions.findIndex((c) => c.id === "block.action. * add-property actions slotted before Delete, plus the colour / * highlight swatch rows. */ export const BLOCK_ACTIONS: readonly BlockCommand[] = [ + ...createInsertBlockActions(editorT, BLOCK_COMMANDS), ...sharedActions.slice(0, deleteActionIndex), COPY_LINK_ACTION, ADD_PROPERTY_ACTION, diff --git a/apps/notes/src/editor/editor.tsx b/apps/notes/src/editor/editor.tsx index 0db04de8..53c7f023 100644 --- a/apps/notes/src/editor/editor.tsx +++ b/apps/notes/src/editor/editor.tsx @@ -283,6 +283,7 @@ export function Editor({ diff --git a/apps/notes/src/keyboard/action-ids.ts b/apps/notes/src/keyboard/action-ids.ts index c338846f..72154108 100644 --- a/apps/notes/src/keyboard/action-ids.ts +++ b/apps/notes/src/keyboard/action-ids.ts @@ -58,6 +58,9 @@ export enum ActionId { * `Mod+Enter` — conventional escape hatch when Enter alone inserts a * newline inside the code. */ ExitCodeBlock = "notes.exit-code-block", + /** Open the block Insert menu below the caret's block (B11.21). Bound to + * `Mod+Shift+Enter`; the editor's `INSERT_MENU_CHORDS` owns the binding. */ + OpenInsertMenu = "notes.open-insert-menu", /** Close the media inspector popover. Bound to `Escape`. */ CloseMediaInspector = "notes.close-media-inspector", /** Toggle the strikethrough text mark on the selection (B11.6). Lexical diff --git a/apps/notes/src/keyboard/default-chords.ts b/apps/notes/src/keyboard/default-chords.ts index e3dccab5..9f332b2b 100644 --- a/apps/notes/src/keyboard/default-chords.ts +++ b/apps/notes/src/keyboard/default-chords.ts @@ -41,6 +41,7 @@ export const DEFAULT_CHORDS: Readonly> = { [ActionId.CutSelectedBlocks]: ["Mod+x"], [ActionId.PasteSelectedBlocks]: ["Mod+v"], [ActionId.ExitCodeBlock]: ["Mod+Enter"], + [ActionId.OpenInsertMenu]: ["Mod+Shift+Enter"], [ActionId.CloseMediaInspector]: ["Escape"], [ActionId.OpenLinkMarkup]: ["Mod+k"], [ActionId.ToggleStrikeMark]: ["Mod+Shift+S"], diff --git a/packages/editor/src/block-command.ts b/packages/editor/src/block-command.ts index c4cd3271..780aa2a3 100644 --- a/packages/editor/src/block-command.ts +++ b/packages/editor/src/block-command.ts @@ -32,6 +32,11 @@ export enum CommandCategory { Align = "align", Indent = "indent", TurnInto = "turn-into", + /** Insert a block above / below the targeted one (B11.21) — block action + * menu only; opens the slash catalogue as an insert picker. */ + Insert = "insert", + /** Block-level background tint (B11.23) — block action menu only. */ + Background = "background", /** Bulk text colour / highlight on the selected block(s) (B11.7) — only * surfaced in the block action menu, never the slash menu. */ Color = "color", diff --git a/packages/editor/src/i18n.tsx b/packages/editor/src/i18n.tsx index 31b4774b..5520a9f7 100644 --- a/packages/editor/src/i18n.tsx +++ b/packages/editor/src/i18n.tsx @@ -50,6 +50,14 @@ export type EditorI18nKey = | "editor.action.menu.alignSection" | "editor.action.menu.indentSection" | "editor.action.menu.actionsSection" + | "editor.action.menu.insertSection" + | "editor.action.menu.backgroundSection" + // Insert menu (B11.21) — gutter `+`, action-menu rows, Mod+Shift+Enter. + | "editor.insert.above" + | "editor.insert.below" + | "editor.insertMenu.region" + | "editor.insertMenu.placeholder" + | "editor.insertMenu.empty" // Slash menu (SlashMenuPlugin). | "editor.slashMenu.region" | "editor.slashMenu.empty" @@ -291,6 +299,13 @@ export const EDITOR_I18N_DEFAULTS: EditorManifest = Object.freeze({ "editor.action.menu.alignSection": "Align", "editor.action.menu.indentSection": "Indent", "editor.action.menu.actionsSection": "Actions", + "editor.action.menu.insertSection": "Insert", + "editor.action.menu.backgroundSection": "Background", + "editor.insert.above": "Insert above…", + "editor.insert.below": "Insert below…", + "editor.insertMenu.region": "Insert a block", + "editor.insertMenu.placeholder": "Search blocks…", + "editor.insertMenu.empty": "No matching blocks", "editor.inline.toolbar.region": "Text formatting", "editor.inline.bold": "Bold", "editor.inline.italic": "Italic", diff --git a/packages/editor/src/icons.tsx b/packages/editor/src/icons.tsx index 8e40a172..505e25f5 100644 --- a/packages/editor/src/icons.tsx +++ b/packages/editor/src/icons.tsx @@ -12,6 +12,7 @@ import { ArrowLineDown, ArrowLineLeft, ArrowLineRight, + ArrowLineUp, ArrowRight, ArrowUp, Bookmark, @@ -20,12 +21,14 @@ import { CaretRight, ChatCircle, Check, + CheckSquare, Code, Columns, Copy, DotsSixVertical, DotsThree, File, + FileArrowUp, FilePlus, FilmStrip, Function as FunctionGlyph, @@ -42,6 +45,7 @@ import { MagnifyingGlass, Minus, MusicNote, + PaintBucket, Paragraph, Plus, Quotes, @@ -336,3 +340,23 @@ export function RichTextTypeIcon(): ReactNode { export function GripIcon(): ReactNode { return ; } + +export function InsertAboveIcon(): ReactNode { + return ; +} + +export function InsertBelowIcon(): ReactNode { + return ; +} + +export function BlockBackgroundIcon(): ReactNode { + return ; +} + +export function TaskIcon(): ReactNode { + return ; +} + +export function TurnIntoPageIcon(): ReactNode { + return ; +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index f67e8e24..e5a59cba 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -101,6 +101,17 @@ export { BlockGutterPlugin, } from "./plugins/block-gutter-plugin"; export { useEditorShortcut } from "./plugins/editor-shortcut"; +export { + INSERT_MENU_CHORDS, + InsertPosition, + type OpenInsertMenuOptions, + createInsertBlockActions, + insertBlockWithCommand, + insertEmptyBlock, + insertMenuRows, + openInsertMenu, + pickInsertTarget, +} from "./plugins/insert-menu"; export { type SlashMenuPluginProps, SlashMenuPlugin, @@ -146,8 +157,13 @@ export { Heading1Icon, Heading2Icon, Heading3Icon, + BlockBackgroundIcon, ImageIcon, IndentIcon, + InsertAboveIcon, + InsertBelowIcon, + TaskIcon, + TurnIntoPageIcon, InlineCodeIcon, ItalicIcon, LinkIcon, diff --git a/packages/editor/src/plugins/block-action-menu.tsx b/packages/editor/src/plugins/block-action-menu.tsx index b9daac06..b771d404 100644 --- a/packages/editor/src/plugins/block-action-menu.tsx +++ b/packages/editor/src/plugins/block-action-menu.tsx @@ -42,6 +42,8 @@ function nodeIcon(node: ReactNode): IconParam { type EditorI18nKey = Parameters[0]; const SECTION_LABEL: Partial> = { + [CommandCategory.Insert]: "editor.action.menu.insertSection", + [CommandCategory.Background]: "editor.action.menu.backgroundSection", [CommandCategory.TurnInto]: "editor.action.menu.turnIntoSection", [CommandCategory.Color]: "editor.action.menu.colorSection", [CommandCategory.Highlight]: "editor.action.menu.highlightSection", @@ -54,12 +56,14 @@ const SECTION_LABEL: Partial> = { // / Align / Indent / Actions) — they're a tall, less-frequent block, so burying // the everyday actions under ~20 swatch rows would be the wrong trade. const SECTION_ORDER: readonly CommandCategory[] = [ + CommandCategory.Insert, CommandCategory.TurnInto, CommandCategory.Align, CommandCategory.Indent, CommandCategory.Action, CommandCategory.Color, CommandCategory.Highlight, + CommandCategory.Background, ]; export function openBlockActionMenu(options: OpenBlockActionMenuOptions): void { diff --git a/packages/editor/src/plugins/block-gutter-plugin.tsx b/packages/editor/src/plugins/block-gutter-plugin.tsx index e0e4a60e..9f61aec4 100644 --- a/packages/editor/src/plugins/block-gutter-plugin.tsx +++ b/packages/editor/src/plugins/block-gutter-plugin.tsx @@ -1,6 +1,10 @@ /** * BlockGutterPlugin — left-margin hover affordance for the editor: - * - A `+` button inserts an empty paragraph below the hovered row. + * - A `+` button opens the Insert menu (B11.21) — the host's slash + * catalogue as a filterable picker anchored to the button — and inserts + * the chosen block below the hovered row. Without `insertCommands` it + * inserts an empty paragraph. `Mod+Shift+Enter` opens the same picker + * below the caret's block (the keyboard path). * - A grip handle: * - On click → opens the block-action menu (TurnInto + Delete), * scoped to the current block-selection if any (else the @@ -20,9 +24,9 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { - $createParagraphNode, - $getNodeByKey, $getRoot, + $getSelection, + $isRangeSelection, type LexicalEditor, type NodeKey, } from "lexical"; @@ -36,6 +40,14 @@ import { openBlockActionMenu } from "./block-action-menu"; import { gutterAnchor } from "./block-gutter-anchor"; import { moveBlocksTo } from "./block-ops"; import { useBlockSelectionStore } from "./block-selection-plugin"; +import { useEditorShortcut } from "./editor-shortcut"; +import { + INSERT_MENU_CHORDS, + InsertPosition, + insertEmptyBlock, + openInsertMenu, + pickInsertTarget, +} from "./insert-menu"; /** Distance from the block's left edge to the gutter element's left * edge. The gutter is two 22px buttons with a 2px gap (46px wide). At @@ -59,6 +71,9 @@ export type BlockGutterPluginProps = { * type). When omitted, the action menu renders no command rows — * the gutter still works for `+` insert + drag-to-reorder. */ commands?: readonly BlockCommand[]; + /** The slash catalogue the `+` Insert menu (and `Mod+Shift+Enter`) offers. + * Omit to keep `+` as a plain "empty paragraph below". */ + insertCommands?: readonly BlockCommand[]; /** CSS selector for the scroll container that owns the document. * Defaults to walking the editor root's nearest scrollable ancestor. * Notes passes `.notes__main`, Journal passes its own scroll host. */ @@ -71,6 +86,7 @@ export type BlockGutterPluginProps = { export function BlockGutterPlugin({ commands, + insertCommands, scrollContainerSelector, documentId, }: BlockGutterPluginProps = {}) { @@ -81,6 +97,10 @@ export function BlockGutterPlugin({ const [drag, setDrag] = useState(null); const frameRef = useRef(null); const dragActiveRef = useRef(false); + // While the Insert menu is open the gutter is pinned to its block: the + // picker anchors to the `+` button, so letting hover move the button to + // another row (or unmount it) would drag the picker along / orphan it. + const insertOpenRef = useRef(false); // Mirror of `hover` for listeners (scroll/resize) whose closure would // otherwise capture a stale value — they reposition the *currently* // hovered block, so they need its live key. @@ -123,7 +143,7 @@ export function BlockGutterPlugin({ } function onMouseMove(event: MouseEvent) { - if (dragActiveRef.current) return; + if (dragActiveRef.current || insertOpenRef.current) return; if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); frameRef.current = requestAnimationFrame(() => { setHover(compute(event)); @@ -147,7 +167,7 @@ export function BlockGutterPlugin({ } function onMouseLeave(event: MouseEvent) { - if (dragActiveRef.current) return; + if (dragActiveRef.current || insertOpenRef.current) return; // The gutter is portaled to (fixed overlay), so it is NOT a // DOM descendant of `.notes__main`. Moving the pointer from a block // onto the gutter button therefore fires `mouseleave` on main — @@ -180,16 +200,62 @@ export function BlockGutterPlugin({ }; }, [editor, scrollContainerSelector]); - const addBelow = useCallback(() => { - if (!hover) return; - editor.update(() => { - const node = $getNodeByKey(hover.key); - if (!node) return; - const paragraph = $createParagraphNode(); - node.insertAfter(paragraph); - paragraph.selectStart(); - }); - }, [editor, hover]); + const addBelow = useCallback( + (event: React.MouseEvent) => { + if (!hover) return; + if (!insertCommands || insertCommands.length === 0) { + insertEmptyBlock(editor, hover.key, InsertPosition.Below); + return; + } + insertOpenRef.current = true; + openInsertMenu({ + editor, + blockKey: hover.key, + position: InsertPosition.Below, + commands: insertCommands, + t, + anchor: event.currentTarget, + ...(documentId ? { documentId } : {}), + onClose: () => { + insertOpenRef.current = false; + }, + }); + }, + [editor, hover, insertCommands, t, documentId], + ); + + useEditorShortcut( + INSERT_MENU_CHORDS, + useCallback( + (event: KeyboardEvent) => { + if (!insertCommands || insertCommands.length === 0 || !editor.isEditable()) return; + const root = editor.getRootElement(); + const active = document.activeElement; + const selected = selectionStore.getSnapshot().selectedKeys; + const focused = root !== null && active !== null && root.contains(active); + if (!focused && selected.size === 0) return; + const blockKey = + selected.size > 0 + ? pickInsertTarget(editor, selected, InsertPosition.Below) + : caretBlockKey(editor); + if (!blockKey) return; + event.preventDefault(); + event.stopPropagation(); + const rect = editor.getElementByKey(blockKey)?.getBoundingClientRect(); + openInsertMenu({ + editor, + blockKey, + position: InsertPosition.Below, + commands: insertCommands, + t, + ...(rect ? { rect } : {}), + ...(documentId ? { documentId } : {}), + }); + }, + [editor, insertCommands, selectionStore, t, documentId], + ), + { enabled: insertCommands !== undefined && insertCommands.length > 0 }, + ); const openMenu = useCallback( (event: React.MouseEvent) => { @@ -306,6 +372,9 @@ export function BlockGutterPlugin({ type="button" className="bs-editor__block-gutter-btn bs-editor__block-gutter-btn--add" onClick={addBelow} + {...(insertCommands && insertCommands.length > 0 + ? { "aria-haspopup": "dialog" as const } + : {})} aria-label={t("editor.gutter.addBelow")} title={t("editor.gutter.addBelow")} > @@ -339,6 +408,20 @@ export function BlockGutterPlugin({ ); } +function caretBlockKey(editor: LexicalEditor): NodeKey | null { + let key: NodeKey | null = null; + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + try { + key = selection.anchor.getNode().getTopLevelElementOrThrow().getKey(); + } catch { + key = null; + } + }); + return key; +} + function findScrollableAncestor(el: HTMLElement): HTMLElement | null { let cur: HTMLElement | null = el.parentElement; while (cur) { diff --git a/packages/editor/src/plugins/insert-menu.test.tsx b/packages/editor/src/plugins/insert-menu.test.tsx new file mode 100644 index 00000000..557ee70d --- /dev/null +++ b/packages/editor/src/plugins/insert-menu.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom +import { + $createListItemNode, + $createListNode, + $isListNode, + ListItemNode, + ListNode, +} from "@lexical/list"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isParagraphNode, + $isRangeSelection, + type LexicalEditor, + type NodeKey, + createEditor as createLexicalEditor, +} from "lexical"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type BlockCommand, CommandCategory } from "../block-command"; +import { createEditorT } from "../i18n"; + +const picker = vi.hoisted(() => ({ + open: vi.fn<(options: Record) => boolean>(() => true), +})); + +vi.mock("@brainstorm-os/sdk/menus", () => ({ + openSearchPicker: (options: Record) => picker.open(options), +})); + +import { + InsertPosition, + createInsertBlockActions, + insertBlockWithCommand, + insertEmptyBlock, + insertMenuRows, + openInsertMenu, + pickInsertTarget, +} from "./insert-menu"; + +const t = createEditorT(); + +function createEditor(): LexicalEditor { + return createLexicalEditor({ + nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode], + onError(error) { + throw error; + }, + }); +} + +function seed(editor: LexicalEditor, texts: readonly string[]): NodeKey[] { + const keys: NodeKey[] = []; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + for (const text of texts) { + const p = $createParagraphNode(); + p.append($createTextNode(text)); + root.append(p); + keys.push(p.getKey()); + } + }, + { discrete: true }, + ); + return keys; +} + +function texts(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => + $getRoot() + .getChildren() + .map((c) => c.getTextContent()), + ); +} + +function command(id: string, label: string, run: BlockCommand["run"] = () => {}): BlockCommand { + return { id, category: CommandCategory.Basic, label, icon: null, keywords: [id], run }; +} + +const CATALOGUE: readonly BlockCommand[] = [ + command("block.paragraph", "Text"), + command("block.heading1", "Heading 1"), + command("block.quote", "Quote"), +]; + +beforeEach(() => { + picker.open.mockReset(); + picker.open.mockImplementation(() => true); +}); + +describe("insertEmptyBlock", () => { + it("places an empty paragraph below and parks the caret in it", () => { + const editor = createEditor(); + const [a] = seed(editor, ["alpha", "bravo"]); + const inserted = insertEmptyBlock(editor, a as NodeKey, InsertPosition.Below); + expect(texts(editor)).toEqual(["alpha", "", "bravo"]); + editor.getEditorState().read(() => { + const sel = $getSelection(); + expect($isRangeSelection(sel) && sel.anchor.getNode().getKey()).toBe(inserted); + }); + }); + + it("places it above", () => { + const editor = createEditor(); + const [, b] = seed(editor, ["alpha", "bravo"]); + insertEmptyBlock(editor, b as NodeKey, InsertPosition.Above); + expect(texts(editor)).toEqual(["alpha", "", "bravo"]); + }); + + it("never nests a paragraph inside a list when inserting above a list item", () => { + const editor = createEditor(); + const itemKeys: NodeKey[] = []; + editor.update( + () => { + const list = $createListNode("bullet"); + for (const text of ["one", "two"]) { + const item = $createListItemNode(); + item.append($createTextNode(text)); + list.append(item); + itemKeys.push(item.getKey()); + } + $getRoot().clear().append(list); + }, + { discrete: true }, + ); + insertEmptyBlock(editor, itemKeys[0] as NodeKey, InsertPosition.Above); + editor.getEditorState().read(() => { + const [first, second] = $getRoot().getChildren(); + expect($isParagraphNode(first)).toBe(true); + expect($isListNode(second)).toBe(true); + }); + insertEmptyBlock(editor, itemKeys[1] as NodeKey, InsertPosition.Above); + editor.getEditorState().read(() => { + const kinds = $getRoot() + .getChildren() + .map((c) => c.getType()); + expect(kinds).toEqual(["paragraph", "list", "paragraph", "list"]); + }); + }); + + it("returns null for a block that is gone", () => { + const editor = createEditor(); + seed(editor, ["alpha"]); + expect(insertEmptyBlock(editor, "missing", InsertPosition.Below)).toBeNull(); + }); +}); + +describe("insertMenuRows", () => { + it("lists the whole catalogue for an empty query and ranks a query", () => { + expect(insertMenuRows(CATALOGUE, "", "none").map((r) => r.id)).toEqual([ + "block.paragraph", + "block.heading1", + "block.quote", + ]); + expect(insertMenuRows(CATALOGUE, "quo", "none").map((r) => r.id)).toEqual(["block.quote"]); + }); + + it("returns one disabled empty-state row when nothing matches", () => { + const rows = insertMenuRows(CATALOGUE, "zzz", "No matching blocks"); + expect(rows).toEqual([{ id: "insert:empty", label: "No matching blocks", disabled: true }]); + }); +}); + +describe("openInsertMenu", () => { + it("opens the shared search picker anchored to the trigger element", () => { + const editor = createEditor(); + const [a] = seed(editor, ["alpha"]); + const anchor = document.createElement("button"); + openInsertMenu({ + editor, + blockKey: a as NodeKey, + position: InsertPosition.Below, + commands: CATALOGUE, + t, + anchor, + }); + const options = picker.open.mock.calls[0]?.[0] as { anchor: Element; ariaLabel: string }; + expect(options.anchor).toBe(anchor); + expect(options.ariaLabel).toBe("Insert a block"); + expect(texts(editor)).toEqual(["alpha"]); + }); + + it("inserts the chosen block below only after a choice", async () => { + const editor = createEditor(); + const [a] = seed(editor, ["alpha", "bravo"]); + const run = vi.fn(); + const commands = [command("block.x", "X", run)]; + openInsertMenu({ + editor, + blockKey: a as NodeKey, + position: InsertPosition.Below, + commands, + t, + }); + const options = picker.open.mock.calls[0]?.[0] as { onSelect: (id: string) => void }; + options.onSelect("block.x"); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(texts(editor)).toEqual(["alpha", "", "bravo"]); + expect(run.mock.calls[0]?.[0]).toMatchObject({ editor }); + }); + + it("falls back to a plain paragraph when no menu host is mounted", () => { + picker.open.mockImplementation(() => false); + const editor = createEditor(); + const [a] = seed(editor, ["alpha"]); + const onClose = vi.fn(); + openInsertMenu({ + editor, + blockKey: a as NodeKey, + position: InsertPosition.Below, + commands: CATALOGUE, + t, + onClose, + }); + expect(texts(editor)).toEqual(["alpha", ""]); + expect(onClose).toHaveBeenCalled(); + }); +}); + +describe("insertBlockWithCommand", () => { + it("passes the document id to the command", async () => { + const editor = createEditor(); + const [a] = seed(editor, ["alpha"]); + const run = vi.fn(); + insertBlockWithCommand( + editor, + a as NodeKey, + InsertPosition.Above, + command("c", "C", run), + "doc-1", + ); + await vi.waitFor(() => expect(run).toHaveBeenCalled()); + expect(run.mock.calls[0]?.[0]).toMatchObject({ documentId: "doc-1" }); + expect(texts(editor)).toEqual(["", "alpha"]); + }); +}); + +describe("createInsertBlockActions", () => { + it("targets the first block for Above and the last for Below", () => { + const editor = createEditor(); + const [a, b, c] = seed(editor, ["alpha", "bravo", "charlie"]); + const keys = new Set([c as NodeKey, a as NodeKey]); + expect(pickInsertTarget(editor, keys, InsertPosition.Above)).toBe(a); + expect(pickInsertTarget(editor, keys, InsertPosition.Below)).toBe(c); + expect(pickInsertTarget(editor, new Set([b as NodeKey]), InsertPosition.Below)).toBe(b); + }); + + it("exposes Insert above / below rows that open the picker", async () => { + const editor = createEditor(); + const [a] = seed(editor, ["alpha"]); + const actions = createInsertBlockActions(t, CATALOGUE); + expect(actions.map((x) => [x.id, x.label, x.category])).toEqual([ + ["block.insert.above", "Insert above…", CommandCategory.Insert], + ["block.insert.below", "Insert below…", CommandCategory.Insert], + ]); + actions[1]?.run({ editor, blockKeys: new Set([a as NodeKey]) }); + await vi.waitFor(() => expect(picker.open).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/packages/editor/src/plugins/insert-menu.tsx b/packages/editor/src/plugins/insert-menu.tsx new file mode 100644 index 00000000..48cfdb52 --- /dev/null +++ b/packages/editor/src/plugins/insert-menu.tsx @@ -0,0 +1,203 @@ +/** + * The block Insert menu (B11.21) — the gutter `+`, the block action menu's + * "Insert above / below…" rows and the `Mod+Shift+Enter` chord all open the + * SAME slash catalogue as a filterable picker through the shared search + * picker (`@brainstorm-os/sdk/menus`), and the chosen command runs against a + * fresh paragraph placed next to the target block. + * + * The paragraph is only created once a command is chosen: dismissing the + * picker leaves the document untouched (no stray empty block to clean up). + * Every slash command is written to act on "the caret's empty paragraph" + * (that is what the slash menu hands it after clearing `/query`), so placing + * the caret in a new empty paragraph and running the command reuses the + * catalogue verbatim — no insert-specific variant per block type. + */ + +import { type SearchPickerItem, openSearchPicker } from "@brainstorm-os/sdk/menus"; +import { $isListItemNode } from "@lexical/list"; +import { + $createParagraphNode, + $getNodeByKey, + $getRoot, + type LexicalEditor, + type NodeKey, +} from "lexical"; +import { type BlockCommand, CommandCategory } from "../block-command"; +import type { EditorT } from "../i18n"; +import { InsertAboveIcon, InsertBelowIcon } from "../icons"; +import { buildSlashMenuView } from "../slash-sections"; +import { getAllBlocks } from "../top-level-block"; + +export enum InsertPosition { + Above = "above", + Below = "below", +} + +/** Opens the insert picker below the caret's (or the last selected) block. */ +export const INSERT_MENU_CHORDS: readonly string[] = ["Mod+Shift+Enter"]; + +const EMPTY_ROW_ID = "insert:empty"; + +/** Rows for the picker: browse order (slash sections) for an empty query, + * relevance-ranked otherwise; a single disabled row when nothing matches. */ +export function insertMenuRows( + commands: readonly BlockCommand[], + query: string, + emptyLabel: string, +): readonly SearchPickerItem[] { + const matches = buildSlashMenuView(commands, query).commands; + if (matches.length === 0) return [{ id: EMPTY_ROW_ID, label: emptyLabel, disabled: true }]; + return matches.map((command) => ({ + id: command.id, + label: command.label, + icon: command.icon, + ...(command.description ? { caption: command.description } : {}), + })); +} + +/** Insert an empty paragraph next to `blockKey` and park the caret in it. + * Returns the new paragraph's key, or `null` when the block is gone. */ +export function insertEmptyBlock( + editor: LexicalEditor, + blockKey: NodeKey, + position: InsertPosition, +): NodeKey | null { + let inserted: NodeKey | null = null; + editor.update( + () => { + const node = $getNodeByKey(blockKey); + if (!node) return; + const paragraph = $createParagraphNode(); + if (position === InsertPosition.Below) { + node.insertAfter(paragraph); + } else if ($isListItemNode(node)) { + // A paragraph can't sit inside a list: above the first item goes + // before the whole list, otherwise it splits the list at the item + // above (`ListItemNode.insertAfter` does the split). + const previous = node.getPreviousSibling(); + if (previous) previous.insertAfter(paragraph); + else node.getParentOrThrow().insertBefore(paragraph); + } else { + node.insertBefore(paragraph); + } + paragraph.selectStart(); + inserted = paragraph.getKey(); + }, + { discrete: true }, + ); + return inserted; +} + +/** Insert a paragraph next to `blockKey`, then run `command` against it as a + * fresh top-level update (the same deferral the slash menu uses, so the + * command reads a settled selection). */ +export function insertBlockWithCommand( + editor: LexicalEditor, + blockKey: NodeKey, + position: InsertPosition, + command: BlockCommand, + documentId?: string, +): boolean { + if (insertEmptyBlock(editor, blockKey, position) === null) return false; + editor.getRootElement()?.focus({ preventScroll: true }); + queueMicrotask(() => { + void Promise.resolve(command.run({ editor, ...(documentId ? { documentId } : {}) })).catch( + () => undefined, + ); + }); + return true; +} + +export type OpenInsertMenuOptions = { + editor: LexicalEditor; + blockKey: NodeKey; + position: InsertPosition; + commands: readonly BlockCommand[]; + t: EditorT; + /** The trigger the picker drops from (tracks + flips off its live rect). */ + anchor?: Element; + /** A viewport rect for a trigger-less open (action-menu row, chord). */ + rect?: DOMRect; + documentId?: string; + onClose?: () => void; +}; + +/** Open the insert picker. Without a mounted menu host it degrades to the + * pre-B11.21 behaviour — a plain empty paragraph — so `+` is never dead. */ +export function openInsertMenu(options: OpenInsertMenuOptions): boolean { + const { editor, blockKey, position, commands, t, anchor, rect, documentId, onClose } = options; + const emptyLabel = t("editor.insertMenu.empty"); + const opened = openSearchPicker({ + placeholder: t("editor.insertMenu.placeholder"), + ariaLabel: t("editor.insertMenu.region"), + filter: (query) => insertMenuRows(commands, query, emptyLabel), + onSelect: (id) => { + const command = commands.find((c) => c.id === id); + if (!command) return; + // After the picker has closed and handed focus back. + setTimeout(() => insertBlockWithCommand(editor, blockKey, position, command, documentId), 0); + }, + ...(onClose ? { onClose } : {}), + ...(rect ? { rect } : anchor ? { anchor } : {}), + }); + if (!opened) { + insertEmptyBlock(editor, blockKey, position); + onClose?.(); + } + return opened; +} + +/** The target block for an insert relative to a block set: the first block + * (document order) for Above, the last for Below. */ +export function pickInsertTarget( + editor: LexicalEditor, + keys: ReadonlySet, + position: InsertPosition, +): NodeKey | null { + let target: NodeKey | null = null; + editor.getEditorState().read(() => { + const ordered = getAllBlocks($getRoot()) + .map((block) => block.getKey()) + .filter((key) => keys.has(key)); + const first = keys.values().next(); + const fallback = first.done ? null : first.value; + target = + (position === InsertPosition.Above ? ordered[0] : ordered[ordered.length - 1]) ?? fallback; + }); + return target; +} + +/** "Insert above… / Insert below…" rows for the block action menu, opening + * the insert picker over `catalogue` next to the targeted block(s). */ +export function createInsertBlockActions( + t: EditorT, + catalogue: readonly BlockCommand[], +): readonly BlockCommand[] { + const action = (position: InsertPosition): BlockCommand => ({ + id: `block.insert.${position}`, + category: CommandCategory.Insert, + label: t(position === InsertPosition.Above ? "editor.insert.above" : "editor.insert.below"), + icon: position === InsertPosition.Above ? : , + keywords: ["insert", "add", "new", "block", position], + run: ({ editor, blockKeys, documentId }) => { + if (!blockKeys || blockKeys.size === 0) return; + const blockKey = pickInsertTarget(editor, blockKeys, position); + if (!blockKey) return; + const rect = editor.getElementByKey(blockKey)?.getBoundingClientRect(); + // The action menu is still closing; open on the next task so the two + // menus don't fight over the runtime's stack / focus restore. + setTimeout(() => { + openInsertMenu({ + editor, + blockKey, + position, + commands: catalogue, + t, + ...(rect ? { rect } : {}), + ...(documentId ? { documentId } : {}), + }); + }, 0); + }, + }); + return [action(InsertPosition.Above), action(InsertPosition.Below)]; +} diff --git a/packages/editor/src/plugins/standard-editing-plugins.tsx b/packages/editor/src/plugins/standard-editing-plugins.tsx index 782b1e29..deeb29e3 100644 --- a/packages/editor/src/plugins/standard-editing-plugins.tsx +++ b/packages/editor/src/plugins/standard-editing-plugins.tsx @@ -38,6 +38,7 @@ import { ColumnsPlugin } from "./columns-plugin"; import { FormatChordsPlugin } from "./format-chords-plugin"; import { InitialFocusPlugin } from "./initial-focus-plugin"; import { InlineToolbarPlugin } from "./inline-toolbar-plugin"; +import { createInsertBlockActions } from "./insert-menu"; import { ListMarkdownShortcutsPlugin } from "./list-markdown-shortcuts-plugin"; import { SlashMenuPlugin } from "./slash-menu-plugin"; import { TablesPlugin } from "./table-plugin"; @@ -89,7 +90,10 @@ export function StandardEditingPlugins({ const base = orderCommandsByPalette(createStandardBlockCommands(t), palette); return extraCommands && extraCommands.length > 0 ? [...base, ...extraCommands] : base; }, [t, extraCommands, palette]); - const actions = useMemo(() => createStandardBlockActions(t), [t]); + const actions = useMemo( + () => [...createInsertBlockActions(t, commands), ...createStandardBlockActions(t)], + [t, commands], + ); return ( @@ -105,6 +109,7 @@ export function StandardEditingPlugins({ {autoFocus ? : null} From c4865d78451297749416e46c6da57c27b844d772 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Sun, 18 Oct 2026 15:52:50 +0200 Subject: [PATCH 002/142] =?UTF-8?q?feat(notes):=20edit=20a=20transclusion?= =?UTF-8?q?=20in=20place=20=E2=80=94=20explicit=20Edit=20here=20mode=20(B1?= =?UTF-8?q?1.22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transcluded body was a read-only mount of the source's Y.Doc. It now carries an Edit here / Done toggle: in edit mode the nested editor is editable and mounts the Notes editing plugins (slash menu, toggles, callouts, embeds, mentions, references, link markup, inline toolbar), so edits land in the SOURCE's doc behind an accent boundary, with the source card as its chip. The source's denormalised title/snippet/refs follow through the same lock-gated update funnel the open note uses. Read-only stays the answer when the host page is read-only, the source isn't loaded in Notes, or the source is locked (disabled toggle with a hint; a lock landing mid-edit drops back live). Undo is the source's own UndoManager. Document-scoped editor chords now ignore keydowns from a nested Lexical root so a host plugin never acts on its stale selection while typing in the nested editor. Co-Authored-By: Claude Opus 5.5 --- apps/notes/src/app.tsx | 54 +++-- apps/notes/src/editor/editor.tsx | 211 +++++++++-------- apps/notes/src/editor/transclusion-body.tsx | 215 +++++++++++++++--- .../src/editor/transclusion-edit-host.tsx | 28 +++ .../src/editor/transclusion-edit.test.tsx | 186 +++++++++++++++ apps/notes/src/i18n/de.json | 8 +- apps/notes/src/i18n/en.json | 8 +- apps/notes/src/i18n/es.json | 8 +- apps/notes/src/i18n/fr.json | 8 +- apps/notes/src/i18n/it.json | 8 +- apps/notes/src/i18n/pt.json | 8 +- apps/notes/src/styles.css | 28 +++ packages/editor/src/index.ts | 2 +- .../editor/src/plugins/editor-shortcut.ts | 26 ++- .../plugins/transclusion-render-context.tsx | 11 +- 15 files changed, 652 insertions(+), 157 deletions(-) create mode 100644 apps/notes/src/editor/transclusion-edit-host.tsx create mode 100644 apps/notes/src/editor/transclusion-edit.test.tsx diff --git a/apps/notes/src/app.tsx b/apps/notes/src/app.tsx index 305f55dc..521fb9d8 100644 --- a/apps/notes/src/app.tsx +++ b/apps/notes/src/app.tsx @@ -58,6 +58,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Editor } from "./editor/editor"; import type { InsertAtEndRequest } from "./editor/insert-at-end-plugin"; import { queuePendingEntityLinks } from "./editor/pending-link"; +import type { TransclusionEditHost } from "./editor/transclusion-edit-host"; import { useNotesT } from "./i18n-hooks"; import { t } from "./i18n/t"; import { ActionId } from "./keyboard/action-ids"; @@ -97,6 +98,26 @@ const PROPS_PREF_KEY = "notes.propsOpen"; /** Cheap order-insensitive equality for the autosave's body-ref diff, so a * keystroke that doesn't change the set of cross-references doesn't re-persist * `bodyRefs`. Compares `kind:entityId` keys. */ +/** The denormalised mirrors (`title`, `body` snippet, `bodyRefs`) a body edit + * changes, or `null` when none moved. Shared by the open note's autosave and + * an in-place transclusion edit of another note (B11.22). */ +function bodyDenormalizationPatch( + note: StoredNote, + body: SerializedEditorState, +): Partial | null { + const { title, snippet } = denormalizeBody(body); + const refs = extractNoteReferences(body); + const titleChanged = title !== note.title; + const snippetChanged = snippet !== note.body; + const refsChanged = !noteRefsEqual(refs, note.bodyRefs); + if (!titleChanged && !snippetChanged && !refsChanged) return null; + const patch: Partial = {}; + if (titleChanged) patch.title = title; + if (snippetChanged) patch.body = snippet; + if (refsChanged) patch.bodyRefs = refs; + return patch; +} + function noteRefsEqual( a: readonly NoteReference[], b: readonly NoteReference[] | undefined, @@ -694,24 +715,30 @@ export function NotesApp() { // AutosavePlugin gates this on real user interaction so the // mount-settle echo doesn't re-fire (the [[project_notes_ // autosave_swallows_first_edit]] invariant). - const { title, snippet } = denormalizeBody(body); - // Extract body cross-refs from the live editor state and persist them - // so the shell can project note→note graph edges — the snippet has no - // rich nodes to walk (F-067). - const refs = extractNoteReferences(body); - const titleChanged = title !== note.title; - const snippetChanged = snippet !== note.body; - const refsChanged = !noteRefsEqual(refs, note.bodyRefs); - if (!titleChanged && !snippetChanged && !refsChanged) return; - const patch: Partial = {}; - if (titleChanged) patch.title = title; - if (snippetChanged) patch.body = snippet; - if (refsChanged) patch.bodyRefs = refs; + const patch = bodyDenormalizationPatch(note, body); + if (!patch) return; update(note.id, patch); }, [note, update], ); + // B11.22 — an in-place transclusion edit writes the SOURCE's Y.Doc directly; + // this keeps the source's denormalised mirrors in step, through the same + // lock-gated `update` funnel the open note uses. + const transclusionHost = useMemo( + () => ({ + canEdit: (id) => notes.has(id), + isLocked: (id) => notes.get(id)?.locked ?? false, + onSourceChange: (id, body) => { + const source = notes.get(id); + if (!source) return; + const patch = bodyDenormalizationPatch(source, body); + if (patch) update(id, patch); + }, + }), + [notes, update], + ); + // F-241 — insert applied (or failed) in the editor. The append is a // programmatic Y.Doc write, so the autosave's real-interaction gate won't // re-derive the sidebar snippet / bodyRefs; refresh them here from the @@ -1123,6 +1150,7 @@ export function NotesApp() { insertRequest && insertRequest.noteId === note.id && !locked ? insertRequest : null } onInsertDone={onInsertDone} + transclusionHost={transclusionHost} /> )} diff --git a/apps/notes/src/editor/editor.tsx b/apps/notes/src/editor/editor.tsx index 53c7f023..198f4221 100644 --- a/apps/notes/src/editor/editor.tsx +++ b/apps/notes/src/editor/editor.tsx @@ -121,6 +121,7 @@ import { makeNoteBootstrap } from "./seed-title"; import { editorTheme } from "./theme"; import { TOGGLE_HEADING_TRANSFORMER } from "./toggle-heading-transformer"; import { renderTransclusionBody } from "./transclusion-body"; +import { type TransclusionEditHost, TransclusionEditProvider } from "./transclusion-edit-host"; import { TransclusionRenderProvider } from "./transclusion-render-context"; import { UNICODE_SHORTCUT_TRANSFORMERS } from "./typing-shortcuts"; @@ -167,6 +168,9 @@ export type EditorProps = { * snippet/refs). Only ever passed for the open, unlocked target note. */ insertRequest?: InsertAtEndRequest | null; onInsertDone?: (applied: boolean) => void; + /** Lets a transcluded body be edited in place (B11.22). Omit ⇒ every + * transclusion in this document stays read-only. */ + transclusionHost?: TransclusionEditHost | null; }; export function Editor({ @@ -182,6 +186,7 @@ export function Editor({ onAnchorDone, insertRequest = null, onInsertDone, + transclusionHost = null, }: EditorProps) { const doc = useYDoc(noteId); const whenLoaded = useYDocLoaded(noteId); @@ -216,116 +221,122 @@ export function Editor({ [doc], ); return ( - - + { - console.error("[notes/editor]", error); - }} > - {/* Forward the WHOLE context object — cherry-picking fields here is + { + console.error("[notes/editor]", error); + }} + > + {/* Forward the WHOLE context object — cherry-picking fields here is what silently dropped the comment wiring (F-163): the app passed onCommentSelection/commentedBlockIds/onCommentBlockClick and this mount forwarded only values+setValue, so the toolbar Comment row, the block highlight and the click-to-thread chip never reached a real shell. */} - - - - - - {onRecoverBlank && ( - + + + + - )} - - - - - - - - - - - - - - - - - - - {onInsertDone && ( - + )} + + + + + + + + + + + + + + + + + + - )} - - - - - - - - - - - - - - {editorHandleRef && } - - - - - - - - - {/* Unconditional mount — rationale in dev-bench-plugin.tsx header. */} - - - - - + {onInsertDone && ( + + )} + + + + + + + + + + + + + + {editorHandleRef && } + + + + + + + + + {/* Unconditional mount — rationale in dev-bench-plugin.tsx header. */} + + + + + + ); } diff --git a/apps/notes/src/editor/transclusion-body.tsx b/apps/notes/src/editor/transclusion-body.tsx index 4170fa90..e844e7b6 100644 --- a/apps/notes/src/editor/transclusion-body.tsx +++ b/apps/notes/src/editor/transclusion-body.tsx @@ -1,41 +1,135 @@ /** - * Nested read-only transclusion body (B6.4b render-half). + * Nested transclusion body (B6.4b render-half, B11.22 edit-in-place). * - * Mounts a real, non-editable `` bound to the target - * entity's replica Y.Doc (resolved through `useYDoc(entityId)`), so the - * transcluded content paints with full fidelity — mentions, embeds, images, - * tables and *nested* transclusions all render through the same node set the - * primary editor uses. The Lexical-free `EditorPreview` was the alternative; - * a live mount is what lets a transcluded sub-tree's own decorator nodes work - * (and the cycle/depth guard already bounds the recursion). + * Mounts a real `` bound to the target entity's replica + * Y.Doc (resolved through `useYDoc(entityId)`), so the transcluded content + * paints with full fidelity — mentions, embeds, images, tables and *nested* + * transclusions all render through the same node set the primary editor uses + * (the cycle/depth guard bounds the recursion). + * + * Read-only by default. "Edit here" switches the body into an explicit edit + * mode: the nested editor becomes editable and mounts the Notes editing + * plugins, so every keystroke writes straight into the SOURCE's Y.Doc (the + * same doc the source's own editor binds), with a visible boundary + the + * source card as its chip. Guarantees: + * - a locked source, a source Notes hasn't loaded, or a read-only host keeps + * the body read-only (the toggle is disabled with a hint, never hidden); + * a lock that lands mid-edit drops the body back to read-only live; + * - undo is the SOURCE's: the nested `CollaborationPlugin` owns its own + * Yjs `UndoManager` over the source doc, and Lexical marks a nested + * editor's key events handled so the host never also undoes; + * - editing a source from within itself can't happen: such a body is + * cycle-elided before it mounts (`decideTransclusionRender`); + * - the source's denormalised title / snippet / refs follow the edit + * through the host's `onSourceChange` (the open note's autosave path). * * `renderTransclusionBody` is injected into `TransclusionRenderContext` by the * primary editor and re-injected here, so a `TransclusionNode` inside the * mounted body resolves the SAME renderer — without `transclusion-node.tsx` * importing this heavy module (which would cycle through * `NOTES_ADDITIONAL_NODES`). - * - * The provider here wraps the nested editor (it must be an ANCESTOR of the - * `` to reach its decorators — see - * `transclusion-render-context.tsx`) and extends the ancestor chain by this - * target's id, so a deeper transclusion of an ancestor collapses via - * `decideTransclusionRender` instead of recursing forever. */ -import { BrainstormEditor, EditablePlugin } from "@brainstorm-os/editor"; +import { + AutosavePlugin, + BLOCK_MARKDOWN_TRANSFORMERS, + BlockEmbedPickerPlugin, + BlockSelectionPlugin, + BrainstormEditor, + CodeBlockPlugin, + ColumnsPlugin, + EditablePlugin, + EmbedPlugin, + InlineToolbarPlugin, + ListMarkdownShortcutsPlugin, + MentionTypeaheadPlugin, + SlashMenuPlugin, + TablesPlugin, + TitlePlugin, + TogglePlugin, + TransclusionTypeaheadPlugin, + TurnIntoPlugin, + useTransclusionRender, +} from "@brainstorm-os/editor"; import { useUniversalBody, useYDoc, useYDocApplyPending, useYDocLoaded, } from "@brainstorm-os/react-yjs"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import type { SerializedEditorState } from "lexical"; +import { useCallback, useMemo, useState } from "react"; +import { t } from "../i18n/t"; +import { BLOCK_COMMANDS } from "./commands"; +import { LinkMarkupPlugin } from "./link-markup-plugin"; import { NOTES_ADDITIONAL_NODES } from "./notes-nodes"; import { editorTheme } from "./theme"; +import { useTransclusionEditHost } from "./transclusion-edit-host"; import { type TransclusionBodyRenderer, TransclusionRenderProvider, } from "./transclusion-render-context"; +const EDIT_TRANSFORMERS = [...BLOCK_MARKDOWN_TRANSFORMERS, ...TRANSFORMERS]; + +export enum TransclusionEditState { + /** Host read-only or the source isn't held by Notes — no toggle at all. */ + Unavailable = "unavailable", + /** Editable in principle, but the source is locked. */ + Locked = "locked", + ReadOnly = "read-only", + Editing = "editing", +} + +/** Pure decision for the body's edit state (unit-tested). */ +export function resolveTransclusionEditState(args: { + hostEditable: boolean; + canEdit: boolean; + locked: boolean; + requested: boolean; +}): TransclusionEditState { + if (!args.hostEditable || !args.canEdit) return TransclusionEditState.Unavailable; + if (args.locked) return TransclusionEditState.Locked; + return args.requested ? TransclusionEditState.Editing : TransclusionEditState.ReadOnly; +} + +function TransclusionEditingPlugins({ + entityId, + onChange, +}: { + entityId: string; + onChange: (state: SerializedEditorState) => void; +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + function TransclusionBodyMount({ entityId, chain, @@ -47,28 +141,81 @@ function TransclusionBodyMount({ const whenLoaded = useYDocLoaded(entityId); const applyPending = useYDocApplyPending(entityId); useUniversalBody(doc); + const { editable: hostEditable = false } = useTransclusionRender(); + const host = useTransclusionEditHost(); + const [requested, setRequested] = useState(false); + const state = resolveTransclusionEditState({ + hostEditable, + canEdit: host?.canEdit(entityId) ?? false, + locked: host?.isLocked(entityId) ?? false, + requested, + }); + const editing = state === TransclusionEditState.Editing; + const nestedChain = useMemo(() => [...chain, entityId], [chain, entityId]); + const onSourceChange = useCallback( + (next: SerializedEditorState) => host?.onSourceChange(entityId, next), + [host, entityId], + ); + const toggleLabel = editing + ? t("notes.transclusion.doneEditing") + : t("notes.transclusion.editHere"); + const toggleHint = + state === TransclusionEditState.Locked + ? t("notes.transclusion.lockedHint") + : editing + ? t("notes.transclusion.doneHint") + : t("notes.transclusion.editHint"); return ( - - { - console.error("[notes/transclusion-body]", error); - }} + {state !== TransclusionEditState.Unavailable && ( +
+ {editing && ( + {t("notes.transclusion.editingBadge")} + )} + +
+ )} + - -
-
+ { + console.error("[notes/transclusion-body]", error); + }} + > + + {editing && } + + + ); } diff --git a/apps/notes/src/editor/transclusion-edit-host.tsx b/apps/notes/src/editor/transclusion-edit-host.tsx new file mode 100644 index 00000000..607ab364 --- /dev/null +++ b/apps/notes/src/editor/transclusion-edit-host.tsx @@ -0,0 +1,28 @@ +/** + * What a transclusion body needs from the Notes app to be edited in place + * (B11.22): whether Notes holds the source (only an object Notes has loaded + * can have its body edited here, so its denormalised title / snippet / refs + * can follow), whether the source is read-only-locked, and where a body edit + * lands for denormalisation. + * + * Provided ABOVE `` (decorators only see providers above the + * composer — see `transclusion-render-context.tsx`). Absent ⇒ every + * transclusion stays read-only. + */ + +import type { SerializedEditorState } from "lexical"; +import { createContext, useContext } from "react"; + +export type TransclusionEditHost = { + canEdit: (entityId: string) => boolean; + isLocked: (entityId: string) => boolean; + onSourceChange: (entityId: string, state: SerializedEditorState) => void; +}; + +const TransclusionEditContext = createContext(null); + +export const TransclusionEditProvider = TransclusionEditContext.Provider; + +export function useTransclusionEditHost(): TransclusionEditHost | null { + return useContext(TransclusionEditContext); +} diff --git a/apps/notes/src/editor/transclusion-edit.test.tsx b/apps/notes/src/editor/transclusion-edit.test.tsx new file mode 100644 index 00000000..d16abff2 --- /dev/null +++ b/apps/notes/src/editor/transclusion-edit.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom + +/** + * B11.22 — editing a transclusion in place. The nested body mounts a real + * Yjs-bound `` over the SOURCE's doc; the explicit + * "Edit here" mode flips it editable only when the host is editable, Notes + * holds the source, and the source isn't locked. + */ + +import { isEventInNestedEditor } from "@brainstorm-os/editor"; +import { YDocProvider, createYDocResolver } from "@brainstorm-os/react-yjs"; +import { type ReactNode, act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Doc } from "yjs"; +import { + TransclusionEditState, + renderTransclusionBody, + resolveTransclusionEditState, +} from "./transclusion-body"; +import { type TransclusionEditHost, TransclusionEditProvider } from "./transclusion-edit-host"; +import { TransclusionRenderProvider } from "./transclusion-render-context"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const SOURCE = "n_source"; +const HOST = "n_host"; + +describe("resolveTransclusionEditState", () => { + const base = { hostEditable: true, canEdit: true, locked: false, requested: false }; + it("is unavailable under a read-only host or a source Notes doesn't hold", () => { + expect(resolveTransclusionEditState({ ...base, hostEditable: false })).toBe( + TransclusionEditState.Unavailable, + ); + expect(resolveTransclusionEditState({ ...base, canEdit: false, requested: true })).toBe( + TransclusionEditState.Unavailable, + ); + }); + it("is locked (never editing) when the source is locked, even if requested", () => { + expect(resolveTransclusionEditState({ ...base, locked: true, requested: true })).toBe( + TransclusionEditState.Locked, + ); + }); + it("edits only on request", () => { + expect(resolveTransclusionEditState(base)).toBe(TransclusionEditState.ReadOnly); + expect(resolveTransclusionEditState({ ...base, requested: true })).toBe( + TransclusionEditState.Editing, + ); + }); +}); + +describe("isEventInNestedEditor", () => { + it("flags events from a Lexical root nested inside the host root only", () => { + const host = document.createElement("div"); + host.setAttribute("data-lexical-editor", "true"); + const nested = document.createElement("div"); + nested.setAttribute("data-lexical-editor", "true"); + const text = document.createElement("span"); + nested.append(text); + const own = document.createElement("span"); + host.append(nested, own); + const fire = (target: Element) => { + const event = new KeyboardEvent("keydown"); + Object.defineProperty(event, "target", { value: target }); + return event; + }; + expect(isEventInNestedEditor(fire(text), host)).toBe(true); + expect(isEventInNestedEditor(fire(own), host)).toBe(false); + expect(isEventInNestedEditor(fire(text), nested)).toBe(false); + expect(isEventInNestedEditor(fire(document.body), host)).toBe(false); + expect(isEventInNestedEditor(fire(text), null)).toBe(false); + }); +}); + +describe("transclusion body edit mode", () => { + let container: HTMLDivElement; + let root: Root; + let docs: Map; + let resolver: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + docs = new Map(); + resolver = createYDocResolver({ load: async () => null, persist: () => {}, release: () => {} }); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + resolver.dispose(); + }); + + function host(overrides: Partial = {}): TransclusionEditHost { + return { + canEdit: (id) => id === SOURCE, + isLocked: () => false, + onSourceChange: vi.fn(), + ...overrides, + }; + } + + async function render( + editHost: TransclusionEditHost | null, + hostEditable: boolean, + ): Promise { + const resolve: typeof resolver.resolve = (id) => { + const handle = resolver.resolve(id); + docs.set(id, handle.doc); + return handle; + }; + const tree: ReactNode = ( + + + + {renderTransclusionBody({ entityId: SOURCE, entityType: "", chain: [HOST] })} + + + + ); + await act(async () => root.render(tree)); + await act(async () => { + await Promise.resolve(); + }); + } + + const toggle = () => + container.querySelector(".notes__transclusion-edit-toggle"); + const state = () => + container.querySelector(".notes__transclusion-edit")?.getAttribute("data-edit-state"); + const editableRoot = () => + container.querySelector(".notes__transclusion-body-content")?.getAttribute("contenteditable"); + + it("binds the nested editor to the SOURCE's doc, read-only until asked", async () => { + await render(host(), true); + expect(docs.has(SOURCE)).toBe(true); + expect(docs.has(HOST)).toBe(false); + expect(state()).toBe(TransclusionEditState.ReadOnly); + expect(editableRoot()).toBe("false"); + expect(toggle()?.getAttribute("aria-pressed")).toBe("false"); + }); + + it("'Edit here' makes the body editable with a visible boundary; 'Done' ends it", async () => { + await render(host(), true); + await act(async () => toggle()?.click()); + expect(state()).toBe(TransclusionEditState.Editing); + expect(editableRoot()).toBe("true"); + expect(container.querySelector(".notes__transclusion-edit--active")).not.toBeNull(); + expect(container.querySelector(".notes__transclusion-edit-badge")).not.toBeNull(); + expect(toggle()?.getAttribute("aria-pressed")).toBe("true"); + await act(async () => toggle()?.click()); + expect(editableRoot()).toBe("false"); + }); + + it("keeps a locked source read-only, with a disabled toggle that says why", async () => { + await render(host({ isLocked: () => true }), true); + expect(state()).toBe(TransclusionEditState.Locked); + expect(toggle()?.disabled).toBe(true); + expect(toggle()?.title).toMatch(/locked/i); + expect(editableRoot()).toBe("false"); + }); + + it("drops back to read-only when the source is locked mid-edit", async () => { + let locked = false; + const editHost = host({ isLocked: () => locked }); + await render(editHost, true); + await act(async () => toggle()?.click()); + expect(editableRoot()).toBe("true"); + locked = true; + await render({ ...editHost }, true); + expect(state()).toBe(TransclusionEditState.Locked); + expect(editableRoot()).toBe("false"); + }); + + it("offers no edit mode under a read-only host or without an edit host", async () => { + await render(host(), false); + expect(toggle()).toBeNull(); + expect(editableRoot()).toBe("false"); + await render(null, true); + expect(toggle()).toBeNull(); + }); +}); diff --git a/apps/notes/src/i18n/de.json b/apps/notes/src/i18n/de.json index 0702e915..c41054cf 100644 --- a/apps/notes/src/i18n/de.json +++ b/apps/notes/src/i18n/de.json @@ -402,5 +402,11 @@ "notes.coverPicker.libraryEmpty": "Noch keine Titelbilder hochgeladen.", "notes.coverPicker.focalHint": "Ziehen Sie, um den Fokuspunkt zu wählen.", "notes.coverPicker.useCover": "Titelbild verwenden", - "notes.coverPicker.galleryRegion": "Verlaufs- und Farbtitelbilder" + "notes.coverPicker.galleryRegion": "Verlaufs- und Farbtitelbilder", + "notes.transclusion.editHere": "Hier bearbeiten", + "notes.transclusion.doneEditing": "Fertig", + "notes.transclusion.editHint": "Die Quellseite hier bearbeiten – Änderungen werden dort gespeichert", + "notes.transclusion.doneHint": "Bearbeitung der Quellseite beenden", + "notes.transclusion.lockedHint": "Die Quellseite ist gesperrt – entsperre sie, um hier zu bearbeiten", + "notes.transclusion.editingBadge": "Quellseite wird bearbeitet" } diff --git a/apps/notes/src/i18n/en.json b/apps/notes/src/i18n/en.json index 19b6d170..8961528b 100644 --- a/apps/notes/src/i18n/en.json +++ b/apps/notes/src/i18n/en.json @@ -402,5 +402,11 @@ "notes.coverPicker.libraryEmpty": "No covers uploaded yet.", "notes.coverPicker.focalHint": "Drag to choose the focal point.", "notes.coverPicker.useCover": "Use cover", - "notes.coverPicker.galleryRegion": "Gradient and colour covers" + "notes.coverPicker.galleryRegion": "Gradient and colour covers", + "notes.transclusion.editHere": "Edit here", + "notes.transclusion.doneEditing": "Done", + "notes.transclusion.editHint": "Edit the source page here — changes are saved to it", + "notes.transclusion.doneHint": "Stop editing the source page", + "notes.transclusion.lockedHint": "The source page is locked — unlock it to edit here", + "notes.transclusion.editingBadge": "Editing the source page" } diff --git a/apps/notes/src/i18n/es.json b/apps/notes/src/i18n/es.json index 9dc05e27..d4759689 100644 --- a/apps/notes/src/i18n/es.json +++ b/apps/notes/src/i18n/es.json @@ -402,5 +402,11 @@ "notes.coverPicker.libraryEmpty": "Aún no se han subido portadas.", "notes.coverPicker.focalHint": "Arrastre para elegir el punto focal.", "notes.coverPicker.useCover": "Usar portada", - "notes.coverPicker.galleryRegion": "Portadas de degradados y colores" + "notes.coverPicker.galleryRegion": "Portadas de degradados y colores", + "notes.transclusion.editHere": "Editar aquí", + "notes.transclusion.doneEditing": "Listo", + "notes.transclusion.editHint": "Editar la página de origen aquí: los cambios se guardan en ella", + "notes.transclusion.doneHint": "Dejar de editar la página de origen", + "notes.transclusion.lockedHint": "La página de origen está bloqueada: desbloquéala para editarla aquí", + "notes.transclusion.editingBadge": "Editando la página de origen" } diff --git a/apps/notes/src/i18n/fr.json b/apps/notes/src/i18n/fr.json index b2793d0d..b46ad3d8 100644 --- a/apps/notes/src/i18n/fr.json +++ b/apps/notes/src/i18n/fr.json @@ -402,5 +402,11 @@ "notes.coverPicker.libraryEmpty": "Pas encore de couvertures importées.", "notes.coverPicker.focalHint": "Faites glisser pour choisir le point focal.", "notes.coverPicker.useCover": "Utiliser la couverture", - "notes.coverPicker.galleryRegion": "Couvertures en dégradés et couleurs" + "notes.coverPicker.galleryRegion": "Couvertures en dégradés et couleurs", + "notes.transclusion.editHere": "Modifier ici", + "notes.transclusion.doneEditing": "Terminé", + "notes.transclusion.editHint": "Modifier la page source ici — les modifications y sont enregistrées", + "notes.transclusion.doneHint": "Arrêter de modifier la page source", + "notes.transclusion.lockedHint": "La page source est verrouillée — déverrouillez-la pour la modifier ici", + "notes.transclusion.editingBadge": "Modification de la page source" } diff --git a/apps/notes/src/i18n/it.json b/apps/notes/src/i18n/it.json index 57e78575..a9178a38 100644 --- a/apps/notes/src/i18n/it.json +++ b/apps/notes/src/i18n/it.json @@ -402,5 +402,11 @@ "notes.coverPicker.libraryEmpty": "Ancora nessuna copertina caricata.", "notes.coverPicker.focalHint": "Trascini per scegliere il punto focale.", "notes.coverPicker.useCover": "Usa copertina", - "notes.coverPicker.galleryRegion": "Copertine con gradienti e colori" + "notes.coverPicker.galleryRegion": "Copertine con gradienti e colori", + "notes.transclusion.editHere": "Modifica qui", + "notes.transclusion.doneEditing": "Fatto", + "notes.transclusion.editHint": "Modifica qui la pagina di origine: le modifiche vengono salvate lì", + "notes.transclusion.doneHint": "Interrompi la modifica della pagina di origine", + "notes.transclusion.lockedHint": "La pagina di origine è bloccata: sbloccala per modificarla qui", + "notes.transclusion.editingBadge": "Modifica della pagina di origine" } diff --git a/apps/notes/src/i18n/pt.json b/apps/notes/src/i18n/pt.json index 342b6f86..31e24ad5 100644 --- a/apps/notes/src/i18n/pt.json +++ b/apps/notes/src/i18n/pt.json @@ -402,5 +402,11 @@ "notes.coverPicker.libraryEmpty": "Ainda não há capas enviadas.", "notes.coverPicker.focalHint": "Arraste para escolher o ponto focal.", "notes.coverPicker.useCover": "Usar capa", - "notes.coverPicker.galleryRegion": "Capas com gradientes e cores" + "notes.coverPicker.galleryRegion": "Capas com gradientes e cores", + "notes.transclusion.editHere": "Editar aqui", + "notes.transclusion.doneEditing": "Concluído", + "notes.transclusion.editHint": "Editar a página de origem aqui — as alterações são salvas nela", + "notes.transclusion.doneHint": "Parar de editar a página de origem", + "notes.transclusion.lockedHint": "A página de origem está bloqueada — desbloqueie-a para editar aqui", + "notes.transclusion.editingBadge": "Editando a página de origem" } diff --git a/apps/notes/src/styles.css b/apps/notes/src/styles.css index 03b8da11..0800588f 100644 --- a/apps/notes/src/styles.css +++ b/apps/notes/src/styles.css @@ -788,3 +788,31 @@ body.is-resizing .notes__props-resize { min-width: 0; } + +/* B11.22 — in-place transclusion editing. The toggle row sits between the + source card (the chip naming where the content lives) and the body; while + editing, the body's well gains an accent boundary so it is unmistakable + that keystrokes land in ANOTHER page. */ +.notes__transclusion-edit { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.notes__transclusion-edit-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} + +.notes__transclusion-edit-badge { + font-size: var(--text-size-xs); + color: var(--text-dim); +} + +.notes__transclusion-edit--active .notes__transclusion-body-content { + outline: var(--border-width) solid var(--accent); + outline-offset: var(--space-1); + border-radius: var(--radius-sm); +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index e5a59cba..b4b49449 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -100,7 +100,7 @@ export { type BlockGutterPluginProps, BlockGutterPlugin, } from "./plugins/block-gutter-plugin"; -export { useEditorShortcut } from "./plugins/editor-shortcut"; +export { isEventInNestedEditor, useEditorShortcut } from "./plugins/editor-shortcut"; export { INSERT_MENU_CHORDS, InsertPosition, diff --git a/packages/editor/src/plugins/editor-shortcut.ts b/packages/editor/src/plugins/editor-shortcut.ts index b0d67106..95a6220f 100644 --- a/packages/editor/src/plugins/editor-shortcut.ts +++ b/packages/editor/src/plugins/editor-shortcut.ts @@ -12,12 +12,31 @@ * - The capture-phase listener stays on `document` so it owns the * event before any nested popover / overlay handler. * + * Nested editors (an editable transclusion body mounted inside a host + * document, B11.22) sit INSIDE the host's root, and these listeners are + * document-scoped — so a chord typed in the nested editor would also fire the + * host's handler and act on the host's stale selection. A hook mounted under + * a composer therefore ignores keydowns whose target belongs to a different, + * nested Lexical root. + * * Chord syntax is the shared parser (`@brainstorm-os/sdk/shortcut`'s * `matchesChord`) — same string format used everywhere else. */ import { matchesChord } from "@brainstorm-os/sdk/shortcut"; -import { useEffect, useRef } from "react"; +import { LexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useContext, useEffect, useRef } from "react"; + +/** Whether `event` originated inside a Lexical editor nested within `root` + * (not `root` itself). Events outside any editor (block-selection mode blurs + * the root to ``) are NOT nested. */ +export function isEventInNestedEditor(event: Event, root: HTMLElement | null): boolean { + if (!root) return false; + const target = event.target; + if (!(target instanceof Element)) return false; + const owner = target.closest('[data-lexical-editor="true"]'); + return owner !== null && owner !== root && root.contains(owner); +} export type EditorShortcutOptions = { /** Set false to skip binding without unmounting. */ @@ -33,6 +52,9 @@ export function useEditorShortcut( ): void { const handlerRef = useRef(handler); handlerRef.current = handler; + const composer = useContext(LexicalComposerContext); + const editorRef = useRef(composer?.[0] ?? null); + editorRef.current = composer?.[0] ?? null; const enabled = options.enabled ?? true; const key = chords.join("\n"); @@ -40,6 +62,8 @@ export function useEditorShortcut( useEffect(() => { if (!enabled || chords.length === 0) return; function onKeydown(event: KeyboardEvent) { + const editor = editorRef.current; + if (editor && isEventInNestedEditor(event, editor.getRootElement())) return; for (const chord of chords) { if (matchesChord(event, chord)) { handlerRef.current(event); diff --git a/packages/editor/src/plugins/transclusion-render-context.tsx b/packages/editor/src/plugins/transclusion-render-context.tsx index 528383cf..d69e8225 100644 --- a/packages/editor/src/plugins/transclusion-render-context.tsx +++ b/packages/editor/src/plugins/transclusion-render-context.tsx @@ -48,11 +48,17 @@ export type TransclusionRenderContextValue = { * NOT including the node currently deciding whether to paint. */ ancestorChain: readonly string[]; renderBody: TransclusionBodyRenderer | null; + /** Whether the document hosting this render point is editable (B11.22). A + * transcluded body may only be edited in place under an editable host; a + * read-only host (locked page, preview) keeps every nested body read-only. + * Absent ⇒ read-only. */ + editable?: boolean; }; const TransclusionRenderContext = createContext({ ancestorChain: [], renderBody: null, + editable: false, }); export type TransclusionRenderProviderProps = TransclusionRenderContextValue & { @@ -62,11 +68,12 @@ export type TransclusionRenderProviderProps = TransclusionRenderContextValue & { export function TransclusionRenderProvider({ ancestorChain, renderBody, + editable = false, children, }: TransclusionRenderProviderProps) { const value = useMemo( - () => ({ ancestorChain, renderBody }), - [ancestorChain, renderBody], + () => ({ ancestorChain, renderBody, editable }), + [ancestorChain, renderBody, editable], ); return ( {children} From 2ec1b6e66445c61ef3e33d9a3ea233faa90ae7e0 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Sun, 18 Oct 2026 15:54:15 +0200 Subject: [PATCH 003/142] =?UTF-8?q?feat(database):=20contextual=20inline?= =?UTF-8?q?=20views=20=E2=80=94=20$host=20predicate=20ref=20+=20host-aware?= =?UTF-8?q?=20embedded=20block=20(9.12.24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdk-types: { $host: true } PropertyRef, allowed on $contains/$notContains; predicateReferencesHost so a host-relative predicate with no host matches nothing as a whole (negation can't widen it). - predicate-eval: PredicateContext { hostEntityId } threaded through evaluatePredicate + evaluateSource; $host resolves to the literal id. Seeded property tests: no host => never matches; host => literal-id parity. - shell entities-repo SQL compiler: $host (and computed $contains RHS) compile to match-nothing, never match-all. - block channel: optional hostEntityId in the BP Startup payload, validated on both ends (sanitizeHostEntityId); BlockRuntimeContext.hostEntityId(); BpBlockMount prop; editor BlockEmbedView derives it from the render chain. - embedded-list block resolves rows via the List's real source + member overrides + default view filter/sort with the host context. - filter builder: FilterRefKind enum (prop/now/host), 'this object' RHS for relation properties on is/is not/contains/does not contain. - entity-draft: $host pins pre-fill the host into a new row's relation. Co-Authored-By: Claude Opus 5.5 --- .../src/blocks/bookmark/entry.test.ts | 2 + .../blocks/embedded-highlight/entry.test.ts | 1 + .../src/blocks/inline-event/entry.test.ts | 2 + apps/database/src/app.ts | 55 +++-- .../src/blocks/embedded-list/entry.test.ts | 85 +++++++- .../src/blocks/embedded-list/entry.ts | 72 +------ .../src/blocks/embedded-list/resolve-rows.ts | 193 ++++++++++++++++++ apps/database/src/i18n/de.json | 1 + apps/database/src/i18n/en.json | 1 + apps/database/src/i18n/es.json | 1 + apps/database/src/i18n/fr.json | 1 + apps/database/src/i18n/it.json | 1 + apps/database/src/i18n/pt.json | 1 + apps/database/src/logic/entity-draft.test.ts | 20 ++ apps/database/src/logic/entity-draft.ts | 38 +++- .../database/src/logic/filter-builder.test.ts | 43 ++++ apps/database/src/logic/filter-builder.ts | 74 +++++-- apps/database/src/types/predicate.ts | 2 + .../src/blocks/embedded-graph/entry.test.ts | 1 + .../src/blocks/inline-task/entry.test.ts | 2 + .../blocks/embedded-whiteboard/entry.test.ts | 1 + .../editor/src/nodes/block-embed-node.test.ts | 13 ++ .../editor/src/nodes/block-embed-node.tsx | 15 ++ packages/sdk-types/src/index.ts | 2 + packages/sdk-types/src/predicate.ts | 39 +++- .../src/block-frame/block-frame-constants.ts | 17 ++ packages/sdk/src/block-frame/index.ts | 1 + .../sdk/src/block-frame/inner-transport.ts | 1 + .../sdk/src/block-frame/transport.test.ts | 20 ++ packages/sdk/src/block-frame/transport.ts | 16 +- .../src/block-mount/bp-block-mount.test.tsx | 21 ++ .../sdk/src/block-mount/bp-block-mount.tsx | 7 + .../src/block-runtime/block-runtime.test.ts | 35 ++++ .../sdk/src/block-runtime/block-runtime.ts | 11 +- .../src/predicate-eval/evaluate-predicate.ts | 124 ++++++++--- .../sdk/src/predicate-eval/evaluate-source.ts | 21 +- .../sdk/src/predicate-eval/host-ref.test.ts | 148 ++++++++++++++ packages/sdk/src/predicate-eval/index.ts | 2 +- .../entities-repo/entities-repo.test.ts | 16 ++ .../storage/entities-repo/entities-repo.ts | 27 ++- 40 files changed, 987 insertions(+), 146 deletions(-) create mode 100644 apps/database/src/blocks/embedded-list/resolve-rows.ts create mode 100644 packages/sdk/src/predicate-eval/host-ref.test.ts diff --git a/apps/bookmarks/src/blocks/bookmark/entry.test.ts b/apps/bookmarks/src/blocks/bookmark/entry.test.ts index c6ec0130..ac8996e6 100644 --- a/apps/bookmarks/src/blocks/bookmark/entry.test.ts +++ b/apps/bookmarks/src/blocks/bookmark/entry.test.ts @@ -17,6 +17,7 @@ function makeCtx(bookmark: unknown): { const ctx = { entityId: "bm-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async (messageName: string) => messageName === "getEntity" ? bookmark : null) as unknown as ( @@ -109,6 +110,7 @@ describe("bookmark block", () => { const ctx = { entityId: "bm-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async () => null) as unknown as (m: string, d: unknown) => Promise, navigate: vi.fn(), diff --git a/apps/books/src/blocks/embedded-highlight/entry.test.ts b/apps/books/src/blocks/embedded-highlight/entry.test.ts index 54729cc3..cabb27a0 100644 --- a/apps/books/src/blocks/embedded-highlight/entry.test.ts +++ b/apps/books/src/blocks/embedded-highlight/entry.test.ts @@ -29,6 +29,7 @@ function makeCtx( const ctx = { entityId: "hl-1", capabilities: () => [], + hostEntityId: () => null, root, graph: ((m: string, d: unknown) => getEntity(m, d as { entityId: string })) as unknown as ( m: string, diff --git a/apps/calendar/src/blocks/inline-event/entry.test.ts b/apps/calendar/src/blocks/inline-event/entry.test.ts index f3077583..8286e853 100644 --- a/apps/calendar/src/blocks/inline-event/entry.test.ts +++ b/apps/calendar/src/blocks/inline-event/entry.test.ts @@ -17,6 +17,7 @@ function makeCtx(event: unknown): { const ctx = { entityId: "evt-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async (messageName: string) => messageName === "getEntity" ? event : null) as unknown as ( @@ -91,6 +92,7 @@ describe("inline-event block", () => { const ctx = { entityId: "evt-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async () => null) as unknown as (m: string, d: unknown) => Promise, navigate: vi.fn(), diff --git a/apps/database/src/app.ts b/apps/database/src/app.ts index 1818043d..3de34282 100644 --- a/apps/database/src/app.ts +++ b/apps/database/src/app.ts @@ -196,12 +196,14 @@ import { type FilterCompareTo, type FilterDraft, FilterOp, + FilterRefKind, type FilterRule, countDraftRules, describeGroup, describeRule, draftToFilterNode, filterNodeToDraft, + opAcceptsHostRef, opAcceptsRef, opIsList, opLabel, @@ -501,6 +503,15 @@ function selectLabelForValue(propertyId: string, value: string): string { /** The human label for a rule's value in the filter chip / menu: a relative- * date rule shows its range name ("Last 7 days"), a vocabulary value its * option label, everything else the raw value. */ +function describeFilterRule(rule: FilterRule): string { + return describeRule( + rule, + colLabel(rule.propertyId), + ruleValueLabel(rule), + undefined, + t("brainstorm.database.filter.thisObject"), + ); +} function ruleValueLabel(rule: FilterRule): string { return rule.op === FilterOp.RelativeDate ? relativeRangeLabel(rule.value) @@ -2762,7 +2773,7 @@ function openFilterMenu(state: AppState, anchor: HTMLElement): void { // value / change condition / remove) — one consistent gesture across the // manager and the pills bar, instead of "click here = silently delete". const items: MenuItem[] = draft.rules.map((rule, i) => ({ - label: describeRule(rule, colLabel(rule.propertyId), ruleValueLabel(rule)), + label: describeFilterRule(rule), icon: propertyKindIcon(rule.propertyId), onClick: () => openFilterRulePillMenu(state, anchor, view.id, draft, i), })); @@ -2856,7 +2867,7 @@ function openFilterGroupMenu( }; const items: MenuItem[] = group.rules.map((rule, i) => ({ - label: describeRule(rule, colLabel(rule.propertyId), ruleValueLabel(rule)), + label: describeFilterRule(rule), icon: IconName.Close, onClick: () => replaceGroup({ ...group, rules: group.rules.filter((_, j) => j !== i) }), })); @@ -2937,6 +2948,7 @@ function openFilterOperatorMenu( // of every other property's operator menu. const def = cachedVaultProperties.find((d) => d.key === propertyId); const isDateProperty = def ? def.valueType === ValueType.Date : /at$/i.test(propertyId); + const isRefProperty = def ? def.valueType === ValueType.EntityRef : /ids?$/i.test(propertyId); const operators = FILTER_OPERATORS.filter((o) => o.op !== FilterOp.RelativeDate || isDateProperty); openSubmenuNextFrame(() => openContextMenu( @@ -2987,23 +2999,40 @@ function openFilterOperatorMenu( // option id (so is/contains matches the stored id), instead of // free-texting a label that never matches (F-027). // Comparison op → offer a literal, the clock (now), or another - // property as the right-hand side (9.12.21 cross-property). - if (opAcceptsRef(o.op)) { - const cols = (state.views.find((x) => x.id === viewId)?.columns ?? []).filter( - (c) => c.visible !== false && c.propertyId !== propertyId, - ); + // property as the right-hand side (9.12.21 cross-property). A + // relation also offers "this object" — the page an embedded view + // sits in (9.12.24 contextual views). + const hostRef = isRefProperty && opAcceptsHostRef(o.op); + if (opAcceptsRef(o.op) || hostRef) { + const cols = opAcceptsRef(o.op) + ? (state.views.find((x) => x.id === viewId)?.columns ?? []).filter( + (c) => c.visible !== false && c.propertyId !== propertyId, + ) + : []; const refItems: MenuItem[] = [ { label: t("brainstorm.database.filter.dateValuePlaceholder"), onClick: () => promptInline(anchor, "", (v) => append(v.trim())), }, - { - label: t("brainstorm.database.filter.dateNow"), - onClick: () => append("", { kind: "now" }), - }, + ...(hostRef + ? [ + { + label: t("brainstorm.database.filter.thisObject"), + onClick: () => append("", { kind: FilterRefKind.Host }), + }, + ] + : []), + ...(opAcceptsRef(o.op) + ? [ + { + label: t("brainstorm.database.filter.dateNow"), + onClick: () => append("", { kind: FilterRefKind.Now }), + }, + ] + : []), ...cols.map((c) => ({ label: `property: ${colLabel(c.propertyId)}`, - onClick: () => append("", { kind: "prop", propertyId: c.propertyId }), + onClick: () => append("", { kind: FilterRefKind.Prop, propertyId: c.propertyId }), })), ]; openSubmenuNextFrame(() => openContextMenu({ x: r.left, y: r.bottom + 4 }, refItems, anchor)); @@ -3386,7 +3415,7 @@ function buildFilterRulePill( return createPill({ variant: "filter", icon: DatabaseIcon.Filter, - text: describeRule(rule, colLabel(rule.propertyId), ruleValueLabel(rule)), + text: describeFilterRule(rule), title: t("brainstorm.database.filter.editRule"), onClick: (anchor) => openFilterRulePillMenu(state, anchor, viewId, draft, index), onRemove: remove, diff --git a/apps/database/src/blocks/embedded-list/entry.test.ts b/apps/database/src/blocks/embedded-list/entry.test.ts index 395fb869..b1084c4f 100644 --- a/apps/database/src/blocks/embedded-list/entry.test.ts +++ b/apps/database/src/blocks/embedded-list/entry.test.ts @@ -26,6 +26,7 @@ function makeCtx(opts: { list: unknown; rowsById?: Record; queryRows?: unknown[]; + hostEntityId?: string | null; }): { root: HTMLElement; navigate: ReturnType; @@ -44,9 +45,11 @@ function makeCtx(opts: { return r; } if (messageName === "queryEntities") { + const type = (data as { operation: { entityTypeId?: string } }).operation.entityTypeId; const vertices: Record = {}; for (const r of opts.queryRows ?? []) { - vertices[(r as { entityId: string }).entityId] = [r]; + const e = r as { entityId: string; entityTypeId: string }; + if (type === undefined || e.entityTypeId === type) vertices[e.entityId] = [r]; } return { results: { vertices } }; } @@ -55,6 +58,7 @@ function makeCtx(opts: { const ctx = { entityId: "list-1", capabilities: () => [], + hostEntityId: () => opts.hostEntityId ?? null, root, graph, navigate, @@ -149,6 +153,7 @@ describe("embedded-list (database grid) block", () => { entityId: "list-1", capabilities: () => [], root, + hostEntityId: () => null, graph: (async () => { throw new Error("denied"); }) as unknown as (m: string, d: unknown) => Promise, @@ -259,6 +264,84 @@ describe("embedded-list (database grid) block", () => { }); }); +describe("embedded-list contextual views (9.12.24)", () => { + const BOOK = "io.test/Book/v1"; + const VIEW_TYPE = "brainstorm/ListView/v1"; + const book = (id: string, author: unknown) => ({ + entityId: id, + entityTypeId: BOOK, + properties: { name: id, author }, + updatedAt: 1, + }); + const list = (extra: Record = {}) => ({ + entityId: "list-1", + entityTypeId: LIST_TYPE, + properties: { + name: "Books", + source: { kind: "byType", types: [BOOK] }, + members: { include: [], exclude: [] }, + defaultViewId: "view-1", + ...extra, + }, + updatedAt: 1, + }); + const view = (filters: unknown, extra: Record = {}) => ({ + entityId: "view-1", + entityTypeId: VIEW_TYPE, + properties: { listId: "list-1", kind: "grid", filters, sorts: [], columns: [], ...extra }, + updatedAt: 1, + }); + const hostFilter = { + kind: "predicate", + predicate: { $contains: { author: { $host: true } } }, + }; + const books = [book("b1", ["page-1"]), book("b2", ["page-2"]), book("b3", ["page-1", "page-2"])]; + const rowNames = (root: HTMLElement) => + [...root.querySelectorAll(".bsdb__row")].map((r) => r.querySelector(".bsdb__td")?.textContent); + + it("filters rows by the host document through the default view", async () => { + const h = makeCtx({ + list: list(), + queryRows: [...books, view(hostFilter)], + hostEntityId: "page-1", + }); + await h.run(); + expect(rowNames(h.root)).toEqual(["b1", "b3"]); + }); + + it("a host-relative view with no host shows nothing, never everything", async () => { + const h = makeCtx({ list: list(), queryRows: [...books, view(hostFilter)] }); + await h.run(); + expect(h.root.querySelectorAll(".bsdb__row")).toHaveLength(0); + expect(h.root.querySelector(".bsdb__empty")).not.toBeNull(); + }); + + it("honours member excludes and a byFilter source with $host", async () => { + const h = makeCtx({ + list: list({ + source: { kind: "byFilter", where: { $contains: { author: { $host: true } } } }, + members: { include: [], exclude: [{ entityId: "b3" }] }, + }), + queryRows: books, + hostEntityId: "page-2", + }); + await h.run(); + expect(rowNames(h.root)).toEqual(["b2"]); + }); + + it("applies the view sort", async () => { + const h = makeCtx({ + list: list(), + queryRows: [ + ...books, + view(null, { sorts: [{ propertyId: "name", direction: "desc", emptyPlacement: "end" }] }), + ], + }); + await h.run(); + expect(rowNames(h.root)).toEqual(["b3", "b2", "b1"]); + }); +}); + describe("embedded-list format helpers (pure)", () => { it("classifies minted keys and ref-like ids", () => { expect(isOpaqueKey("prop_mpye0tff_8acd19")).toBe(true); diff --git a/apps/database/src/blocks/embedded-list/entry.ts b/apps/database/src/blocks/embedded-list/entry.ts index 662ec7a8..31419c81 100644 --- a/apps/database/src/blocks/embedded-list/entry.ts +++ b/apps/database/src/blocks/embedded-list/entry.ts @@ -7,8 +7,11 @@ * over the BP graph module). * * Data flow: the host hands us the embedded entity id (a `brainstorm/List/v1`). - * We `getEntity` it for the title + membership, resolve its rows (explicit - * `members.include`, else a type query against `source`), derive a small + * We `getEntity` it for the title + membership, resolve its rows the way the + * Database app does — source ∪ include \ exclude, then the default view's + * filter + sort, with the host document as the `$host` context so a + * contextual filter ("author contains this page") resolves against the page + * the block sits in (`./resolve-rows.ts`, 9.12.24) — derive a small * column set from the rows' properties, resolve any referenced entities' * titles, and paint a grid. A row click asks the host to open that entity * (`navigate`); a host `refresh` ping re-queries. Pure DOM — no framework in @@ -28,68 +31,7 @@ import { formatCell, plainText, } from "./format"; - -interface BpEntity { - entityId: string; - entityTypeId: string; - properties: Record; - updatedAt: number; -} - -interface QueryResult { - results?: { vertices?: Record }; -} - -function asId(member: unknown): string | null { - if (typeof member === "string") return member; - if (member && typeof member === "object") { - const id = - (member as { id?: unknown; entityId?: unknown }).id ?? - (member as { entityId?: unknown }).entityId; - if (typeof id === "string") return id; - } - return null; -} - -/** Best-effort read of a type id from a List's `source` object (the - * type-derived list case). Defensive: the shape is app-internal and may - * evolve, so unknown shapes resolve to null (→ empty grid, not a throw). */ -function sourceType(list: BpEntity): string | null { - const source = list.properties.source as - | { type?: unknown; query?: { type?: unknown } } - | null - | undefined; - if (!source || typeof source !== "object") return null; - if (typeof source.type === "string") return source.type; - if (source.query && typeof source.query.type === "string") return source.query.type; - return null; -} - -async function resolveRows( - graph: (m: string, d: unknown) => Promise, - list: BpEntity, -): Promise { - const members = (list.properties.members as { include?: unknown } | undefined)?.include; - if (Array.isArray(members) && members.length > 0) { - const ids = members - .map(asId) - .filter((x): x is string => x !== null) - .slice(0, MAX_ROWS); - const got = await Promise.all( - ids.map((entityId) => graph("getEntity", { entityId }).catch(() => null)), - ); - return got.filter((x): x is BpEntity => x !== null); - } - const type = sourceType(list); - if (type) { - const res = await graph("queryEntities", { operation: { entityTypeId: type } }); - const vertices = res.results?.vertices ?? {}; - return Object.values(vertices) - .map((v) => v[0]) - .slice(0, MAX_ROWS); - } - return []; -} +import { type BpEntity, resolveEmbeddedRows } from "./resolve-rows"; /** Resolve stored ref-like ids to their entities' titles via the graph. * Entity ids (`ent_…`) resolve; catalog ids (`di_…` dictionary options the @@ -158,7 +100,7 @@ export function bootEmbeddedList(ctx: BlockRuntimeContext): void { ctx.onLoad(async () => { try { const list = await ctx.graph("getEntity", { entityId: ctx.entityId }); - const rows = await resolveRows(ctx.graph, list); + const rows = await resolveEmbeddedRows(ctx.graph, list, ctx.hostEntityId()); const columns = deriveColumns(rows); const titles = await resolveRefTitles(ctx.graph, collectRefIds(rows, columns)); renderGrid(ctx.root, list, rows, columns, titles, (row) => diff --git a/apps/database/src/blocks/embedded-list/resolve-rows.ts b/apps/database/src/blocks/embedded-list/resolve-rows.ts new file mode 100644 index 00000000..6c13cdd1 --- /dev/null +++ b/apps/database/src/blocks/embedded-list/resolve-rows.ts @@ -0,0 +1,193 @@ +/** + * Row resolution for the embedded Database block (9.12.24 — contextual inline + * views). The block runs in the opaque-origin jail with only the BP graph + * (`getEntity` / `queryEntities` by type), so it resolves a List the same way + * the Database app does — `(source ∪ include) \ exclude`, then the default + * view's filter + sort — through the SHARED `predicate-eval` stack, with the + * host document threaded in as the `$host` context. A view filter like + * "author contains this page" therefore resolves against the page the block + * is dropped into, and against nothing when there is no host. + * + * Jail limits, by design: links are not visible to the block, so a `byLink` + * source contributes no members here; a source with no type scope (`byFilter`, + * `byVocabulary`) scans every readable entity. + */ + +import { LIST_VIEW_ENTITY_TYPE, entityToListView } from "@brainstorm-os/sdk"; +import { + type Entity, + type ListSource, + ListSourceKind, + type ListView, +} from "@brainstorm-os/sdk-types"; +import type { EntityRow } from "@brainstorm-os/sdk/in-memory-entities"; +import { + type PredicateContext, + applyMemberOverrides, + evaluatePredicate, + evaluateSource, +} from "@brainstorm-os/sdk/predicate-eval"; +import { applyManualOrder, applySorts, flattenFilter } from "../../logic/compile-view"; +import { MAX_ROWS } from "./format"; + +export interface BpEntity { + entityId: string; + entityTypeId: string; + properties: Record; + updatedAt: number; +} + +interface QueryResult { + results?: { vertices?: Record }; +} + +export type BlockGraph = (messageName: string, data: unknown) => Promise; + +const SOURCE_KINDS = new Set(Object.values(ListSourceKind)); + +function asId(member: unknown): string | null { + if (typeof member === "string") return member; + if (member && typeof member === "object") { + const id = (member as { entityId?: unknown }).entityId ?? (member as { id?: unknown }).id; + if (typeof id === "string") return id; + } + return null; +} + +function memberIds(list: BpEntity, key: "include" | "exclude"): string[] { + const raw = (list.properties.members as Record | undefined)?.[key]; + if (!Array.isArray(raw)) return []; + return raw.map(asId).filter((x): x is string => x !== null); +} + +/** The List's `source` as a real `ListSource`. Pre-9.3.5 rows stored a bare + * `{ type }` (or `{ query: { type } }`); those read as `byType`. Anything + * else unrecognised is `null` — no source members, never a throw. */ +export function readSource(list: BpEntity): ListSource | null { + const raw = list.properties.source as Record | null | undefined; + if (!raw || typeof raw !== "object") return null; + if (typeof raw.kind === "string" && SOURCE_KINDS.has(raw.kind)) return raw as ListSource; + const legacy = + typeof raw.type === "string" + ? raw.type + : typeof (raw.query as { type?: unknown } | undefined)?.type === "string" + ? (raw.query as { type: string }).type + : null; + return legacy ? { kind: ListSourceKind.ByType, types: [legacy] } : null; +} + +/** The entity types a source can draw members from, or `null` when some + * branch is unscoped by type and needs a full scan. */ +export function sourceTypes(source: ListSource | null): string[] | null { + if (source === null) return []; + switch (source.kind) { + case ListSourceKind.ByType: + return source.types; + case ListSourceKind.ByLink: + return []; + case ListSourceKind.ByFilter: + case ListSourceKind.ByVocabulary: + return null; + case ListSourceKind.Composite: { + const out = new Set(); + for (const child of source.sources) { + const types = sourceTypes(child); + if (types === null) return null; + for (const t of types) out.add(t); + } + return [...out]; + } + } +} + +function toRow(e: BpEntity): EntityRow { + return { + id: e.entityId, + type: e.entityTypeId, + properties: e.properties, + createdAt: 0, + updatedAt: e.updatedAt, + deletedAt: null, + }; +} + +function toEntity(e: BpEntity): Entity { + return { + id: e.entityId, + type: e.entityTypeId, + properties: e.properties, + createdAt: 0, + updatedAt: e.updatedAt, + } as Entity; +} + +async function queryVertices(graph: BlockGraph, entityTypeId?: string): Promise { + const res = await graph("queryEntities", { + operation: entityTypeId ? { entityTypeId } : {}, + }); + return Object.values(res.results?.vertices ?? {}) + .map((v) => v[0]) + .filter((x): x is BpEntity => !!x && typeof x.entityId === "string"); +} + +/** The view the embed paints: the List's `defaultViewId`, else its first + * listed view, else any view bound to the List. `null` ⇒ unfiltered. */ +async function resolveView(graph: BlockGraph, list: BpEntity): Promise { + const views = (await queryVertices(graph, LIST_VIEW_ENTITY_TYPE).catch(() => [])) + .map((e) => entityToListView(toEntity(e))) + .filter((v): v is ListView => v !== null && v.listId === list.entityId); + if (views.length === 0) return null; + const byId = new Map(views.map((v) => [v.id, v])); + const preferred = [ + list.properties.defaultViewId, + ...(Array.isArray(list.properties.views) ? list.properties.views : []), + ]; + for (const id of preferred) { + if (typeof id === "string" && byId.has(id)) return byId.get(id) ?? null; + } + return views[0] ?? null; +} + +export async function resolveEmbeddedRows( + graph: BlockGraph, + list: BpEntity, + hostEntityId: string | null, +): Promise { + const ctx: PredicateContext = hostEntityId === null ? {} : { hostEntityId }; + const source = readSource(list); + const types = sourceTypes(source); + const candidates = new Map(); + const batches = + types === null + ? [await queryVertices(graph)] + : await Promise.all(types.map((t) => queryVertices(graph, t))); + for (const batch of batches) for (const e of batch) candidates.set(e.entityId, e); + + const include = memberIds(list, "include"); + const missing = include.filter((id) => !candidates.has(id)).slice(0, MAX_ROWS); + const fetched = await Promise.all( + missing.map((entityId) => graph("getEntity", { entityId }).catch(() => null)), + ); + for (const e of fetched) if (e) candidates.set(e.entityId, e); + + const rows = [...candidates.values()].map(toRow); + const resolved = evaluateSource(source, { entities: rows, links: [] }, ctx); + const effective = applyMemberOverrides( + resolved, + include.map((entityId) => ({ entityId })), + memberIds(list, "exclude").map((entityId) => ({ entityId })), + ); + + const view = await resolveView(graph, list); + const filter = view ? flattenFilter(view.filters) : null; + const now = Date.now(); + const members = rows.filter( + (r) => effective.has(r.id) && (filter === null || evaluatePredicate(r, filter, now, ctx)), + ); + const sorted = view ? applySorts(members, view.sorts) : members; + const ordered = + view?.manualOrder && view.manualOrder.length > 0 + ? applyManualOrder(sorted, view.manualOrder) + : sorted; + return ordered.slice(0, MAX_ROWS).map((r) => candidates.get(r.id) as BpEntity); +} diff --git a/apps/database/src/i18n/de.json b/apps/database/src/i18n/de.json index c0a26cb8..4244586d 100644 --- a/apps/database/src/i18n/de.json +++ b/apps/database/src/i18n/de.json @@ -72,6 +72,7 @@ "brainstorm.database.filter.editValue": "Wert bearbeiten…", "brainstorm.database.filter.dateValuePlaceholder": "ein Wert…", "brainstorm.database.filter.dateNow": "jetzt", + "brainstorm.database.filter.thisObject": "dieses Objekt", "brainstorm.database.sort.clear": "Sortierung löschen", "brainstorm.database.collection.newBlank": "Leere Sammlung", "brainstorm.database.collection.newFromObjects": "Aus vorhandenen Objekten…", diff --git a/apps/database/src/i18n/en.json b/apps/database/src/i18n/en.json index 3a1e50a9..62b1b681 100644 --- a/apps/database/src/i18n/en.json +++ b/apps/database/src/i18n/en.json @@ -72,6 +72,7 @@ "brainstorm.database.filter.editValue": "Edit value…", "brainstorm.database.filter.dateValuePlaceholder": "a value…", "brainstorm.database.filter.dateNow": "now", + "brainstorm.database.filter.thisObject": "this object", "brainstorm.database.sort.clear": "Clear sort", "brainstorm.database.collection.newBlank": "Blank collection", "brainstorm.database.collection.newFromObjects": "From existing objects…", diff --git a/apps/database/src/i18n/es.json b/apps/database/src/i18n/es.json index 6bf22c85..0bb1e1b9 100644 --- a/apps/database/src/i18n/es.json +++ b/apps/database/src/i18n/es.json @@ -72,6 +72,7 @@ "brainstorm.database.filter.editValue": "Editar valor…", "brainstorm.database.filter.dateValuePlaceholder": "un valor…", "brainstorm.database.filter.dateNow": "ahora", + "brainstorm.database.filter.thisObject": "este objeto", "brainstorm.database.sort.clear": "Borrar orden", "brainstorm.database.collection.newBlank": "Colección en blanco", "brainstorm.database.collection.newFromObjects": "Desde objetos existentes…", diff --git a/apps/database/src/i18n/fr.json b/apps/database/src/i18n/fr.json index c2d7cd9f..4ce52381 100644 --- a/apps/database/src/i18n/fr.json +++ b/apps/database/src/i18n/fr.json @@ -72,6 +72,7 @@ "brainstorm.database.filter.editValue": "Modifier la valeur…", "brainstorm.database.filter.dateValuePlaceholder": "une valeur…", "brainstorm.database.filter.dateNow": "maintenant", + "brainstorm.database.filter.thisObject": "cet objet", "brainstorm.database.sort.clear": "Effacer le tri", "brainstorm.database.collection.newBlank": "Collection vierge", "brainstorm.database.collection.newFromObjects": "À partir d'objets existants…", diff --git a/apps/database/src/i18n/it.json b/apps/database/src/i18n/it.json index 5ca0a727..60ee88d3 100644 --- a/apps/database/src/i18n/it.json +++ b/apps/database/src/i18n/it.json @@ -72,6 +72,7 @@ "brainstorm.database.filter.editValue": "Modifica valore…", "brainstorm.database.filter.dateValuePlaceholder": "un valore…", "brainstorm.database.filter.dateNow": "adesso", + "brainstorm.database.filter.thisObject": "questo oggetto", "brainstorm.database.sort.clear": "Cancella ordinamento", "brainstorm.database.collection.newBlank": "Raccolta vuota", "brainstorm.database.collection.newFromObjects": "Da oggetti esistenti…", diff --git a/apps/database/src/i18n/pt.json b/apps/database/src/i18n/pt.json index db0004bf..0f12ed80 100644 --- a/apps/database/src/i18n/pt.json +++ b/apps/database/src/i18n/pt.json @@ -72,6 +72,7 @@ "brainstorm.database.filter.editValue": "Editar valor…", "brainstorm.database.filter.dateValuePlaceholder": "um valor…", "brainstorm.database.filter.dateNow": "agora", + "brainstorm.database.filter.thisObject": "este objeto", "brainstorm.database.sort.clear": "Limpar ordenação", "brainstorm.database.collection.newBlank": "Coleção em branco", "brainstorm.database.collection.newFromObjects": "A partir de objetos existentes…", diff --git a/apps/database/src/logic/entity-draft.test.ts b/apps/database/src/logic/entity-draft.test.ts index c4a58c71..ea94ce5e 100644 --- a/apps/database/src/logic/entity-draft.test.ts +++ b/apps/database/src/logic/entity-draft.test.ts @@ -394,3 +394,23 @@ describe("collectAndOnlyPredicatesFromFilterNode", () => { ).toEqual([]); }); }); + +describe("draftForList — this object ($host) pins (9.12.24)", () => { + const viewFilters: FilterNode = { + kind: FilterNodeKind.Group, + op: FilterGroupOp.And, + children: [ + { kind: FilterNodeKind.Predicate, predicate: { $contains: { authors: { $host: true } } } }, + { kind: FilterNodeKind.Predicate, predicate: { $eq: { owner: { $host: true } } } }, + ], + }; + + it("pre-fills the host into the relation a contextual view filters on", () => { + const draft = draftForList({ ...noSource, viewFilters, hostEntityId: "page_1" }); + expect(draft.properties).toEqual({ authors: ["page_1"], owner: "page_1" }); + }); + + it("pins nothing for a $host ref without a host", () => { + expect(draftForList({ ...noSource, viewFilters }).properties).toEqual({}); + }); +}); diff --git a/apps/database/src/logic/entity-draft.ts b/apps/database/src/logic/entity-draft.ts index 1f7da624..90b2e599 100644 --- a/apps/database/src/logic/entity-draft.ts +++ b/apps/database/src/logic/entity-draft.ts @@ -23,7 +23,9 @@ import { type FilterNode, FilterNodeKind, type PropertyPredicate, + type PropertyRef, type ScalarValue, + isHostRef, isPropertyRef, } from "../types/predicate"; @@ -47,6 +49,9 @@ export type DraftInputs = { source: ListSource | null; viewFilters: FilterNode | null; defaultTypeUrl: string | null; + /** The host an embedded view renders inside (9.12.24): a `$host` pin + * ("author contains this object") pre-fills the new row with it. */ + hostEntityId?: string; }; /** @@ -61,11 +66,12 @@ export type DraftInputs = { export function draftForList(inputs: DraftInputs): EntityDraft { const properties: Record = {}; + const host = inputs.hostEntityId; for (const pred of collectAndOnlyPredicates(inputs.source)) { - pinFromPredicate(pred, properties); + pinFromPredicate(pred, properties, host); } for (const pred of collectAndOnlyPredicatesFromFilterNode(inputs.viewFilters)) { - pinFromPredicate(pred, properties); + pinFromPredicate(pred, properties, host); } const draft: EntityDraft = { properties }; @@ -117,10 +123,24 @@ export function collectAndOnlyPredicatesFromFilterNode( return node.children.flatMap(collectAndOnlyPredicatesFromFilterNode); } +/** The concrete value a predicate RHS pins, or `undefined`: literals pass, + * `$host` becomes the host id (if any), every other ref pins nothing. */ +function pinnable( + value: ScalarValue | PropertyRef, + host: string | undefined, +): ScalarValue | undefined { + if (!isPropertyRef(value)) return value; + return isHostRef(value) ? host : undefined; +} + /** Drain a `PropertyPredicate` into `out` if it pins a property. Mutates `out`. */ -function pinFromPredicate(pred: PropertyPredicate, out: Record): void { +function pinFromPredicate( + pred: PropertyPredicate, + out: Record, + host: string | undefined, +): void { if ("$and" in pred) { - for (const child of pred.$and) pinFromPredicate(child, out); + for (const child of pred.$and) pinFromPredicate(child, out, host); return; } // $or / $not — non-pinnable; user picks at create time. @@ -129,8 +149,10 @@ function pinFromPredicate(pred: PropertyPredicate, out: Record { ]); }); }); + +describe("filter-builder — this object ($host) right-hand side (9.12.24)", () => { + const host = { kind: FilterRefKind.Host } as const; + + it("compiles contains / is to a $host ref and round-trips", () => { + const contains: FilterRule = { + propertyId: "author", + op: FilterOp.Contains, + value: "", + compareTo: host, + }; + expect(isRuleComplete(contains)).toBe(true); + expect(ruleToPredicate(contains)).toEqual({ $contains: { author: { $host: true } } }); + const eq: FilterRule = { propertyId: "owner", op: FilterOp.Eq, value: "", compareTo: host }; + expect(ruleToPredicate(eq)).toEqual({ $eq: { owner: { $host: true } } }); + const node = draftToFilterNode({ op: FilterGroupOp.And, rules: [contains, eq] }); + expect(filterNodeToDraft(node).rules).toEqual([contains, eq]); + }); + + it("is only offered on identity / membership ops", () => { + expect(opAcceptsHostRef(FilterOp.Contains)).toBe(true); + expect(opAcceptsHostRef(FilterOp.Neq)).toBe(true); + expect(opAcceptsHostRef(FilterOp.Gt)).toBe(false); + const gt: FilterRule = { propertyId: "n", op: FilterOp.Gt, value: "", compareTo: host }; + expect(isRuleComplete(gt)).toBe(false); + expect(ruleToPredicate(gt)).toBeNull(); + }); + + it("describes with the supplied host label", () => { + const rule: FilterRule = { + propertyId: "author", + op: FilterOp.Contains, + value: "", + compareTo: host, + }; + expect(describeRule(rule, "Author", undefined, undefined, "cet objet")).toBe( + "Author contains cet objet", + ); + }); +}); diff --git a/apps/database/src/logic/filter-builder.ts b/apps/database/src/logic/filter-builder.ts index 293e7158..ede76c0b 100644 --- a/apps/database/src/logic/filter-builder.ts +++ b/apps/database/src/logic/filter-builder.ts @@ -53,9 +53,22 @@ export enum FilterOp { RelativeDate = "relativeDate", } +/** What a reference right-hand side points at. Wire-compatible with the + * pre-enum `"prop"` / `"now"` literals. */ +export enum FilterRefKind { + Prop = "prop", + Now = "now", + /** The entity the view is embedded in (9.12.24) — "this object". */ + Host = "host", +} + /** A comparison right-hand side that references something else instead of a - * typed-in value (9.12.21): another property on the same row, or the clock. */ -export type FilterCompareTo = { kind: "prop"; propertyId: string } | { kind: "now" }; + * typed-in value (9.12.21): another property on the same row, the clock, or + * the host object an embedded view renders inside (9.12.24). */ +export type FilterCompareTo = + | { kind: FilterRefKind.Prop; propertyId: string } + | { kind: FilterRefKind.Now } + | { kind: FilterRefKind.Host }; /** The comparison ops that accept a `compareTo` reference (`due < now()`, * `assignee is owner`). The list ops + text ops stay literal-only. */ @@ -72,6 +85,23 @@ export function opAcceptsRef(op: FilterOp): boolean { return REF_OPS.has(op); } +/** The ops a "this object" (`$host`) right-hand side makes sense for: identity + * (`is` / `is not`) on a single ref, membership (`contains`) on a relation. */ +const HOST_REF_OPS: ReadonlySet = new Set([ + FilterOp.Eq, + FilterOp.Neq, + FilterOp.Contains, + FilterOp.NotContains, +]); + +export function opAcceptsHostRef(op: FilterOp): boolean { + return HOST_REF_OPS.has(op); +} + +function refAllowed(compareTo: FilterCompareTo, op: FilterOp): boolean { + return compareTo.kind === FilterRefKind.Host ? opAcceptsHostRef(op) : opAcceptsRef(op); +} + /** The multi-value ops whose raw input is a comma-separated list rather * than one scalar. Centralised so the parse / inverse / describe paths * all agree on which ops mean "a list". */ @@ -146,8 +176,8 @@ export function isRuleComplete(rule: FilterRule): boolean { if (!rule.propertyId) return false; if (!opNeedsValue(rule.op)) return true; // A comparison referencing another property / the clock needs no literal. - if (rule.compareTo && opAcceptsRef(rule.op)) { - return rule.compareTo.kind === "now" || rule.compareTo.propertyId !== ""; + if (rule.compareTo && refAllowed(rule.compareTo, rule.op)) { + return rule.compareTo.kind !== FilterRefKind.Prop || rule.compareTo.propertyId !== ""; } if (opIsList(rule.op)) return coerceList(rule.value).length > 0; return rule.value.trim() !== ""; @@ -156,9 +186,15 @@ export function isRuleComplete(rule: FilterRule): boolean { /** The `PropertyRef` a comparison rule's `compareTo` resolves to, or `null` * when the rule is a plain literal (or the ref is incomplete). */ function refOperand(rule: FilterRule): PropertyRef | null { - if (!rule.compareTo || !opAcceptsRef(rule.op)) return null; - if (rule.compareTo.kind === "now") return { $now: true }; - return rule.compareTo.propertyId ? { $prop: rule.compareTo.propertyId } : null; + if (!rule.compareTo || !refAllowed(rule.compareTo, rule.op)) return null; + switch (rule.compareTo.kind) { + case FilterRefKind.Now: + return { $now: true }; + case FilterRefKind.Host: + return { $host: true }; + case FilterRefKind.Prop: + return rule.compareTo.propertyId ? { $prop: rule.compareTo.propertyId } : null; + } } /** Split a comma-separated input into coerced scalars: trimmed, empties @@ -198,9 +234,9 @@ export function ruleToPredicate(rule: FilterRule): PropertyPredicate | null { case FilterOp.RelativeDate: return { $relativeDate: { [p]: rule.value } }; case FilterOp.Contains: - return { $contains: { [p]: rule.value } }; + return { $contains: { [p]: refOperand(rule) ?? rule.value } }; case FilterOp.NotContains: - return { $notContains: { [p]: rule.value } }; + return { $notContains: { [p]: refOperand(rule) ?? rule.value } }; case FilterOp.Like: return { $like: { [p]: rule.value } }; case FilterOp.NotLike: @@ -302,7 +338,11 @@ function predicateToRule(pred: PropertyPredicate): FilterRule | null { // `compareTo` reference rather than a literal value (9.12.21). if (isPropertyRef(raw)) { const compareTo: FilterCompareTo = - "$now" in raw ? { kind: "now" } : { kind: "prop", propertyId: raw.$prop }; + "$now" in raw + ? { kind: FilterRefKind.Now } + : "$host" in raw + ? { kind: FilterRefKind.Host } + : { kind: FilterRefKind.Prop, propertyId: raw.$prop }; return { propertyId, op, value: "", compareTo }; } let value = ""; @@ -343,21 +383,25 @@ export function filterNodeToDraft(node: FilterNode | null): FilterDraft { } /** Compact one-line description for the menu, e.g. `status is "Done"`, - * `due less than now`, or `assignee is owner`. A `compareTo` reference shows - * the referenced property / `now` unquoted; a literal stays quoted. */ + * `due less than now`, `assignee is owner`, or `author contains this object`. + * A `compareTo` reference shows the referenced property / `now` / the host + * label unquoted; a literal stays quoted. */ export function describeRule( rule: FilterRule, propertyLabel: string, valueLabel?: string, refLabelOf?: (propertyId: string) => string, + hostLabel = "this object", ): string { const head = `${propertyLabel} ${opLabel(rule.op)}`; if (!opNeedsValue(rule.op)) return head; - if (rule.compareTo && opAcceptsRef(rule.op)) { + if (rule.compareTo && refAllowed(rule.compareTo, rule.op)) { const ref = - rule.compareTo.kind === "now" + rule.compareTo.kind === FilterRefKind.Now ? "now" - : (refLabelOf?.(rule.compareTo.propertyId) ?? rule.compareTo.propertyId); + : rule.compareTo.kind === FilterRefKind.Host + ? hostLabel + : (refLabelOf?.(rule.compareTo.propertyId) ?? rule.compareTo.propertyId); return `${head} ${ref}`; } return `${head} "${valueLabel ?? rule.value}"`; diff --git a/apps/database/src/types/predicate.ts b/apps/database/src/types/predicate.ts index 969f944d..4520decb 100644 --- a/apps/database/src/types/predicate.ts +++ b/apps/database/src/types/predicate.ts @@ -14,7 +14,9 @@ export { type FilterNode, FilterNodeKind, type FilterPredicateNode, + isHostRef, isPropertyRef, + predicateReferencesHost, type PropertyPath, type PropertyPredicate, type PropertyRef, diff --git a/apps/graph/src/blocks/embedded-graph/entry.test.ts b/apps/graph/src/blocks/embedded-graph/entry.test.ts index c19417f6..0431f368 100644 --- a/apps/graph/src/blocks/embedded-graph/entry.test.ts +++ b/apps/graph/src/blocks/embedded-graph/entry.test.ts @@ -19,6 +19,7 @@ function makeCtx(graph: unknown): { const ctx = { entityId: "gr-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async (messageName: string) => messageName === "getEntity" ? graph : null) as unknown as ( diff --git a/apps/tasks/src/blocks/inline-task/entry.test.ts b/apps/tasks/src/blocks/inline-task/entry.test.ts index 13068556..6cfe5a24 100644 --- a/apps/tasks/src/blocks/inline-task/entry.test.ts +++ b/apps/tasks/src/blocks/inline-task/entry.test.ts @@ -25,6 +25,7 @@ function makeCtx(task: unknown): CtxHarness { const ctx = { entityId: "task-1", capabilities: () => [], + hostEntityId: () => null, root, graph: graph as unknown as (m: string, d: unknown) => Promise, navigate, @@ -121,6 +122,7 @@ describe("inline-task block", () => { const ctx = { entityId: "task-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async () => { throw new Error("denied"); diff --git a/apps/whiteboard/src/blocks/embedded-whiteboard/entry.test.ts b/apps/whiteboard/src/blocks/embedded-whiteboard/entry.test.ts index 27daf6c8..02637336 100644 --- a/apps/whiteboard/src/blocks/embedded-whiteboard/entry.test.ts +++ b/apps/whiteboard/src/blocks/embedded-whiteboard/entry.test.ts @@ -19,6 +19,7 @@ function makeCtx(board: unknown): { const ctx = { entityId: "wb-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async (messageName: string) => messageName === "getEntity" ? board : null) as unknown as ( diff --git a/packages/editor/src/nodes/block-embed-node.test.ts b/packages/editor/src/nodes/block-embed-node.test.ts index 4d4b08a9..c65f9a06 100644 --- a/packages/editor/src/nodes/block-embed-node.test.ts +++ b/packages/editor/src/nodes/block-embed-node.test.ts @@ -10,6 +10,7 @@ import { BLOCK_EMBED_NODE_TYPE, BlockEmbedNode, SHELL_ENTITY_CARD_BLOCK_ID, + embedHostEntityId, } from "./block-embed-node"; function editor(): LexicalEditor { @@ -541,3 +542,15 @@ describe("BlockEmbedNode", () => { }); }); }); + +describe("embedHostEntityId (9.12.24)", () => { + it("is the innermost document of the render chain", () => { + expect(embedHostEntityId(["note_1"], "list_1")).toBe("note_1"); + expect(embedHostEntityId(["note_1", "note_2"], "list_1")).toBe("note_2"); + }); + + it("is null with no document context or for a self-embed", () => { + expect(embedHostEntityId([], "list_1")).toBeNull(); + expect(embedHostEntityId(["list_1"], "list_1")).toBeNull(); + }); +}); diff --git a/packages/editor/src/nodes/block-embed-node.tsx b/packages/editor/src/nodes/block-embed-node.tsx index 8e87fbf2..72bada2b 100644 --- a/packages/editor/src/nodes/block-embed-node.tsx +++ b/packages/editor/src/nodes/block-embed-node.tsx @@ -58,6 +58,7 @@ import { subscribeEntityTitles, } from "../plugins/entity-index"; import { dispatchOpenEntity } from "../plugins/open-entity-dispatch"; +import { useTransclusionRender } from "../plugins/transclusion-render-context"; /** Non-React `t` for the imperative `exportDOM` clipboard path (English * defaults — the live decorator uses the reactive `useEditorT`). */ @@ -469,6 +470,7 @@ export function BlockEmbedView({ ? blockProtocolSubtitle(renderer, entityType, t) : entityTypeLabel(entityType, t); const bpService = getEditorHost().bp; + const hostEntityId = embedHostEntityId(useTransclusionRender().ancestorChain, entityId); const source = useBlockSource(effectiveBlockId, isBp); const [height, setHeight] = useState(null); const mountHandle = useRef(null); @@ -568,6 +570,7 @@ export function BlockEmbedView({ ` keys its remount * on capability-list contents, but it's still kinder to pass a stable * reference so React's prop comparator can short-circuit identity-equal diff --git a/packages/sdk-types/src/index.ts b/packages/sdk-types/src/index.ts index 8f9c7623..f92cfc6e 100644 --- a/packages/sdk-types/src/index.ts +++ b/packages/sdk-types/src/index.ts @@ -3179,7 +3179,9 @@ export { type FilterNode, FilterNodeKind, type FilterPredicateNode, + isHostRef, isPropertyRef, + predicateReferencesHost, type PropertyPath, type PropertyPredicate, type PropertyRef, diff --git a/packages/sdk-types/src/predicate.ts b/packages/sdk-types/src/predicate.ts index 5421e7de..20435898 100644 --- a/packages/sdk-types/src/predicate.ts +++ b/packages/sdk-types/src/predicate.ts @@ -30,8 +30,15 @@ export type ScalarValue = string | number | boolean | null; * the current clock (`$now`, resolved at evaluation/compile time) instead of a * fixed literal — e.g. `due < now()` or `assignee = owner`. Comparison ops * accept a `PropertyRef` anywhere a literal is allowed; the evaluator and the - * SQL compiler resolve it the same way. */ -export type PropertyRef = { $prop: PropertyPath } | { $now: true }; + * SQL compiler resolve it the same way. + * + * `$host` (9.12.24) resolves to the id of the HOST entity an embedded view is + * rendered inside (a Database block dropped into a page: "Books where author + * contains this page"). It is supplied by the evaluation context, never the + * row; with no host in context a predicate that references `$host` matches + * NOTHING — the SQL compiler, which has no host, always compiles it to + * match-nothing. */ +export type PropertyRef = { $prop: PropertyPath } | { $now: true } | { $host: true }; /** A comparison right-hand side: either a literal or a computed `PropertyRef`. */ export type Comparand = number | string | PropertyRef; @@ -39,8 +46,8 @@ export type Comparand = number | string | PropertyRef; export type PropertyPredicate = | { $eq: Record } | { $neq: Record } - | { $contains: Record } - | { $notContains: Record } + | { $contains: Record } + | { $notContains: Record } | { $gt: Record } | { $lt: Record } | { $gte: Record } @@ -68,7 +75,29 @@ export type PropertyPredicate = * literal. Shared by the evaluator + SQL compiler + filter-builder so all * three branch on the same shape (9.12.21). */ export function isPropertyRef(v: unknown): v is PropertyRef { - return typeof v === "object" && v !== null && ("$prop" in v || "$now" in v); + return typeof v === "object" && v !== null && ("$prop" in v || "$now" in v || "$host" in v); +} + +/** True when the ref resolves to the evaluation context's host entity. */ +export function isHostRef(v: unknown): v is { $host: true } { + return isPropertyRef(v) && "$host" in v; +} + +/** True when any leaf of `pred` compares against `$host`. A host-relative + * predicate evaluated with no host must match nothing — including under + * `$not` / `$neq` / `$notContains`, which a per-leaf `false` would invert into + * match-everything — so evaluators gate on this before walking rows. */ +export function predicateReferencesHost(pred: PropertyPredicate): boolean { + if ("$and" in pred) return pred.$and.some(predicateReferencesHost); + if ("$or" in pred) return pred.$or.some(predicateReferencesHost); + if ("$not" in pred) return predicateReferencesHost(pred.$not); + for (const map of Object.values(pred) as unknown[]) { + if (!map || typeof map !== "object") continue; + for (const rhs of Object.values(map as Record)) { + if (isHostRef(rhs)) return true; + } + } + return false; } /** Filter UI tree — a stack of predicates organised in AND/OR groups. diff --git a/packages/sdk/src/block-frame/block-frame-constants.ts b/packages/sdk/src/block-frame/block-frame-constants.ts index d2687aab..d012893a 100644 --- a/packages/sdk/src/block-frame/block-frame-constants.ts +++ b/packages/sdk/src/block-frame/block-frame-constants.ts @@ -210,6 +210,23 @@ export interface BlockFrameBootstrap { readonly entityId: string; } +/** Upper bound on a host entity id carried in the Startup payload. Vault ids + * are short opaque tokens; anything longer is not one. */ +export const BLOCK_FRAME_MAX_HOST_ENTITY_ID_LENGTH = 256; + +const HOST_ENTITY_ID_RE = /^[\x21-\x7e]+$/; + +/** The validated host entity id (9.12.24), or `null`. Applied on BOTH sides of + * the Startup channel — the host never sends a malformed id and the block + * never trusts one — so a structured, bounded id is the only shape that + * crosses: printable ASCII, no whitespace, at most + * {@link BLOCK_FRAME_MAX_HOST_ENTITY_ID_LENGTH} chars. */ +export function sanitizeHostEntityId(value: unknown): string | null { + if (typeof value !== "string") return null; + if (value.length === 0 || value.length > BLOCK_FRAME_MAX_HOST_ENTITY_ID_LENGTH) return null; + return HOST_ENTITY_ID_RE.test(value) ? value : null; +} + /** Neutralise the byte sequences a JSON string field could carry that would * otherwise break out of the inline ``, HTML comment * openers) or terminate the script per the JS spec's line terminators diff --git a/packages/sdk/src/block-frame/index.ts b/packages/sdk/src/block-frame/index.ts index 7c288e87..5171bd73 100644 --- a/packages/sdk/src/block-frame/index.ts +++ b/packages/sdk/src/block-frame/index.ts @@ -33,6 +33,7 @@ export { BlockFramePhase, buildBlockSrcdoc, makeBlockFrameUrl, + sanitizeHostEntityId, } from "./block-frame-constants"; export { type BlockFrameHandle, diff --git a/packages/sdk/src/block-frame/inner-transport.ts b/packages/sdk/src/block-frame/inner-transport.ts index f45636a6..d474c0f2 100644 --- a/packages/sdk/src/block-frame/inner-transport.ts +++ b/packages/sdk/src/block-frame/inner-transport.ts @@ -59,6 +59,7 @@ export { BLOCK_FRAME_BOOTSTRAP_GLOBAL, type BlockFrameBootstrap, BLOCK_FRAME_ROOT_ID, + sanitizeHostEntityId, } from "./block-frame-constants"; import { type BlockFrameEnvelope, diff --git a/packages/sdk/src/block-frame/transport.test.ts b/packages/sdk/src/block-frame/transport.test.ts index 440726e0..8aa537e7 100644 --- a/packages/sdk/src/block-frame/transport.test.ts +++ b/packages/sdk/src/block-frame/transport.test.ts @@ -234,6 +234,26 @@ describe("createBlockFrameTransport — startup envelope", () => { t.close(); }); + it("carries a valid host entity id in the startup payload, and drops a malformed one (9.12.24)", () => { + const startupFor = (hostEntityId: string) => { + const handle = makeFakeHandle(); + handle.setPhase(BlockFramePhase.Mounted); + const t = createBlockFrameTransport({ + handle, + entityId: "ent_list", + capabilities: [], + hostEntityId, + host: makeFakeHost(), + }); + const payload = lastEnvelope(handle)?.payload as BlockFrameStartupPayload; + t.close(); + return payload; + }; + expect(startupFor("ent_page").hostEntityId).toBe("ent_page"); + expect(startupFor("ent page")).not.toHaveProperty("hostEntityId"); + expect(startupFor("")).not.toHaveProperty("hostEntityId"); + }); + it("flushStartup is idempotent — startup envelope sent exactly once", () => { const handle = makeFakeHandle(); handle.setPhase(BlockFramePhase.Mounted); diff --git a/packages/sdk/src/block-frame/transport.ts b/packages/sdk/src/block-frame/transport.ts index 9abb23fc..e1eb57d5 100644 --- a/packages/sdk/src/block-frame/transport.ts +++ b/packages/sdk/src/block-frame/transport.ts @@ -56,6 +56,7 @@ import { BLOCK_FRAME_DEFAULT_MAX_PAYLOAD_BYTES, BlockFrameDropReason, BlockFramePhase, + sanitizeHostEntityId, } from "./block-frame-constants"; /** @@ -114,6 +115,12 @@ export interface BlockFrameEnvelope { */ export interface BlockFrameStartupPayload { readonly capabilities: readonly string[]; + /** The entity whose document embeds this block (9.12.24) — e.g. the Note + * a Database view was dropped into, so a contextual filter can say "rows + * related to this page". Distinct from the envelope `entityId` (the + * embedded entity the block renders). Absent when the host has no + * document context. Validated with `sanitizeHostEntityId` on both ends. */ + readonly hostEntityId?: string; } export interface CreateBlockFrameTransportOptions { @@ -140,6 +147,10 @@ export interface CreateBlockFrameTransportOptions * `"entities.read:Note"`). Carried in the startup envelope. The * transport does NOT enforce — broker does. */ readonly capabilities: readonly string[]; + /** The embedding document's entity id, carried in the Startup payload + * (see {@link BlockFrameStartupPayload.hostEntityId}). Dropped unless it + * passes `sanitizeHostEntityId`. */ + readonly hostEntityId?: string; /** Callback fired for every well-formed inbound `Message` envelope. * `Startup` envelopes (which only flow Host→Block) are NOT forwarded * to `onMessage`. */ @@ -306,6 +317,7 @@ export function createBlockFrameTransport( const host = opts.host ?? globalThis.window; const channelId = (opts.mintChannelId ?? defaultMintChannelId)(); const frozenCapabilities = Object.freeze([...capabilities]) as readonly string[]; + const hostEntityId = sanitizeHostEntityId(opts.hostEntityId); const maxPayloadBytes = opts.maxPayloadBytes && opts.maxPayloadBytes > 0 ? opts.maxPayloadBytes @@ -364,7 +376,9 @@ export function createBlockFrameTransport( const env = buildEnvelope( BlockFrameMessageKind.Startup, BlockFrameMessageDirection.HostToBlock, - { capabilities: frozenCapabilities }, + hostEntityId === null + ? { capabilities: frozenCapabilities } + : { capabilities: frozenCapabilities, hostEntityId }, ); // Startup envelopes carry only the capability list, which the // host minted — they are bounded by construction and not gated diff --git a/packages/sdk/src/block-mount/bp-block-mount.test.tsx b/packages/sdk/src/block-mount/bp-block-mount.test.tsx index 848b340e..b89d5dd9 100644 --- a/packages/sdk/src/block-mount/bp-block-mount.test.tsx +++ b/packages/sdk/src/block-mount/bp-block-mount.test.tsx @@ -196,6 +196,27 @@ describe("", () => { expect(env.payload.capabilities).toEqual(["blocks.read", "entities.read:Note"]); }); + it("hands the host entity id to the block in the Startup payload (9.12.24)", async () => { + await act(async () => { + root.render( + , + ); + }); + const postSpy = vi.spyOn(getContentWindow(findFrame()), "postMessage"); + await act(async () => { + lastIntersection?.fire([{ isIntersecting: true }]); + }); + const env = postSpy.mock.calls[0]?.[0] as { payload: { hostEntityId?: string } }; + expect(env.payload.hostEntityId).toBe("ent_page"); + }); + it("loads the bsblock:// bundle URL when `blockId` is supplied, sharing one channel id", async () => { await act(async () => { root.render( diff --git a/packages/sdk/src/block-mount/bp-block-mount.tsx b/packages/sdk/src/block-mount/bp-block-mount.tsx index 92bb0c53..1df11a0f 100644 --- a/packages/sdk/src/block-mount/bp-block-mount.tsx +++ b/packages/sdk/src/block-mount/bp-block-mount.tsx @@ -79,6 +79,10 @@ export interface BpBlockMountProps { * the seam does NOT re-flush — Block Protocol calls re-check via * the broker on every invocation (this list is advisory). */ readonly capabilities: readonly string[]; + /** The entity whose document hosts this embed (9.12.24), handed to the + * block in its Startup payload so a contextual view can filter relative + * to "this page". Changing it remounts the seam. */ + readonly hostEntityId?: string; /** Inbound BP-protocol messages from the block. `Startup` envelopes * are NOT forwarded — they only flow host→block. */ readonly onMessage?: (payload: TIn) => void; @@ -139,6 +143,7 @@ export function BpBlockMount( const { entityId, capabilities, + hostEntityId, onMessage, bp, blockId, @@ -235,6 +240,7 @@ export function BpBlockMount( handle: frame, entityId, capabilities: capsSnapshot, + ...(hostEntityId !== undefined ? { hostEntityId } : {}), mintChannelId: () => channelId, onMessage: (payload) => { onMessageRef.current?.(payload); @@ -287,6 +293,7 @@ export function BpBlockMount( }, [ entityId, capsSnapshot, + hostEntityId, blockId, title, IntersectionObserverImpl, diff --git a/packages/sdk/src/block-runtime/block-runtime.test.ts b/packages/sdk/src/block-runtime/block-runtime.test.ts index a0848e7b..e8886f54 100644 --- a/packages/sdk/src/block-runtime/block-runtime.test.ts +++ b/packages/sdk/src/block-runtime/block-runtime.test.ts @@ -96,6 +96,41 @@ describe("startBlock — in-iframe harness", () => { expect(seen).toEqual(["entities.read:Database"]); }); + it("exposes the Startup host entity id (9.12.24), validated", () => { + const seen: Array = []; + startBlock( + (ctx) => { + seen.push(ctx.hostEntityId()); + ctx.onLoad(() => { + seen.push(ctx.hostEntityId()); + }); + }, + { win: h.win, parent: h.parent }, + ); + h.deliver({ capabilities: [], hostEntityId: "ent_page_1" }, BlockFrameMessageKind.Startup); + expect(seen).toEqual([null, "ent_page_1"]); + }); + + it.each([ + ["a non-string", 42], + ["an empty string", ""], + ["whitespace", "ent page"], + ["an over-long id", "x".repeat(257)], + ["a control char", "ent\u0000"], + ])("drops %s host entity id", (_label, hostEntityId) => { + let seen: string | null | undefined; + startBlock( + (ctx) => { + ctx.onLoad(() => { + seen = ctx.hostEntityId(); + }); + }, + { win: h.win, parent: h.parent }, + ); + h.deliver({ capabilities: [], hostEntityId }, BlockFrameMessageKind.Startup); + expect(seen).toBeNull(); + }); + it("frames a BP graph request and resolves the correlated *Response", async () => { let ctxRef: BlockRuntimeContext | null = null; startBlock( diff --git a/packages/sdk/src/block-runtime/block-runtime.ts b/packages/sdk/src/block-runtime/block-runtime.ts index 9dfb428a..3025b740 100644 --- a/packages/sdk/src/block-runtime/block-runtime.ts +++ b/packages/sdk/src/block-runtime/block-runtime.ts @@ -28,6 +28,7 @@ import { type BlockFrameBootstrap, type BlockFrameInnerTransport, createBlockFrameInnerTransport, + sanitizeHostEntityId, } from "@brainstorm-os/sdk/block-frame/inner"; /** Host→block control messages that are NOT BP protocol traffic (they carry @@ -90,8 +91,13 @@ interface BpWireMessage { /** The context a block's boot callback receives. */ export interface BlockRuntimeContext { - /** The embedding entity id (the entity this block was dropped onto). */ + /** The embedded entity id — the entity this block renders (for the + * Database block, the `List`). */ readonly entityId: string; + /** The entity whose document embeds this block (9.12.24) — e.g. the Note + * a Database view was dropped into — or `null` when the host gave none + * (or before Startup arrives). Validated; never the raw wire value. */ + hostEntityId(): string | null; /** Capability snapshot from the host Startup envelope. Advisory — the * broker re-checks every call; empty until Startup arrives. */ capabilities(): readonly string[]; @@ -167,6 +173,7 @@ export function startBlock(boot: BlockBoot, opts: StartBlockOptions = {}): () => } let caps: readonly string[] = []; + let hostEntityId: string | null = null; let loader: (() => void | Promise) | null = null; const pending = new Map< string, @@ -194,6 +201,7 @@ export function startBlock(boot: BlockBoot, opts: StartBlockOptions = {}): () => ...(opts.parent ? { parent: opts.parent } : {}), onStartup: (payload) => { caps = payload.capabilities; + hostEntityId = sanitizeHostEntityId(payload.hostEntityId); // The frame can't read the embedder's `:root`, and the host doesn't // know when our inner transport started listening — so pull the theme // now that Startup proves the channel is live. The host answers with a @@ -233,6 +241,7 @@ export function startBlock(boot: BlockBoot, opts: StartBlockOptions = {}): () => const ctx: BlockRuntimeContext = { entityId: bootstrap.entityId, capabilities: () => caps, + hostEntityId: () => hostEntityId, root, graph(messageName: string, data: unknown): Promise { return new Promise((resolve, reject) => { diff --git a/packages/sdk/src/predicate-eval/evaluate-predicate.ts b/packages/sdk/src/predicate-eval/evaluate-predicate.ts index 9b61ed80..f3ae64a2 100644 --- a/packages/sdk/src/predicate-eval/evaluate-predicate.ts +++ b/packages/sdk/src/predicate-eval/evaluate-predicate.ts @@ -15,11 +15,31 @@ * truth-table for both without divergence. */ -import { isPropertyRef } from "@brainstorm-os/sdk-types"; -import type { PropertyPredicate, ScalarValue } from "@brainstorm-os/sdk-types"; +import { isPropertyRef, predicateReferencesHost } from "@brainstorm-os/sdk-types"; +import type { PropertyPredicate, PropertyRef, ScalarValue } from "@brainstorm-os/sdk-types"; import { type EntityRow, readPropertyPath } from "../in-memory-entities"; import { isInRelativeRange, isRelativeDateRange } from "./relative-date"; +/** Ambient inputs a predicate may reference (9.12.24). `hostEntityId` is the + * entity an embedded view renders inside — what a `{ $host: true }` ref + * resolves to. Absent ⇒ every host-relative predicate matches nothing. */ +export type PredicateContext = { + readonly hostEntityId?: string; +}; + +const NO_CONTEXT: PredicateContext = Object.freeze({}); + +const hostRefCache = new WeakMap(); + +function referencesHost(pred: PropertyPredicate): boolean { + let hit = hostRefCache.get(pred); + if (hit === undefined) { + hit = predicateReferencesHost(pred); + hostRefCache.set(pred, hit); + } + return hit; +} + /** * Evaluate a `PropertyPredicate` against a single entity. * @@ -27,42 +47,66 @@ import { isInRelativeRange, isRelativeDateRange } from "./relative-date"; * resolves against a single consistent clock for the whole pass — and so tests * can pin a fixed reference. It defaults to the current time, which is what * makes a "Last 7 days" view filter re-roll every time it recompiles. + * + * `ctx` supplies the host entity a `$host` ref resolves to. A host-relative + * predicate with no host is rejected as a whole (not per leaf), so negation + * can never turn "unknown host" into "every row". */ export function evaluatePredicate( entity: EntityRow, pred: PropertyPredicate, now: number = Date.now(), + ctx: PredicateContext = NO_CONTEXT, +): boolean { + if (ctx.hostEntityId === undefined && referencesHost(pred)) return false; + return evaluateNode(entity, pred, now, ctx); +} + +function evaluateNode( + entity: EntityRow, + pred: PropertyPredicate, + now: number, + ctx: PredicateContext, ): boolean { - if ("$and" in pred) return pred.$and.every((p) => evaluatePredicate(entity, p, now)); - if ("$or" in pred) return pred.$or.some((p) => evaluatePredicate(entity, p, now)); - if ("$not" in pred) return !evaluatePredicate(entity, pred.$not, now); + if ("$and" in pred) return pred.$and.every((p) => evaluateNode(entity, p, now, ctx)); + if ("$or" in pred) return pred.$or.some((p) => evaluateNode(entity, p, now, ctx)); + if ("$not" in pred) return !evaluateNode(entity, pred.$not, now, ctx); if ("$eq" in pred) return everyEntry(pred.$eq, (path, want) => - scalarEq(readScalar(entity, path), eqOperand(entity, want, now)), + scalarEq(readScalar(entity, path), operand(entity, want, now, ctx)), ); if ("$neq" in pred) return everyEntry( pred.$neq, - (path, want) => !scalarEq(readScalar(entity, path), eqOperand(entity, want, now)), + (path, want) => !scalarEq(readScalar(entity, path), operand(entity, want, now, ctx)), ); if ("$contains" in pred) return everyEntry(pred.$contains, (path, want) => - collectionContains(readPropertyPath(entity, path), want), + collectionContains(readPropertyPath(entity, path), containsOperand(entity, want, now, ctx)), ); if ("$notContains" in pred) return everyEntry( pred.$notContains, - (path, want) => !collectionContains(readPropertyPath(entity, path), want), + (path, want) => + !collectionContains(readPropertyPath(entity, path), containsOperand(entity, want, now, ctx)), ); if ("$gt" in pred) - return everyEntry(pred.$gt, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s > 0)); + return everyEntry(pred.$gt, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s > 0), + ); if ("$lt" in pred) - return everyEntry(pred.$lt, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s < 0)); + return everyEntry(pred.$lt, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s < 0), + ); if ("$gte" in pred) - return everyEntry(pred.$gte, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s >= 0)); + return everyEntry(pred.$gte, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s >= 0), + ); if ("$lte" in pred) - return everyEntry(pred.$lte, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s <= 0)); + return everyEntry(pred.$lte, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s <= 0), + ); if ("$in" in pred) return everyEntry(pred.$in, (path, list) => { const v = readScalar(entity, path); @@ -152,14 +196,37 @@ function compare(a: unknown, b: number | string): number { return 0; } -/** Resolve an `$eq`/`$neq` right-hand side (9.12.21): a `PropertyRef` reads - * another property — or the clock (`$now`) — on the same entity; a literal - * passes through unchanged. */ -function eqOperand(entity: EntityRow, operand: unknown, now: number): unknown { - if (isPropertyRef(operand)) { - return "$now" in operand ? now : readScalar(entity, operand.$prop); - } - return operand; +/** Resolve a `PropertyRef` to the value it stands for: another property on + * the same entity (`$prop`), the clock (`$now`), or the host entity's id + * (`$host`) — a literal id, so `x = $host` is exactly `x = ""`. */ +function resolveRef( + entity: EntityRow, + ref: PropertyRef, + now: number, + ctx: PredicateContext, +): unknown { + if ("$now" in ref) return now; + if ("$host" in ref) return ctx.hostEntityId ?? null; + return readScalar(entity, ref.$prop); +} + +/** Resolve an `$eq`/`$neq` right-hand side (9.12.21): a `PropertyRef` resolves + * through {@link resolveRef}; a literal passes through unchanged. */ +function operand(entity: EntityRow, rhs: unknown, now: number, ctx: PredicateContext): unknown { + return isPropertyRef(rhs) ? resolveRef(entity, rhs, now, ctx) : rhs; +} + +/** A `$contains` right-hand side resolved to the scalar the collection is + * searched for. A ref that resolves to a non-scalar never matches. */ +function containsOperand( + entity: EntityRow, + rhs: unknown, + now: number, + ctx: PredicateContext, +): ScalarValue { + const v = operand(entity, rhs, now, ctx); + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v; + return null; } /** Coerce a value to an orderable scalar: a property-ui `DateValue` `{at}` @@ -176,11 +243,13 @@ function asComparable(value: unknown): number | string | null { /** Resolve a comparison right-hand side to an orderable value — a literal, * another property (`$prop`), or the clock (`$now`). `null` ⇒ incomparable. */ -function comparand(entity: EntityRow, rhs: unknown, now: number): number | string | null { - if (isPropertyRef(rhs)) { - return "$now" in rhs ? now : asComparable(readScalar(entity, rhs.$prop)); - } - return asComparable(rhs); +function comparand( + entity: EntityRow, + rhs: unknown, + now: number, + ctx: PredicateContext, +): number | string | null { + return asComparable(operand(entity, rhs, now, ctx)); } /** True when both operands are comparable and their ordering satisfies `ok`. A @@ -191,10 +260,11 @@ function signMatches( path: string, rhs: unknown, now: number, + ctx: PredicateContext, ok: (sign: number) => boolean, ): boolean { const left = asComparable(readScalar(entity, path)); - const right = comparand(entity, rhs, now); + const right = comparand(entity, rhs, now, ctx); if (left === null || right === null) return false; return ok(compare(left, right)); } diff --git a/packages/sdk/src/predicate-eval/evaluate-source.ts b/packages/sdk/src/predicate-eval/evaluate-source.ts index 59cbf5be..c9b3f7af 100644 --- a/packages/sdk/src/predicate-eval/evaluate-source.ts +++ b/packages/sdk/src/predicate-eval/evaluate-source.ts @@ -19,21 +19,27 @@ import { } from "@brainstorm-os/sdk-types"; import { effectiveMembers } from "../collections"; import type { InMemoryVault } from "../in-memory-entities"; -import { evaluatePredicate } from "./evaluate-predicate"; +import { type PredicateContext, evaluatePredicate } from "./evaluate-predicate"; -export function evaluateSource(source: ListSource | null, db: InMemoryVault): Set { +/** `ctx` carries the host entity a `$host` ref in a `byFilter` source resolves + * to (9.12.24); without it a host-relative filter contributes no members. */ +export function evaluateSource( + source: ListSource | null, + db: InMemoryVault, + ctx?: PredicateContext, +): Set { if (source === null) return new Set(); switch (source.kind) { case ListSourceKind.ByType: return collectByType(db, source.types); case ListSourceKind.ByFilter: - return collectByFilter(db, source.where); + return collectByFilter(db, source.where, ctx); case ListSourceKind.ByLink: return collectByLink(db, source); case ListSourceKind.ByVocabulary: return collectByVocabulary(db, source.vocabularyId, source.values); case ListSourceKind.Composite: - return collectComposite(db, source); + return collectComposite(db, source, ctx); } } @@ -50,11 +56,13 @@ function collectByType(db: InMemoryVault, types: ReadonlyArray): Set[1], + ctx: PredicateContext | undefined, ): Set { const out = new Set(); + const now = Date.now(); for (const e of db.entities) { if (e.deletedAt !== null) continue; - if (evaluatePredicate(e, where)) out.add(e.id); + if (evaluatePredicate(e, where, now, ctx)) out.add(e.id); } return out; } @@ -129,9 +137,10 @@ function collectByVocabulary( function collectComposite( db: InMemoryVault, source: { op: CompositeOp; sources: ReadonlyArray }, + ctx: PredicateContext | undefined, ): Set { if (source.sources.length === 0) return new Set(); - const childSets = source.sources.map((s) => evaluateSource(s, db)); + const childSets = source.sources.map((s) => evaluateSource(s, db, ctx)); if (source.op === CompositeOp.And) { return intersectAll(childSets); } diff --git a/packages/sdk/src/predicate-eval/host-ref.test.ts b/packages/sdk/src/predicate-eval/host-ref.test.ts new file mode 100644 index 00000000..9dfee53a --- /dev/null +++ b/packages/sdk/src/predicate-eval/host-ref.test.ts @@ -0,0 +1,148 @@ +import { + CompositeOp, + type ListSource, + ListSourceKind, + type PropertyPredicate, + type PropertyRef, + type ScalarValue, + predicateReferencesHost, +} from "@brainstorm-os/sdk-types"; +import { describe, expect, it } from "vitest"; +import type { EntityRow, InMemoryVault } from "../in-memory-entities"; +import { evaluatePredicate } from "./evaluate-predicate"; +import { evaluateSource } from "./evaluate-source"; + +const HOST: { $host: true } = { $host: true }; + +function row(id: string, properties: Record): EntityRow { + return { id, type: "io.test/Book/v1", properties, createdAt: 0, updatedAt: 0, deletedAt: null }; +} + +/** mulberry32 — deterministic, so a failing seed reproduces exactly. fast-check + * is not a workspace dependency; this is the same generate-and-compare shape. */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const IDS = ["ent_a", "ent_b", "ent_c", "ent_d"] as const; +const PATHS = ["author", "editors", "title"] as const; +const LEAF_OPS = ["$eq", "$neq", "$contains", "$notContains", "$gt", "$lte"] as const; + +function pick(r: () => number, xs: readonly T[]): T { + return xs[Math.floor(r() * xs.length)] as T; +} + +function genEntity(r: () => number, i: number): EntityRow { + const props: Record = {}; + if (r() < 0.8) props.author = pick(r, IDS); + if (r() < 0.8) props.editors = IDS.filter(() => r() < 0.4); + if (r() < 0.5) props.title = pick(r, ["x", "ent_a", "y"]); + return row(`row_${i}`, props); +} + +/** A random predicate tree whose leaves compare against `rhs()` — called once + * per leaf so the host-ref and literal trees are built from one shape. */ +function genPredicate( + r: () => number, + depth: number, + rhs: (r: () => number) => ScalarValue | PropertyRef, +): PropertyPredicate { + const roll = r(); + if (depth > 0 && roll < 0.15) return { $not: genPredicate(r, depth - 1, rhs) }; + if (depth > 0 && roll < 0.3) + return { $and: [genPredicate(r, depth - 1, rhs), genPredicate(r, depth - 1, rhs)] }; + if (depth > 0 && roll < 0.45) + return { $or: [genPredicate(r, depth - 1, rhs), genPredicate(r, depth - 1, rhs)] }; + const op = pick(r, LEAF_OPS); + const path = pick(r, PATHS); + return { [op]: { [path]: rhs(r) } } as unknown as PropertyPredicate; +} + +/** Same seed ⇒ same tree; one variant with `$host` leaves, one with the literal. */ +function hostAndLiteral(seed: number, hostId: string): [PropertyPredicate, PropertyPredicate] { + const leaf = + (asHost: boolean) => + (r: () => number): ScalarValue | PropertyRef => { + if (r() < 0.6) return asHost ? HOST : hostId; + return pick(r, IDS); + }; + return [genPredicate(rng(seed), 3, leaf(true)), genPredicate(rng(seed), 3, leaf(false))]; +} + +describe("$host ref (9.12.24) — property tests", () => { + const entities = Array.from({ length: 24 }, (_, i) => genEntity(rng(1000 + i), i)); + + it("with no host, a host-relative predicate never matches — even negated", () => { + let checked = 0; + for (let seed = 1; seed <= 400; seed += 1) { + const [withHost] = hostAndLiteral(seed, "ent_a"); + if (!predicateReferencesHost(withHost)) continue; + for (const e of entities) { + expect(evaluatePredicate(e, withHost, 0)).toBe(false); + expect(evaluatePredicate(e, { $not: withHost }, 0)).toBe(false); + expect(evaluatePredicate(e, withHost, 0, {})).toBe(false); + } + checked += 1; + } + expect(checked).toBeGreaterThan(200); + }); + + it("with a host, `$host` is exactly the literal-id predicate", () => { + for (let seed = 1; seed <= 400; seed += 1) { + const hostId = pick(rng(seed * 7), IDS); + const [withHost, literal] = hostAndLiteral(seed, hostId); + for (const e of entities) { + expect(evaluatePredicate(e, withHost, 0, { hostEntityId: hostId })).toBe( + evaluatePredicate(e, literal, 0), + ); + } + } + }); +}); + +describe("$host ref — examples", () => { + const book = row("b1", { author: "page_1", editors: ["page_1", "page_2"] }); + + it("relation contains this page", () => { + const pred: PropertyPredicate = { $contains: { editors: HOST } }; + expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_2" })).toBe(true); + expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_9" })).toBe(false); + expect(evaluatePredicate(book, pred, 0)).toBe(false); + }); + + it("scalar relation is this page", () => { + const pred: PropertyPredicate = { $eq: { author: HOST } }; + expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_1" })).toBe(true); + expect(evaluatePredicate(book, { $neq: { author: HOST } }, 0)).toBe(false); + }); + + it("a predicate without $host is unaffected by the missing host", () => { + expect(evaluatePredicate(book, { $eq: { author: "page_1" } }, 0)).toBe(true); + }); + + it("threads the host through evaluateSource (byFilter + composite)", () => { + const db: InMemoryVault = { + entities: [book, row("b2", { author: "page_2" })], + links: [], + }; + const byHost: ListSource = { + kind: ListSourceKind.ByFilter, + where: { $eq: { author: HOST } }, + }; + expect([...evaluateSource(byHost, db, { hostEntityId: "page_2" })]).toEqual(["b2"]); + expect(evaluateSource(byHost, db).size).toBe(0); + const composite: ListSource = { + kind: ListSourceKind.Composite, + op: CompositeOp.Or, + sources: [byHost, { kind: ListSourceKind.ByType, types: ["io.test/Book/v1"] }], + }; + expect(evaluateSource(composite, db).size).toBe(2); + }); +}); diff --git a/packages/sdk/src/predicate-eval/index.ts b/packages/sdk/src/predicate-eval/index.ts index 1aef0555..ab6e24ec 100644 --- a/packages/sdk/src/predicate-eval/index.ts +++ b/packages/sdk/src/predicate-eval/index.ts @@ -6,7 +6,7 @@ * membership semantics cannot drift between client and service. */ -export { evaluatePredicate } from "./evaluate-predicate"; +export { type PredicateContext, evaluatePredicate } from "./evaluate-predicate"; export { applyMemberOverrides, byLinkAnchors, diff --git a/packages/shell/src/main/storage/entities-repo/entities-repo.test.ts b/packages/shell/src/main/storage/entities-repo/entities-repo.test.ts index a483a5be..0a895fb6 100644 --- a/packages/shell/src/main/storage/entities-repo/entities-repo.test.ts +++ b/packages/shell/src/main/storage/entities-repo/entities-repo.test.ts @@ -180,6 +180,22 @@ describe("EntitiesRepository", () => { expect(ids({ where: { $and: [{ $gt: { n: 1 } }, { $lt: { n: 10 } }] } })).toEqual(["a"]); }); + it("a $host-relative predicate matches nothing — no host exists at the SQL layer (9.12.24)", () => { + seed({ id: "a", properties: { author: "page_1", n: 5 } }); + seed({ id: "b", properties: { author: "page_2", n: 50 } }); + const ids = (q: Parameters[0]) => + env.repo + .query(q) + .map((e) => e.id) + .sort(); + const host = { $host: true } as const; + expect(ids({ where: { $eq: { author: host } } })).toEqual([]); + expect(ids({ where: { $contains: { author: host } } })).toEqual([]); + expect(ids({ where: { $gt: { n: host } } })).toEqual([]); + expect(ids({ where: { $or: [{ $eq: { n: 5 } }, { $eq: { author: host } }] } })).toEqual([]); + expect(ids({ where: { $contains: { author: { $prop: "n" } } } })).toEqual([]); + }); + it("query compiles comparison refs ($now / $prop) (9.12.21)", () => { const now = Date.now(); const DAY = 86_400_000; diff --git a/packages/shell/src/main/storage/entities-repo/entities-repo.ts b/packages/shell/src/main/storage/entities-repo/entities-repo.ts index b4a50faf..607e3392 100644 --- a/packages/shell/src/main/storage/entities-repo/entities-repo.ts +++ b/packages/shell/src/main/storage/entities-repo/entities-repo.ts @@ -24,6 +24,7 @@ import { type LinkPredicate, type PropertyPredicate, isPropertyRef, + predicateReferencesHost, } from "@brainstorm-os/sdk-types"; import type { SqliteDatabase, SqliteStatement } from "@brainstorm-os/sqlite"; import { EdgeMatch, type GraphPattern } from "../../entities/pattern"; @@ -954,16 +955,26 @@ function decodePatternRows( type Compiled = { sql: string; params: unknown[] }; +const MATCH_NOTHING: Compiled = { sql: "0 = 1", params: [] }; + +/** A query has no host entity, so a `$host`-relative predicate (9.12.24) is + * rejected WHOLE — compiling it per leaf would let `$or`/`$not` around a + * dead leaf widen into rows the author never asked for. */ function compilePredicate(pred: PropertyPredicate): Compiled { + if (predicateReferencesHost(pred)) return MATCH_NOTHING; + return compilePredicateNode(pred); +} + +function compilePredicateNode(pred: PropertyPredicate): Compiled { if ("$and" in pred) { - const parts = pred.$and.map(compilePredicate); + const parts = pred.$and.map(compilePredicateNode); return { sql: `(${parts.map((p) => p.sql).join(" AND ")})`, params: parts.flatMap((p) => p.params), }; } if ("$or" in pred) { - const parts = pred.$or.map(compilePredicate); + const parts = pred.$or.map(compilePredicateNode); return { sql: `(${parts.map((p) => p.sql).join(" OR ")})`, params: parts.flatMap((p) => p.params), @@ -980,8 +991,10 @@ function compilePredicate(pred: PropertyPredicate): Compiled { if ("$now" in rhs) { clauses.push(`${left} = ?`); params.push(Date.now()); - } else { + } else if ("$prop" in rhs) { clauses.push(`${left} = json_extract(e.properties, '${jsonPath(rhs.$prop)}')`); + } else { + return MATCH_NOTHING; } } else { clauses.push(`${left} = ?`); @@ -991,6 +1004,10 @@ function compilePredicate(pred: PropertyPredicate): Compiled { return { sql: `(${clauses.join(" AND ")})`, params }; } if ("$contains" in pred) { + // A computed `$contains` RHS is an in-memory-evaluator feature; SQL + // has no row-relative LIKE here, so it matches nothing rather than + // LIKE-ing the stringified ref object. + if (Object.values(pred.$contains).some(isPropertyRef)) return MATCH_NOTHING; return conjunction( pred.$contains, (path) => `lower(json_extract(e.properties, '${path}')) LIKE ?`, @@ -1031,8 +1048,10 @@ function compileComparison(obj: Record, op: ">" | "<"): Compi if ("$now" in rhs) { right = "?"; params.push(Date.now()); - } else { + } else if ("$prop" in rhs) { right = `CAST(${jsonAtOrSelf(rhs.$prop)} AS REAL)`; + } else { + return MATCH_NOTHING; } } else { right = "?"; From d88cba2b204c686e7caa8fcce3503df2f0bb61ea Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Sun, 18 Oct 2026 15:55:59 +0200 Subject: [PATCH 004/142] fix(predicate-eval): $contains matches multi-valued { value } envelopes, so 'relation contains this object' works on multi relations Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/predicate-eval/evaluate-predicate.ts | 9 ++++++++- packages/sdk/src/predicate-eval/host-ref.test.ts | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/predicate-eval/evaluate-predicate.ts b/packages/sdk/src/predicate-eval/evaluate-predicate.ts index f3ae64a2..26786be7 100644 --- a/packages/sdk/src/predicate-eval/evaluate-predicate.ts +++ b/packages/sdk/src/predicate-eval/evaluate-predicate.ts @@ -176,11 +176,18 @@ function collectionContains(collection: unknown, want: ScalarValue): boolean { return collection.toLowerCase().includes(want.toLowerCase()); } if (Array.isArray(collection)) { - return collection.some((c) => scalarEq(c, want)); + return collection.some((c) => scalarEq(unwrapLabeled(c), want)); } return false; } +/** A multi-valued property stores `{ value, label? }` envelopes (a relation is + * `[{ value: "" }]`); membership compares the envelope's `value`. */ +function unwrapLabeled(item: unknown): unknown { + if (item && typeof item === "object" && "value" in item) return (item as { value: unknown }).value; + return item; +} + function collectAll(value: unknown): unknown[] { if (value === undefined || value === null) return []; if (Array.isArray(value)) return value; diff --git a/packages/sdk/src/predicate-eval/host-ref.test.ts b/packages/sdk/src/predicate-eval/host-ref.test.ts index 9dfee53a..1eb20441 100644 --- a/packages/sdk/src/predicate-eval/host-ref.test.ts +++ b/packages/sdk/src/predicate-eval/host-ref.test.ts @@ -117,6 +117,13 @@ describe("$host ref — examples", () => { expect(evaluatePredicate(book, pred, 0)).toBe(false); }); + it("a multi-valued relation's { value } envelopes match by id", () => { + const multi = row("b2", { authors: [{ value: "page_1" }, { value: "page_3" }] }); + const pred: PropertyPredicate = { $contains: { authors: HOST } }; + expect(evaluatePredicate(multi, pred, 0, { hostEntityId: "page_3" })).toBe(true); + expect(evaluatePredicate(multi, pred, 0, { hostEntityId: "page_2" })).toBe(false); + }); + it("scalar relation is this page", () => { const pred: PropertyPredicate = { $eq: { author: HOST } }; expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_1" })).toBe(true); From 1f024f401f37e3a21a3a8f4ad7a8c4f0b55d59f0 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Sun, 18 Oct 2026 16:01:14 +0200 Subject: [PATCH 005/142] feat(editor): block + table-cell backgrounds from theme tokens (B11.23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real block-level background for paragraphs, headings, quotes, callouts, toggles and list items: the block action menu gains a Background section (one row per swatch). Only the swatch NAME is stored, on the element's own style (synced by @lexical/yjs, carried explicitly through duplicate and the block clipboard, re-validated on paste), and BlockBackgroundPlugin — mounted by BrainstormEditor so previews and nested bodies paint it too — mirrors it to a data attribute that editor-theme.css maps to a token. The tokens are new: color.blockBackground.{gray..red}, defined in every built-in theme (light washes / dark tints by appearance), with a contrast ratchet holding text.primary at AA on each, and the canonical token-name snapshot re-synced (TOKEN_NAME_VERSION 4). Table cells get a Cell background submenu over the same tokens; hasCellBackgroundColor is on. Co-Authored-By: Claude Opus 5.5 --- apps/notes/src/editor/commands.tsx | 2 + packages/editor/src/block-background.test.ts | 222 ++++++++++++++++++ packages/editor/src/block-background.tsx | 175 ++++++++++++++ packages/editor/src/editor-theme.css | 57 +++++ packages/editor/src/editor.tsx | 2 + packages/editor/src/i18n.tsx | 2 + packages/editor/src/index.ts | 14 ++ .../editor/src/plugins/block-clipboard.ts | 23 +- packages/editor/src/plugins/block-ops.ts | 3 + .../src/plugins/standard-editing-plugins.tsx | 7 +- packages/editor/src/plugins/table-menu.tsx | 15 ++ packages/editor/src/plugins/table-ops.ts | 23 ++ packages/editor/src/plugins/table-plugin.tsx | 2 +- packages/sdk-types/src/token-names.ts | 11 +- packages/tokens/src/themes-contrast.test.ts | 13 + packages/tokens/src/themes.ts | 38 +++ packages/tokens/src/tokens.ts | 17 ++ 17 files changed, 622 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/block-background.test.ts create mode 100644 packages/editor/src/block-background.tsx diff --git a/apps/notes/src/editor/commands.tsx b/apps/notes/src/editor/commands.tsx index d13407b9..54c8f6f5 100644 --- a/apps/notes/src/editor/commands.tsx +++ b/apps/notes/src/editor/commands.tsx @@ -28,6 +28,7 @@ import { TocIcon, TodoListIcon, applySwatchToBlocks, + createBlockBackgroundActions, createEditorT, createEntityEmbedCommand, createInsertBlockActions, @@ -480,6 +481,7 @@ export const BLOCK_ACTIONS: readonly BlockCommand[] = [ ...sharedActions.slice(deleteActionIndex), ...SWATCH_COLORS.map((color) => colorAction(ColorTarget.Text, color)), ...SWATCH_COLORS.map((color) => colorAction(ColorTarget.Highlight, color)), + ...createBlockBackgroundActions(editorT), ]; /** Slash-trigger path: the caller's paragraph (the one with `/property`) diff --git a/packages/editor/src/block-background.test.ts b/packages/editor/src/block-background.test.ts new file mode 100644 index 00000000..5b65ce13 --- /dev/null +++ b/packages/editor/src/block-background.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment jsdom +import { createHeadlessEditor } from "@lexical/headless"; +import { $createListItemNode, $createListNode, ListItemNode, ListNode } from "@lexical/list"; +import { $createHeadingNode, HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, + TableCellNode, + TableNode, + TableRowNode, +} from "@lexical/table"; +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $isElementNode, + type LexicalEditor, + type NodeKey, + createEditor, +} from "lexical"; +import { describe, expect, it } from "vitest"; +import { + $canHaveBlockBackground, + BLOCK_BACKGROUND_ATTR, + applyBlockBackground, + blockBackgroundToken, + createBlockBackgroundActions, + readBlockBackground, + registerBlockBackground, + withBlockBackground, +} from "./block-background"; +import { CommandCategory } from "./block-command"; +import { createEditorT } from "./i18n"; +import { + insertBlocks, + parseBrainstormPayload, + serializeBlocksAsJson, +} from "./plugins/block-clipboard"; +import { duplicateBlocks } from "./plugins/block-ops"; +import { setTableCellBackground } from "./plugins/table-ops"; +import { SwatchColor } from "./text-color"; + +function makeEditor(): LexicalEditor { + return createEditor({ + nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, TableNode, TableRowNode, TableCellNode], + onError(error) { + throw error; + }, + }); +} + +function seed(editor: LexicalEditor): { + paragraph: NodeKey; + heading: NodeKey; + list: NodeKey; + item: NodeKey; +} { + const keys = { paragraph: "", heading: "", list: "", item: "" }; + editor.update( + () => { + const p = $createParagraphNode().append($createTextNode("para")); + const h = $createHeadingNode("h2").append($createTextNode("head")); + const item = $createListItemNode().append($createTextNode("item")); + const list = $createListNode("bullet").append(item); + $getRoot().clear().append(p, h, list); + keys.paragraph = p.getKey(); + keys.heading = h.getKey(); + keys.list = list.getKey(); + keys.item = item.getKey(); + }, + { discrete: true }, + ); + return keys; +} + +function styleOf(editor: LexicalEditor, key: NodeKey): string { + return editor.getEditorState().read(() => { + const node = $getNodeByKey(key); + return node && $isElementNode(node) ? node.getStyle() : ""; + }); +} + +describe("block background style", () => { + it("stores only a swatch name and reads unknown values as Default", () => { + const style = withBlockBackground("", SwatchColor.Blue); + expect(style).toBe("--bs-block-background: blue"); + expect(readBlockBackground(style)).toBe(SwatchColor.Blue); + expect(readBlockBackground("--bs-block-background: url(x)")).toBe(SwatchColor.Default); + expect(readBlockBackground("")).toBe(SwatchColor.Default); + expect(withBlockBackground(style, SwatchColor.Default)).toBe(""); + expect(blockBackgroundToken(SwatchColor.Red)).toBe("var(--color-block-background-red)"); + expect(blockBackgroundToken(SwatchColor.Default)).toBeNull(); + }); + + it("applies to paragraphs, headings and list items but not list / table containers", () => { + const editor = makeEditor(); + const keys = seed(editor); + applyBlockBackground( + editor, + new Set([keys.paragraph, keys.heading, keys.list, keys.item]), + SwatchColor.Green, + ); + expect(readBlockBackground(styleOf(editor, keys.paragraph))).toBe(SwatchColor.Green); + expect(readBlockBackground(styleOf(editor, keys.heading))).toBe(SwatchColor.Green); + expect(readBlockBackground(styleOf(editor, keys.item))).toBe(SwatchColor.Green); + expect(styleOf(editor, keys.list)).toBe(""); + editor.update( + () => { + expect($canHaveBlockBackground($createTableNode())).toBe(false); + }, + { discrete: true }, + ); + applyBlockBackground(editor, new Set([keys.paragraph]), SwatchColor.Default); + expect(styleOf(editor, keys.paragraph)).toBe(""); + }); + + it("mirrors the stored swatch onto the block DOM, live", () => { + const editor = makeEditor(); + const root = document.createElement("div"); + root.contentEditable = "true"; + document.body.append(root); + editor.setRootElement(root); + const keys = seed(editor); + const off = registerBlockBackground(editor); + applyBlockBackground(editor, new Set([keys.heading]), SwatchColor.Purple); + expect(editor.getElementByKey(keys.heading)?.getAttribute(BLOCK_BACKGROUND_ATTR)).toBe("purple"); + expect(editor.getElementByKey(keys.paragraph)?.hasAttribute(BLOCK_BACKGROUND_ATTR)).toBe(false); + applyBlockBackground(editor, new Set([keys.heading]), SwatchColor.Default); + expect(editor.getElementByKey(keys.heading)?.hasAttribute(BLOCK_BACKGROUND_ATTR)).toBe(false); + off(); + editor.setRootElement(null); + root.remove(); + }); + + it("survives duplicate and the block clipboard round-trip", () => { + const editor = makeEditor(); + const keys = seed(editor); + applyBlockBackground(editor, new Set([keys.paragraph]), SwatchColor.Yellow); + duplicateBlocks(editor, new Set([keys.paragraph])); + const styles = editor.getEditorState().read(() => + $getRoot() + .getChildren() + .map((c) => ($isElementNode(c) ? readBlockBackground(c.getStyle()) : null)), + ); + expect(styles.slice(0, 2)).toEqual([SwatchColor.Yellow, SwatchColor.Yellow]); + + const json = serializeBlocksAsJson(editor, new Set([keys.paragraph])); + const payload = parseBrainstormPayload(json); + expect(payload).not.toBeNull(); + if (!payload) return; + const [pasted] = insertBlocks(editor, payload.blocks, new Set()); + expect(readBlockBackground(styleOf(editor, pasted as NodeKey))).toBe(SwatchColor.Yellow); + }); + + it("re-validates a pasted background name", () => { + const editor = makeEditor(); + seed(editor); + const [pasted] = insertBlocks( + editor, + [ + { + type: "paragraph", + version: 1, + children: [], + blockBackground: "expression(alert(1))", + } as never, + ], + new Set(), + ); + expect(styleOf(editor, pasted as NodeKey)).toBe(""); + }); + + it("offers one Background action per swatch in the block action menu", () => { + const actions = createBlockBackgroundActions(createEditorT()); + expect(actions).toHaveLength(10); + expect(actions.every((a) => a.category === CommandCategory.Background)).toBe(true); + expect(actions.map((a) => a.label)).toContain("Blue"); + const editor = makeEditor(); + const keys = seed(editor); + actions + .find((a) => a.id === "block.background.red") + ?.run({ + editor, + blockKeys: new Set([keys.paragraph]), + }); + expect(readBlockBackground(styleOf(editor, keys.paragraph))).toBe(SwatchColor.Red); + }); +}); + +describe("table cell background", () => { + it("sets and clears the caret cell's token background", () => { + const editor = createHeadlessEditor({ + nodes: [TableNode, TableRowNode, TableCellNode], + onError(error) { + throw error; + }, + }); + let cellKey: NodeKey = ""; + editor.update( + () => { + const cell = $createTableCellNode(0).append( + $createParagraphNode().append($createTextNode("c")), + ); + const table = $createTableNode().append($createTableRowNode().append(cell)); + $getRoot().clear().append(table); + cell.getFirstChildOrThrow>().selectEnd(); + cellKey = cell.getKey(); + }, + { discrete: true }, + ); + setTableCellBackground(editor, blockBackgroundToken(SwatchColor.Blue)); + const read = () => + editor + .getEditorState() + .read(() => ($getNodeByKey(cellKey) as TableCellNode).getBackgroundColor()); + expect(read()).toBe("var(--color-block-background-blue)"); + setTableCellBackground(editor, null); + expect(read()).toBeNull(); + }); +}); diff --git a/packages/editor/src/block-background.tsx b/packages/editor/src/block-background.tsx new file mode 100644 index 00000000..137df701 --- /dev/null +++ b/packages/editor/src/block-background.tsx @@ -0,0 +1,175 @@ +/** + * Block-level background (B11.23) — a tint behind a whole paragraph, heading, + * quote, callout, toggle or list item, distinct from the inline highlight + * (`text-color.ts`) that only paints text runs. + * + * Persistence: the element node's own `__style` (`ElementNode.setStyle`), + * which `@lexical/yjs` syncs as a node property — so it lives in the document + * Y.Doc, round-trips across devices and peers, and survives duplicate / + * copy-paste through the editor's JSON clone paths (which carry it + * explicitly, since Lexical 0.21's element `exportJSON` omits `style`). Only + * the swatch NAME is stored (`--bs-block-background: blue`), never a CSS + * colour: a peer can't smuggle an arbitrary value into the page, and the + * rendered colour always comes from the active theme's + * `--color-block-background-` token (defined in every theme). + * + * Rendering: `BlockBackgroundPlugin` mirrors the stored name onto the block's + * DOM as `data-bs-block-background`, and `editor-theme.css` maps that to the + * token. Table cells use Lexical's own cell background with the same tokens. + */ + +import { $isListNode } from "@lexical/list"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $isTableCellNode, $isTableNode, $isTableRowNode } from "@lexical/table"; +import { + $getNodeByKey, + $getRoot, + $isElementNode, + $isRootNode, + type ElementNode, + type LexicalEditor, + type LexicalNode, + type NodeKey, +} from "lexical"; +import { type ReactNode, useEffect } from "react"; +import { type BlockCommand, CommandCategory } from "./block-command"; +import type { EditorT } from "./i18n"; +import { $isColumnNode, $isColumnsNode } from "./nodes/columns-node"; +import { SWATCH_COLORS, SwatchColor, mergeStyleProp } from "./text-color"; + +export const BLOCK_BACKGROUND_STYLE_PROP = "--bs-block-background"; +export const BLOCK_BACKGROUND_ATTR = "data-bs-block-background"; + +const HUES = new Set(SWATCH_COLORS.filter((c) => c !== SwatchColor.Default)); + +/** The swatch a block's stored style names; anything unknown reads Default. */ +export function readBlockBackground(style: string): SwatchColor { + for (const decl of style.split(";")) { + const idx = decl.indexOf(":"); + if (idx < 0) continue; + if (decl.slice(0, idx).trim() !== BLOCK_BACKGROUND_STYLE_PROP) continue; + const value = decl.slice(idx + 1).trim(); + return HUES.has(value) ? (value as SwatchColor) : SwatchColor.Default; + } + return SwatchColor.Default; +} + +/** The stored style with its block background set (Default clears it). */ +export function withBlockBackground(style: string, color: SwatchColor): string { + return mergeStyleProp( + style, + BLOCK_BACKGROUND_STYLE_PROP, + color === SwatchColor.Default ? null : color, + ); +} + +/** The theme token a hue renders through. */ +export function blockBackgroundToken(color: SwatchColor): string | null { + return color === SwatchColor.Default ? null : `var(--color-block-background-${color})`; +} + +/** Whether `node` takes a block background: a block-level element that is + * not a structural container (list, table, columns) or the root. */ +export function $canHaveBlockBackground(node: LexicalNode | null): node is ElementNode { + if (!node || !$isElementNode(node) || node.isInline() || $isRootNode(node)) return false; + if ($isListNode(node) || $isColumnsNode(node) || $isColumnNode(node)) return false; + return !($isTableNode(node) || $isTableRowNode(node) || $isTableCellNode(node)); +} + +/** Set (or clear, with Default) the background of every block in `keys`. */ +export function applyBlockBackground( + editor: LexicalEditor, + keys: ReadonlySet, + color: SwatchColor, +): void { + if (keys.size === 0) return; + editor.update( + () => { + for (const key of keys) { + const node = $getNodeByKey(key); + if ($canHaveBlockBackground(node)) node.setStyle(withBlockBackground(node.getStyle(), color)); + } + }, + { discrete: true }, + ); +} + +function syncElement(editor: LexicalEditor, key: NodeKey): void { + const dom = editor.getElementByKey(key); + if (!dom) return; + const node = $getNodeByKey(key); + const color = $canHaveBlockBackground(node) + ? readBlockBackground(node.getStyle()) + : SwatchColor.Default; + if (color === SwatchColor.Default) dom.removeAttribute(BLOCK_BACKGROUND_ATTR); + else dom.setAttribute(BLOCK_BACKGROUND_ATTR, color); +} + +function syncAll(editor: LexicalEditor): void { + editor.getEditorState().read(() => { + const walk = (node: LexicalNode) => { + if (!$isElementNode(node)) return; + syncElement(editor, node.getKey()); + for (const child of node.getChildren()) walk(child); + }; + walk($getRoot()); + }); +} + +/** Mirror stored block backgrounds onto the editor's DOM, now and on every + * update. Returns the unregister function. */ +export function registerBlockBackground(editor: LexicalEditor): () => void { + syncAll(editor); + const offUpdate = editor.registerUpdateListener(({ editorState, dirtyElements }) => { + if (dirtyElements.size === 0) return; + editorState.read(() => { + for (const key of dirtyElements.keys()) syncElement(editor, key); + }); + }); + const offRoot = editor.registerRootListener(() => syncAll(editor)); + return () => { + offUpdate(); + offRoot(); + }; +} + +/** Mounted by `` so every editor — read-only previews and + * nested transclusion bodies included — paints block backgrounds. */ +export function BlockBackgroundPlugin(): null { + const [editor] = useLexicalComposerContext(); + useEffect(() => registerBlockBackground(editor), [editor]); + return null; +} + +export function BackgroundSwatch({ color }: { color: SwatchColor }): ReactNode { + return ( +