From 21b2d99f674ef1360b29dfe950cce0eb89f03f31 Mon Sep 17 00:00:00 2001 From: krijn Date: Thu, 7 May 2026 17:24:36 +0200 Subject: [PATCH 01/20] Enhance Slash Menu and Toolbar Components - Added `SlashMenuContent` and `ToolbarSeparatorProps` to improve component functionality and type definitions. - Refactored `SlashMenu` to utilize new context and streamline item selection. - Introduced `fieldEditorTextEntryAttrs` utility for managing text entry attributes in editors. - Updated styles for better visual consistency and responsiveness in the Slash Menu. - Cleaned up imports and component structure for improved readability and maintainability. --- .../textEntrySurfaceSemantics.test.tsx | 219 +++++++++++++ packages/rendering/react/src/index.ts | 10 +- .../react/src/primitives/editor/content.tsx | 3 +- .../src/primitives/editor/inlineContent.tsx | 3 +- .../react/src/primitives/editor/root.tsx | 3 - .../primitives/editor/tableCellContent.tsx | 3 +- .../rendering/react/src/primitives/index.ts | 13 +- .../src/primitives/selection-toolbar/root.tsx | 17 +- .../src/primitives/slash-menu/content.tsx | 263 ++++++++++++++++ .../react/src/primitives/slash-menu/index.ts | 8 +- .../react/src/primitives/slash-menu/item.tsx | 16 +- .../react/src/primitives/slash-menu/list.tsx | 5 +- .../react/src/primitives/slash-menu/root.tsx | 106 +++++-- .../react/src/primitives/toolbar/index.ts | 2 +- .../src/primitives/toolbar/separator.tsx | 12 +- .../src/utils/fieldEditorTextEntryAttrs.ts | 9 + playground/src/components/SlashMenu.css | 219 +++++++------ playground/src/components/SlashMenu.tsx | 289 ++++-------------- 18 files changed, 792 insertions(+), 408 deletions(-) create mode 100644 packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx create mode 100644 packages/rendering/react/src/primitives/slash-menu/content.tsx create mode 100644 packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts diff --git a/packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx b/packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx new file mode 100644 index 0000000..d5c45a6 --- /dev/null +++ b/packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import type { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; +import { FIELD_EDITOR_SLOT_KEY } from "../constants/fieldEditor"; +import { Pen } from "../primitives/index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createEditor(options: Parameters[0] = {}) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderEditor(editor: ReturnType): Promise<{ + container: HTMLDivElement; + root: Root; +}> { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + return { container, root }; +} + +async function cleanupEditor( + editor: ReturnType, + root: Root, + container: HTMLElement, +): Promise { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function getFieldEditor(editor: ReturnType): FieldEditorImpl { + const fieldEditor = editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ); + if (!fieldEditor) { + throw new Error("Missing attached field editor"); + } + return fieldEditor; +} + +describe("@pen/react text entry surface semantics", () => { + it("marks only the active inline edit surface as a multiline textbox", async () => { + const editor = createEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = "semantic-second-block"; + + editor.apply([ + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + ]); + + const { container, root } = await renderEditor(editor); + const fieldEditor = getFieldEditor(editor); + const firstSurface = container.querySelector( + `[data-block-id="${firstBlockId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + const secondSurface = container.querySelector( + `[data-block-id="${secondBlockId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + + expect(firstSurface).not.toBeNull(); + expect(secondSurface).not.toBeNull(); + expect(container.querySelectorAll('[role="textbox"]')).toHaveLength(0); + + await act(async () => { + fieldEditor.activate(firstBlockId); + await flushAnimationFrames(2); + }); + + expect(firstSurface?.getAttribute("role")).toBe("textbox"); + expect(firstSurface?.getAttribute("aria-multiline")).toBe("true"); + expect(secondSurface?.hasAttribute("role")).toBe(false); + + await cleanupEditor(editor, root, container); + }); + + it("marks the active table cell edit surface as a multiline textbox", async () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "semantic-table", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-table-cell-text", + blockId: "semantic-table", + row: 0, + col: 0, + offset: 0, + text: "Alpha", + }, + { + type: "insert-table-cell-text", + blockId: "semantic-table", + row: 0, + col: 1, + offset: 0, + text: "Beta", + }, + ]); + + const { container, root } = await renderEditor(editor); + const fieldEditor = getFieldEditor(editor); + const firstCellSurface = container.querySelector( + `[data-block-id="semantic-table"] [data-cell-row="0"][data-cell-col="0"] [data-pen-field-editor-surface]`, + ) as HTMLElement | null; + const secondCellSurface = container.querySelector( + `[data-block-id="semantic-table"] [data-cell-row="0"][data-cell-col="1"] [data-pen-field-editor-surface]`, + ) as HTMLElement | null; + + expect(firstCellSurface).not.toBeNull(); + expect(secondCellSurface).not.toBeNull(); + expect(container.querySelectorAll('[role="textbox"]')).toHaveLength(0); + + await act(async () => { + editor.selectCell("semantic-table", 0, 0); + fieldEditor.activateCellFromElement?.( + "semantic-table", + 0, + 0, + firstCellSurface!, + ); + await flushAnimationFrames(2); + }); + + expect(firstCellSurface?.getAttribute("role")).toBe("textbox"); + expect(firstCellSurface?.getAttribute("aria-multiline")).toBe("true"); + expect(secondCellSurface?.hasAttribute("role")).toBe(false); + + await cleanupEditor(editor, root, container); + }); + + it("marks the expanded edit surface as a multiline textbox", async () => { + const editor = createEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = "semantic-expanded-second"; + + editor.apply([ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { type: "insert-text", blockId: secondBlockId, offset: 0, text: "World" }, + ]); + + const { container, root } = await renderEditor(editor); + const fieldEditor = getFieldEditor(editor); + const blocksHost = container.querySelector( + "[data-pen-editor-blocks-host]", + ) as HTMLElement | null; + + expect(blocksHost).not.toBeNull(); + expect(blocksHost?.hasAttribute("role")).toBe(false); + + await act(async () => { + fieldEditor.activate(firstBlockId); + editor.selectTextRange( + { blockId: firstBlockId, offset: 1 }, + { blockId: secondBlockId, offset: 2 }, + ); + await flushAnimationFrames(2); + }); + + expect(fieldEditor.getSnapshot().mode).toBe("expanded"); + expect(blocksHost?.getAttribute("role")).toBe("textbox"); + expect(blocksHost?.getAttribute("aria-multiline")).toBe("true"); + + await cleanupEditor(editor, root, container); + }); +}); diff --git a/packages/rendering/react/src/index.ts b/packages/rendering/react/src/index.ts index ae67334..78fc304 100644 --- a/packages/rendering/react/src/index.ts +++ b/packages/rendering/react/src/index.ts @@ -53,17 +53,22 @@ export { type ToolbarButtonProps, type ToolbarToggleProps, type ToolbarSelectProps, + type ToolbarSeparatorProps, } from "./primitives/toolbar/index"; // ── Slash menu primitives ─────────────────────────────────── export { SlashMenuRoot, + SlashMenuContent, SlashMenuInput, SlashMenuList, SlashMenuGroup, SlashMenuItem, SlashMenuEmpty, + useSlashMenuContext, + type SlashMenuContextValue, type SlashMenuRootProps, + type SlashMenuContentProps, type SlashMenuInputProps, type SlashMenuListProps, type SlashMenuGroupProps, @@ -371,9 +376,6 @@ export type { HistoryState, } from "@pen/history"; export type { MultiplayerState, PeerState } from "@pen/multiplayer"; -export type { - RemoteCursorState, - RemoteSelectionState, -} from "@pen/multiplayer"; +export type { RemoteCursorState, RemoteSelectionState } from "@pen/multiplayer"; export type { CreateEditorOptions } from "@pen/types"; diff --git a/packages/rendering/react/src/primitives/editor/content.tsx b/packages/rendering/react/src/primitives/editor/content.tsx index efce06b..b7b54da 100644 --- a/packages/rendering/react/src/primitives/editor/content.tsx +++ b/packages/rendering/react/src/primitives/editor/content.tsx @@ -37,6 +37,7 @@ import { } from "../../utils/flowCapabilities"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; import { AIStructuredTargetPreviewItem } from "../ai/structuredTargetPreview"; import { AutocompletePreviewBlock } from "./autocompletePreviewBlock"; import { EditorBlock } from "./block"; @@ -1362,7 +1363,7 @@ export function EditorContent(props: EditorContentProps) { {...(fieldEditorState.mode === "expanded" ? { [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: "", + ...fieldEditorTextEntryAttrs(true), } : {})} ref={blocksHostRef} diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 552c591..20c8542 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -12,6 +12,7 @@ import { useBlockTextSnapshot } from "../../hooks/useBlockTextSnapshot"; import { useFieldEditorState } from "../../hooks/useFieldEditorState"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; import { applyInlineDecorationsToDeltas } from "../../utils/inlineDecorations"; export interface InlineContentProps extends AsChildProps { @@ -145,7 +146,7 @@ export function InlineContent(props: InlineContentProps) { const primitiveProps: Record = { [DATA_ATTRS.inlineContent]: "", [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: isActiveSurface ? "" : undefined, + ...fieldEditorTextEntryAttrs(isActiveSurface), [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, "data-placeholder": showPlaceholder ? placeholder : undefined, style: showPlaceholder diff --git a/packages/rendering/react/src/primitives/editor/root.tsx b/packages/rendering/react/src/primitives/editor/root.tsx index c945945..2701f79 100644 --- a/packages/rendering/react/src/primitives/editor/root.tsx +++ b/packages/rendering/react/src/primitives/editor/root.tsx @@ -260,10 +260,7 @@ export function EditorRoot(props: EditorRootProps) { [DATA_ATTRS.focused]: focused || undefined, [DATA_ATTRS.readonly]: readonly || undefined, [DATA_ATTRS.empty]: isEmpty || undefined, - role: "textbox", tabIndex: -1, - "aria-multiline": "true", - "aria-readonly": readonly, }; return ( diff --git a/packages/rendering/react/src/primitives/editor/tableCellContent.tsx b/packages/rendering/react/src/primitives/editor/tableCellContent.tsx index 012b0b5..74f8c7d 100644 --- a/packages/rendering/react/src/primitives/editor/tableCellContent.tsx +++ b/packages/rendering/react/src/primitives/editor/tableCellContent.tsx @@ -5,6 +5,7 @@ import { useFieldEditorState } from "../../hooks/useFieldEditorState"; import { fullReconcileDeltasToDOM } from "../../field-editor/reconciler"; import { useCellTextSnapshot } from "../../hooks/useCellTextSnapshot"; import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; const TABLE_CELL_MIN_WIDTH = "6rem"; @@ -85,7 +86,7 @@ function cellSurfaceAttrs( return { [DATA_ATTRS.inlineContent]: "", [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: isActiveCell ? "" : undefined, + ...fieldEditorTextEntryAttrs(isActiveCell), [DATA_ATTRS.ignorePointerGesture]: isActiveCell ? "" : undefined, [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, [DATA_ATTRS.tableCellRow]: row, diff --git a/packages/rendering/react/src/primitives/index.ts b/packages/rendering/react/src/primitives/index.ts index 6dd61c9..4b75bb8 100644 --- a/packages/rendering/react/src/primitives/index.ts +++ b/packages/rendering/react/src/primitives/index.ts @@ -22,6 +22,7 @@ export { export { SlashMenuRoot, + SlashMenuContent, SlashMenuInput, SlashMenuList, SlashMenuGroup, @@ -81,10 +82,7 @@ export { AIInlineSession, AIInlineSessionActions, } from "./ai/index"; -export { - AISuggestionsRoot, - AISuggestionsPopover, -} from "./aiSuggestions/index"; +export { AISuggestionsRoot, AISuggestionsPopover } from "./aiSuggestions/index"; export { MultiplayerPresenceList, MultiplayerRemoteCursors, @@ -117,6 +115,7 @@ import { import { SlashMenuRoot, + SlashMenuContent, SlashMenuInput, SlashMenuList, SlashMenuGroup, @@ -176,10 +175,7 @@ import { AIInlineSession, AIInlineSessionActions, } from "./ai/index"; -import { - AISuggestionsRoot, - AISuggestionsPopover, -} from "./aiSuggestions/index"; +import { AISuggestionsRoot, AISuggestionsPopover } from "./aiSuggestions/index"; import { MultiplayerPresenceList, MultiplayerRemoteCursors, @@ -209,6 +205,7 @@ export const Pen = { }, SlashMenu: { Root: SlashMenuRoot, + Content: SlashMenuContent, Input: SlashMenuInput, List: SlashMenuList, Group: SlashMenuGroup, diff --git a/packages/rendering/react/src/primitives/selection-toolbar/root.tsx b/packages/rendering/react/src/primitives/selection-toolbar/root.tsx index 328fae9..861b481 100644 --- a/packages/rendering/react/src/primitives/selection-toolbar/root.tsx +++ b/packages/rendering/react/src/primitives/selection-toolbar/root.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext } from "react"; import type { Editor } from "@pen/types"; -import { EditorContext } from "../../context/editorContext"; +import { useEditorContext } from "../../context/editorContext"; import { ToolbarContext, type ToolbarContextValue, @@ -36,23 +36,12 @@ export function useSelectionToolbarContext(): SelectionToolbarContextValue { } export interface SelectionToolbarRootProps extends AsChildProps { - editor?: Editor; ref?: React.Ref; } export function SelectionToolbarRoot(props: SelectionToolbarRootProps) { - const { editor: editorProp, ...rest } = props; - const editorContext = useContext(EditorContext); - const editor = editorProp ?? editorContext?.editor; - - if (!editor) { - if (isDevelopmentEnvironment()) { - console.error( - "Pen: must be used within or receive an editor prop.", - ); - } - throw new Error("Missing editor for Pen.SelectionToolbar.Root"); - } + const { ...rest } = props; + const { editor } = useEditorContext(); const toolbarState = useToolbar(editor); const selectionToolbar = useSelectionToolbar(editor); diff --git a/packages/rendering/react/src/primitives/slash-menu/content.tsx b/packages/rendering/react/src/primitives/slash-menu/content.tsx new file mode 100644 index 0000000..1195ff5 --- /dev/null +++ b/packages/rendering/react/src/primitives/slash-menu/content.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useRef, useState } from "react"; +import type { Editor } from "@pen/types"; +import { EditorContext } from "../../context/editorContext"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; +import { composeRefs } from "../../utils/composeRefs"; +import { isDevelopmentEnvironment } from "../../utils/environment"; +import { useSlashMenuContext } from "./root"; + +type Side = "top" | "bottom"; + +interface SlashMenuPosition { + top: number; + left: number; + maxHeight: number; + side: Side; +} + +export interface SlashMenuContentProps extends AsChildProps { + /** + * Preferred placement side relative to the caret. + * @default "bottom" + */ + side?: Side; + /** Horizontal offset in px from the caret. @default 14 */ + alignOffset?: number; + /** Gap in px between the caret and menu. @default 10 */ + sideOffset?: number; + /** Minimum max-height in px when viewport space is tight. @default 120 */ + minHeight?: number; + /** Viewport padding in px. @default 16 */ + viewportPadding?: number; + ref?: React.Ref; +} + +export function SlashMenuContent(props: SlashMenuContentProps) { + const { + alignOffset = 14, + minHeight = 120, + ref, + side: preferredSide = "bottom", + sideOffset = 10, + viewportPadding = 16, + ...rest + } = props; + const editorContext = React.useContext(EditorContext); + const { + dismiss, + editor: controllerEditor, + items, + open, + query, + selectedIndex, + } = useSlashMenuContext(); + const editor = controllerEditor ?? editorContext?.editor; + const contentRef = useRef(null); + const [position, setPosition] = useState(null); + + useEffect(() => { + if (!open || !editor) { + setPosition(null); + return; + } + + let frame = 0; + const syncPosition = () => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + setPosition( + resolveMenuPosition({ + alignOffset, + editor, + element: contentRef.current, + minHeight, + preferredSide, + sideOffset, + viewportPadding, + }), + ); + }); + }; + + syncPosition(); + window.addEventListener("resize", syncPosition); + window.addEventListener("scroll", syncPosition, true); + document.addEventListener("selectionchange", syncPosition); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", syncPosition); + window.removeEventListener("scroll", syncPosition, true); + document.removeEventListener("selectionchange", syncPosition); + }; + }, [ + alignOffset, + editor, + items.length, + minHeight, + open, + preferredSide, + query, + sideOffset, + viewportPadding, + ]); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + if (contentRef.current?.contains(event.target as Node)) return; + dismiss(); + }; + + document.addEventListener("mousedown", handlePointerDown, true); + return () => + document.removeEventListener("mousedown", handlePointerDown, true); + }, [dismiss, open]); + + useEffect(() => { + if (!open) return; + + const selectedItemElement = + contentRef.current?.querySelector( + "[data-pen-slash-menu-item][data-selected]", + ); + selectedItemElement?.scrollIntoView({ block: "nearest" }); + }, [open, items.length, selectedIndex]); + + if (!editor) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: must be used within or .", + ); + } + throw new Error("Missing editor for Pen.SlashMenu.Content"); + } + + if (!open) return null; + + const primitiveProps: Record = { + "data-pen-slash-menu-content": "", + "data-side": position?.side ?? preferredSide, + style: { + position: "fixed" as const, + top: 0, + left: 0, + transform: position + ? `translate3d(${Math.round(position.left)}px, ${Math.round(position.top)}px, 0)` + : undefined, + maxHeight: position + ? `${Math.round(position.maxHeight)}px` + : undefined, + willChange: "transform", + zIndex: 60, + visibility: position ? ("visible" as const) : ("hidden" as const), + }, + }; + + return renderAsChild( + { ...rest, ref: composeRefs(ref, contentRef) }, + "div", + primitiveProps, + ); +} + +function resolveMenuPosition(options: { + alignOffset: number; + editor: Editor; + element: HTMLElement | null; + minHeight: number; + preferredSide: Side; + sideOffset: number; + viewportPadding: number; +}): SlashMenuPosition | null { + const { + alignOffset, + editor, + element, + minHeight, + preferredSide, + sideOffset, + viewportPadding, + } = options; + + if (typeof window === "undefined") return null; + + const anchorRect = getAnchorRect(editor); + if (!anchorRect) return null; + + const elementRect = element?.getBoundingClientRect(); + const menuWidth = elementRect?.width || 320; + const menuHeight = elementRect?.height || minHeight; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let side = preferredSide; + let top = + side === "top" + ? anchorRect.top - sideOffset - menuHeight + : anchorRect.bottom + sideOffset; + + if ( + side === "bottom" && + top + menuHeight > viewportHeight - viewportPadding + ) { + side = "top"; + top = anchorRect.top - sideOffset - menuHeight; + } + + if (side === "top" && top < viewportPadding) { + side = "bottom"; + top = anchorRect.bottom + sideOffset; + } + + const left = clamp( + anchorRect.left - alignOffset, + viewportPadding, + viewportWidth - menuWidth - viewportPadding, + ); + const availableHeight = + side === "bottom" + ? viewportHeight - top - viewportPadding + : anchorRect.top - sideOffset - viewportPadding; + + return { + top: Math.max(viewportPadding, top), + left, + maxHeight: Math.max(minHeight, availableHeight), + side, + }; +} + +function getAnchorRect(editor: Editor): DOMRect | null { + if (typeof window === "undefined") return null; + + const domSelection = window.getSelection(); + if (domSelection?.rangeCount) { + const range = domSelection.getRangeAt(0).cloneRange(); + range.collapse(false); + const rect = + Array.from(range.getClientRects()).at(-1) ?? + range.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + return rect; + } + } + + const editorSelection = editor.selection; + if (editorSelection?.type !== "text") return null; + + const blockElement = document.querySelector( + `[data-block-id="${escapeCssAttributeValue(editorSelection.anchor.blockId)}"]`, + ); + return blockElement?.getBoundingClientRect() ?? null; +} + +function escapeCssAttributeValue(value: string): string { + return value.replace(/["\\]/g, "\\$&"); +} + +function clamp(value: number, min: number, max: number) { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +} diff --git a/packages/rendering/react/src/primitives/slash-menu/index.ts b/packages/rendering/react/src/primitives/slash-menu/index.ts index 9f813d2..1556417 100644 --- a/packages/rendering/react/src/primitives/slash-menu/index.ts +++ b/packages/rendering/react/src/primitives/slash-menu/index.ts @@ -1,4 +1,10 @@ -export { SlashMenuRoot, useSlashMenuContext, type SlashMenuRootProps } from "./root"; +export { + SlashMenuRoot, + useSlashMenuContext, + type SlashMenuContextValue, + type SlashMenuRootProps, +} from "./root"; +export { SlashMenuContent, type SlashMenuContentProps } from "./content"; export { SlashMenuInput, type SlashMenuInputProps } from "./input"; export { SlashMenuList, type SlashMenuListProps } from "./list"; export { SlashMenuGroup, type SlashMenuGroupProps } from "./group"; diff --git a/packages/rendering/react/src/primitives/slash-menu/item.tsx b/packages/rendering/react/src/primitives/slash-menu/item.tsx index 68dcad6..62b0593 100644 --- a/packages/rendering/react/src/primitives/slash-menu/item.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/item.tsx @@ -4,28 +4,38 @@ import { renderAsChild, type AsChildProps } from "../../utils/asChild"; export interface SlashMenuItemProps extends AsChildProps { blockType?: string; + index?: number; onSelect?: () => void; ref?: React.Ref; [key: string]: unknown; } export function SlashMenuItem(props: SlashMenuItemProps) { - const { blockType, onSelect, ...rest } = props; - const { confirm } = useSlashMenuContext(); + const { blockType, index, onSelect, ...rest } = props; + const { confirm, select, selectedIndex } = useSlashMenuContext(); + const isSelected = index != null && index === selectedIndex; const handleClick = () => { if (onSelect) { onSelect(); } else { - confirm(); + confirm(index); } }; + const handleMouseEnter = () => { + if (index == null) return; + select(index); + }; + const primitiveProps: Record = { "data-pen-slash-menu-item": "", "data-block-type": blockType, + "data-selected": isSelected || undefined, role: "option", + "aria-selected": isSelected, onClick: handleClick, + onMouseEnter: handleMouseEnter, }; return renderAsChild(rest, "div", primitiveProps); diff --git a/packages/rendering/react/src/primitives/slash-menu/list.tsx b/packages/rendering/react/src/primitives/slash-menu/list.tsx index a855326..9c97e0d 100644 --- a/packages/rendering/react/src/primitives/slash-menu/list.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/list.tsx @@ -15,7 +15,7 @@ export interface SlashMenuListProps extends AsChildProps { */ export function SlashMenuList(props: SlashMenuListProps) { const { children, ...rest } = props; - const { items, selectedIndex, confirm } = useSlashMenuContext(); + const { items } = useSlashMenuContext(); const hasManualChildren = React.Children.count(children) > 0; @@ -40,8 +40,7 @@ export function SlashMenuList(props: SlashMenuListProps) { confirm(idx)} + index={idx} > {item.display.title} diff --git a/packages/rendering/react/src/primitives/slash-menu/root.tsx b/packages/rendering/react/src/primitives/slash-menu/root.tsx index ed0603b..138b802 100644 --- a/packages/rendering/react/src/primitives/slash-menu/root.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/root.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useEffect } from "react"; -import { useEditorContext } from "../../context/editorContext"; +import React, { createContext, useContext, useEffect, useRef } from "react"; +import type { Editor } from "@pen/types"; +import { EditorContext } from "../../context/editorContext"; import { useSlashMenu, type SlashMenuState, @@ -8,7 +9,10 @@ import { import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { isDevelopmentEnvironment } from "../../utils/environment"; -type SlashMenuContextValue = SlashMenuState & SlashMenuActions; +export type SlashMenuContextValue = SlashMenuState & + SlashMenuActions & { + editor?: Editor; + }; const SlashMenuContext = createContext(null); @@ -26,57 +30,121 @@ export function useSlashMenuContext(): SlashMenuContextValue { } export interface SlashMenuRootProps extends AsChildProps { + controller?: SlashMenuContextValue; + editor?: Editor; open?: boolean; onOpenChange?: (open: boolean) => void; ref?: React.Ref; } export function SlashMenuRoot(props: SlashMenuRootProps) { - const { open: controlledOpen, onOpenChange, ...rest } = props; - const { editor } = useEditorContext(); + const { controller, editor, ...rest } = props; + if (controller) { + return ( + + ); + } + + return ; +} + +type UncontrolledSlashMenuRootProps = Omit; + +function UncontrolledSlashMenuRoot(props: UncontrolledSlashMenuRootProps) { + const { editor: editorProp, ...rest } = props; + const editorContext = useContext(EditorContext); + const editor = editorProp ?? editorContext?.editor; + + if (!editor) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: must be used within or receive an editor prop.", + ); + } + throw new Error("Missing editor for Pen.SlashMenu.Root"); + } + const menuState = useSlashMenu(editor); - const isOpen = controlledOpen ?? menuState.open; + return ( + + ); +} + +type SlashMenuRootContentProps = Omit< + SlashMenuRootProps, + "controller" | "editor" +> & { + controller: SlashMenuContextValue; + editor?: Editor; +}; + +function SlashMenuRootContent(props: SlashMenuRootContentProps) { + const { + controller, + editor: editorProp, + open: controlledOpen, + onOpenChange, + ...rest + } = props; + const editorContext = useContext(EditorContext); + const editor = editorProp ?? controller.editor ?? editorContext?.editor; + + const isOpen = controlledOpen ?? controller.open; const wrappedState: SlashMenuContextValue = { - ...menuState, + ...controller, + editor, + open: isOpen, dismiss: () => { - menuState.dismiss(); + controller.dismiss(); onOpenChange?.(false); }, - confirm: () => { - menuState.confirm(); + confirm: (index?: number) => { + controller.confirm(index); onOpenChange?.(false); }, }; + const wrappedStateRef = useRef(wrappedState); + wrappedStateRef.current = wrappedState; useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { + const currentState = wrappedStateRef.current; + switch (event.key) { case "ArrowDown": event.preventDefault(); - wrappedState.select( + currentState.select( Math.min( - wrappedState.selectedIndex + 1, - wrappedState.items.length - 1, + currentState.selectedIndex + 1, + currentState.items.length - 1, ), ); break; case "ArrowUp": event.preventDefault(); - wrappedState.select( - Math.max(wrappedState.selectedIndex - 1, 0), + currentState.select( + Math.max(currentState.selectedIndex - 1, 0), ); break; case "Enter": event.preventDefault(); - wrappedState.confirm(); + currentState.confirm(); break; case "Escape": event.preventDefault(); - wrappedState.dismiss(); + currentState.dismiss(); break; } }; @@ -84,10 +152,10 @@ export function SlashMenuRoot(props: SlashMenuRootProps) { document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); - }); + }, [isOpen]); const primitiveProps: Record = { - role: "listbox", + role: "dialog", "data-pen-slash-menu": "", "data-open": isOpen || undefined, }; diff --git a/packages/rendering/react/src/primitives/toolbar/index.ts b/packages/rendering/react/src/primitives/toolbar/index.ts index 7d5626c..55ce3d3 100644 --- a/packages/rendering/react/src/primitives/toolbar/index.ts +++ b/packages/rendering/react/src/primitives/toolbar/index.ts @@ -3,4 +3,4 @@ export { ToolbarGroup, type ToolbarGroupProps } from "./group"; export { ToolbarButton, type ToolbarButtonProps } from "./button"; export { ToolbarToggle, type ToolbarToggleProps } from "./toggle"; export { ToolbarSelect, type ToolbarSelectProps } from "./select"; -export { ToolbarSeparator } from "./separator"; +export { ToolbarSeparator, type ToolbarSeparatorProps } from "./separator"; diff --git a/packages/rendering/react/src/primitives/toolbar/separator.tsx b/packages/rendering/react/src/primitives/toolbar/separator.tsx index 5deef73..f947390 100644 --- a/packages/rendering/react/src/primitives/toolbar/separator.tsx +++ b/packages/rendering/react/src/primitives/toolbar/separator.tsx @@ -1,5 +1,13 @@ import React from "react"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; -export function ToolbarSeparator() { - return
; +export interface ToolbarSeparatorProps extends AsChildProps { + ref?: React.Ref; +} + +export function ToolbarSeparator(props: ToolbarSeparatorProps) { + return renderAsChild(props, "div", { + role: "separator", + "data-pen-toolbar-separator": "", + }); } diff --git a/packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts b/packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts new file mode 100644 index 0000000..ad689ce --- /dev/null +++ b/packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts @@ -0,0 +1,9 @@ +import { DATA_ATTRS } from "./dataAttributes"; + +export function fieldEditorTextEntryAttrs(isActive: boolean): Record { + return { + [DATA_ATTRS.fieldEditorActiveSurface]: isActive ? "" : undefined, + role: isActive ? "textbox" : undefined, + "aria-multiline": isActive ? true : undefined, + }; +} diff --git a/playground/src/components/SlashMenu.css b/playground/src/components/SlashMenu.css index 4476180..496939c 100644 --- a/playground/src/components/SlashMenu.css +++ b/playground/src/components/SlashMenu.css @@ -1,156 +1,151 @@ -[data-pen-slash-menu] { - display: none; -} - -[data-pen-slash-menu][data-open] { - display: flex; - flex-direction: column; - position: fixed; - z-index: 60; - width: min(300px, calc(100vw - 24px)); - padding: 4px; - background: color-mix(in srgb, var(--surface) 94%, var(--bg)); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: - 0 12px 28px rgba(15, 23, 42, 0.14), - 0 2px 6px rgba(15, 23, 42, 0.08); - backdrop-filter: blur(14px); +[data-pen-slash-menu-content] { + display: flex; + flex-direction: column; + width: min(300px, calc(100vw - 24px)); + padding: 4px; + background: color-mix(in srgb, var(--surface) 94%, var(--bg)); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: + 0 12px 28px rgba(15, 23, 42, 0.14), + 0 2px 6px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(14px); + overflow: hidden; } [data-pen-slash-menu-list] { - display: flex; - flex-direction: column; - gap: 6px; - overflow-y: auto; - padding: 2px 1px; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + padding: 2px 1px; } [data-pen-slash-menu-group] { - display: flex; - flex-direction: column; - gap: 2px; + display: flex; + flex-direction: column; + gap: 2px; } [data-pen-slash-menu-group-heading] { - padding: 4px 8px 1px; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-muted); - opacity: 0.82; + padding: 4px 8px 1px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + opacity: 0.82; } [data-pen-slash-menu-item] { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - width: 100%; - padding: 6px 8px; - font-family: inherit; - color: var(--text); - background: transparent; - border: 0; - border-radius: 8px; - cursor: pointer; - text-align: left; - transition: - background 0.12s ease, - box-shadow 0.12s ease; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + font-family: inherit; + color: var(--text); + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: + background 0.12s ease, + box-shadow 0.12s ease; } [data-pen-slash-menu-item]:hover, [data-pen-slash-menu-item][data-selected] { - background: color-mix(in srgb, var(--bg) 76%, transparent); + background: color-mix(in srgb, var(--bg) 76%, transparent); } [data-pen-slash-menu-item]:focus-visible { - outline: none; - background: color-mix(in srgb, var(--bg) 76%, transparent); - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--border) 80%, transparent), - 0 0 0 3px rgba(113, 113, 122, 0.08); + outline: none; + background: color-mix(in srgb, var(--bg) 76%, transparent); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--border) 80%, transparent), + 0 0 0 3px rgba(113, 113, 122, 0.08); } .slash-menu-item-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 6px; - background: color-mix(in srgb, var(--bg) 92%, transparent); - border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 9px; - font-weight: 650; - line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: color-mix(in srgb, var(--bg) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 650; + line-height: 1; } [data-pen-slash-menu-item][data-selected] .slash-menu-item-icon { - color: color-mix(in srgb, var(--accent-fg) 92%, var(--text)); - background: color-mix(in srgb, var(--accent) 18%, var(--bg)); - border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + color: color-mix(in srgb, var(--accent-fg) 92%, var(--text)); + background: color-mix(in srgb, var(--accent) 18%, var(--bg)); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } .slash-menu-item-content { - display: flex; - flex-direction: column; - min-width: 0; - gap: 1px; + display: flex; + flex-direction: column; + min-width: 0; + gap: 1px; } .slash-menu-item-title { - font-size: 11px; - font-weight: 550; - line-height: 1.2; - color: var(--text); + font-size: 11px; + font-weight: 550; + line-height: 1.2; + color: var(--text); } .slash-menu-item-description { - font-size: 10px; - line-height: 1.2; - color: var(--text-muted); - opacity: 0.9; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-size: 10px; + line-height: 1.2; + color: var(--text-muted); + opacity: 0.9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .slash-menu-item-alias { - flex-shrink: 0; - padding-left: 6px; - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 9px; - font-weight: 500; - opacity: 0.72; + flex-shrink: 0; + padding-left: 6px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 500; + opacity: 0.72; } [data-pen-slash-menu-empty] { - padding: 12px 10px; - color: var(--text-muted); - text-align: center; - border: 1px dashed var(--border); - border-radius: 8px; - background: color-mix(in srgb, var(--bg) 80%, transparent); - font-size: 11px; + padding: 12px 10px; + color: var(--text-muted); + text-align: center; + border: 1px dashed var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--bg) 80%, transparent); + font-size: 11px; } @media (prefers-color-scheme: dark) { - [data-pen-slash-menu][data-open] { - background: color-mix(in srgb, var(--surface) 94%, black); - box-shadow: - 0 14px 32px rgba(0, 0, 0, 0.34), - 0 2px 8px rgba(0, 0, 0, 0.22); - } - - [data-pen-slash-menu-item]:focus-visible { - box-shadow: - inset 0 0 0 1px var(--border), - 0 0 0 3px rgba(161, 161, 170, 0.12); - } + [data-pen-slash-menu-content] { + background: color-mix(in srgb, var(--surface) 94%, black); + box-shadow: + 0 14px 32px rgba(0, 0, 0, 0.34), + 0 2px 8px rgba(0, 0, 0, 0.22); + } + + [data-pen-slash-menu-item]:focus-visible { + box-shadow: + inset 0 0 0 1px var(--border), + 0 0 0 3px rgba(161, 161, 170, 0.12); + } } diff --git a/playground/src/components/SlashMenu.tsx b/playground/src/components/SlashMenu.tsx index 2ccc9e6..b865a4f 100644 --- a/playground/src/components/SlashMenu.tsx +++ b/playground/src/components/SlashMenu.tsx @@ -1,29 +1,14 @@ import "./SlashMenu.css"; import type { Editor } from "@pen/types"; -import { useEffect, useRef, useState } from "react"; -import { useSlashMenu, type SlashMenuState } from "@pen/react"; +import { Pen, useSlashMenuContext, type SlashMenuState } from "@pen/react"; interface SlashMenuProps { editor: Editor; } -interface SlashMenuPosition { - top: number; - left: number; - maxHeight: number; -} - type SlashMenuItemData = SlashMenuState["items"][number]; const EMPTY_RESULTS_MESSAGE = "No matching commands"; -const MENU_GAP = 10; -const VIEWPORT_PADDING = 16; -const MIN_MENU_HEIGHT = 120; -const FALLBACK_POSITION: SlashMenuPosition = { - top: 96, - left: 0, - maxHeight: 320, -}; const BLOCK_TYPE_ICONS: Record = { paragraph: "P", heading: "H", @@ -39,10 +24,6 @@ const BLOCK_TYPE_ICONS: Record = { toggle: ">>", }; -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - function formatGroupLabel(group?: string) { if (!group) return "Other"; return group @@ -60,53 +41,20 @@ function getItemIcon(item: SlashMenuItemData) { ); } -function getSlashQuery(editor: Editor) { - const selection = editor.selection; - if (selection?.type !== "text") return null; - - const isCollapsed = - selection.anchor.blockId === selection.focus.blockId && - selection.anchor.offset === selection.focus.offset; - if (!isCollapsed) return null; - - const block = editor.getBlock(selection.anchor.blockId); - const text = block?.textContent() ?? ""; - if (!text.startsWith("/")) return null; - - return text.slice(1); -} - -function getAnchorRect(editor: Editor) { - if (typeof window === "undefined") return null; - - const domSelection = window.getSelection(); - if (domSelection?.rangeCount) { - const range = domSelection.getRangeAt(0).cloneRange(); - range.collapse(false); - - const rect = - Array.from(range.getClientRects()).at(-1) ?? range.getBoundingClientRect(); - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - - const editorSelection = editor.selection; - if (editorSelection?.type !== "text") return null; - - const blockElement = document.querySelector( - `[data-block-id="${editorSelection.anchor.blockId}"]`, +export function SlashMenu({ editor }: SlashMenuProps) { + return ( + + +
+ +
+
+
); - return blockElement?.getBoundingClientRect() ?? null; } -export function SlashMenu({ editor }: SlashMenuProps) { - const { open, query, items, selectedIndex, setQuery, select, confirm, dismiss } = - useSlashMenu(editor); - - const menuRef = useRef(null); - const [position, setPosition] = useState(FALLBACK_POSITION); - +function SlashMenuContent() { + const { items } = useSlashMenuContext(); const groupedItems = new Map< string, Array<{ index: number; item: SlashMenuItemData }> @@ -118,195 +66,66 @@ export function SlashMenu({ editor }: SlashMenuProps) { groupedItems.set(groupLabel, groupEntries); }); - const slashMenuGroups = Array.from(groupedItems.entries()).map( + const slashMenuGroupItems = Array.from(groupedItems.entries()).map( ([groupLabel, groupItems]) => { const groupItemElements = groupItems.map(({ item, index }) => { const itemAlias = item.display.aliases?.[0]; - const isSelected = index === selectedIndex; return ( - + {itemAlias ? ( + + ) : null} + + ); }); return ( -
-
- {groupLabel} -
+ {groupItemElements} -
+ ); }, ); - const menuStyle = { - top: `${position.top}px`, - left: `${position.left}px`, - maxHeight: `${position.maxHeight}px`, - }; - - function updatePosition() { - const anchorRect = getAnchorRect(editor); - if (!anchorRect) return; - - const menuWidth = menuRef.current?.offsetWidth ?? 320; - const nextLeft = clamp( - anchorRect.left - 14, - VIEWPORT_PADDING, - window.innerWidth - menuWidth - VIEWPORT_PADDING, - ); - const nextTop = anchorRect.bottom + MENU_GAP; - const availableHeight = - window.innerHeight - nextTop - VIEWPORT_PADDING; - - setPosition({ - top: nextTop, - left: nextLeft, - maxHeight: Math.max(MIN_MENU_HEIGHT, availableHeight), - }); - } - - useEffect(() => { - if (!open) return; - - const syncQueryFromEditor = () => { - const nextQuery = getSlashQuery(editor); - if (nextQuery === null) { - dismiss(); - return; - } - setQuery(nextQuery); - }; - - syncQueryFromEditor(); - const unsubscribeDocument = editor.onDocumentCommit(syncQueryFromEditor); - const unsubscribeSelection = editor.onSelectionChange(syncQueryFromEditor); - - return () => { - unsubscribeDocument(); - unsubscribeSelection(); - }; - }, [dismiss, editor, open, setQuery]); - - useEffect(() => { - if (!open) return; - - const syncPosition = () => { - window.requestAnimationFrame(updatePosition); - }; - - syncPosition(); - window.addEventListener("resize", syncPosition); - window.addEventListener("scroll", syncPosition, true); - document.addEventListener("selectionchange", syncPosition); - - return () => { - window.removeEventListener("resize", syncPosition); - window.removeEventListener("scroll", syncPosition, true); - document.removeEventListener("selectionchange", syncPosition); - }; - }, [editor, open, query, items.length]); - - useEffect(() => { - if (!open) return; - - const handleKeyDown = (event: KeyboardEvent) => { - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - select(selectedIndex + 1); - break; - case "ArrowUp": - event.preventDefault(); - select(selectedIndex - 1); - break; - case "Enter": - event.preventDefault(); - confirm(); - break; - case "Escape": - event.preventDefault(); - dismiss(); - break; - } - }; - - document.addEventListener("keydown", handleKeyDown, true); - return () => { - document.removeEventListener("keydown", handleKeyDown, true); - }; - }, [confirm, dismiss, open, select, selectedIndex]); - - useEffect(() => { - if (!open) return; - - const handlePointerDown = (event: MouseEvent) => { - if (menuRef.current?.contains(event.target as Node)) return; - dismiss(); - }; - - document.addEventListener("mousedown", handlePointerDown, true); - return () => { - document.removeEventListener("mousedown", handlePointerDown, true); - }; - }, [dismiss, open]); - - useEffect(() => { - if (!open) return; - - const selectedItemElement = menuRef.current?.querySelector( - "[data-pen-slash-menu-item][data-selected]", - ); - selectedItemElement?.scrollIntoView({ block: "nearest" }); - }, [open, selectedIndex]); - - if (!open) return null; - return ( -
-
- {slashMenuGroups.length > 0 ? ( - slashMenuGroups - ) : ( -
- {EMPTY_RESULTS_MESSAGE} -
- )} -
-
+ <> + +
{slashMenuGroupItems}
+
+ +
{EMPTY_RESULTS_MESSAGE}
+
+ ); } From a4804d020c8f115fd8b3aca715f661306dc4744f Mon Sep 17 00:00:00 2001 From: krijn Date: Fri, 8 May 2026 02:42:08 +0200 Subject: [PATCH 02/20] fix --- playground/server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/server/index.ts b/playground/server/index.ts index 7895bbe..a4690e0 100644 --- a/playground/server/index.ts +++ b/playground/server/index.ts @@ -1724,7 +1724,7 @@ function normalizePlaygroundSelectionModelName( modelName: string | undefined, ): string { if (!modelName) { - return "claude-3-haiku-20240307"; + return "claude-haiku-4-5"; } return normalizePlaygroundModelName(modelName); From 322d44434a24d0d4f7fd6c025c9cf78634eb92e6 Mon Sep 17 00:00:00 2001 From: krijn Date: Thu, 7 May 2026 17:24:36 +0200 Subject: [PATCH 03/20] Enhance Slash Menu and Toolbar Components - Added `SlashMenuContent` and `ToolbarSeparatorProps` to improve component functionality and type definitions. - Refactored `SlashMenu` to utilize new context and streamline item selection. - Introduced `fieldEditorTextEntryAttrs` utility for managing text entry attributes in editors. - Updated styles for better visual consistency and responsiveness in the Slash Menu. - Cleaned up imports and component structure for improved readability and maintainability. --- .../textEntrySurfaceSemantics.test.tsx | 219 +++++++++++++ packages/rendering/react/src/index.ts | 10 +- .../react/src/primitives/editor/content.tsx | 3 +- .../src/primitives/editor/inlineContent.tsx | 3 +- .../react/src/primitives/editor/root.tsx | 3 - .../primitives/editor/tableCellContent.tsx | 3 +- .../rendering/react/src/primitives/index.ts | 13 +- .../src/primitives/selection-toolbar/root.tsx | 17 +- .../src/primitives/slash-menu/content.tsx | 263 ++++++++++++++++ .../react/src/primitives/slash-menu/index.ts | 8 +- .../react/src/primitives/slash-menu/item.tsx | 16 +- .../react/src/primitives/slash-menu/list.tsx | 5 +- .../react/src/primitives/slash-menu/root.tsx | 106 +++++-- .../react/src/primitives/toolbar/index.ts | 2 +- .../src/primitives/toolbar/separator.tsx | 12 +- .../src/utils/fieldEditorTextEntryAttrs.ts | 9 + playground/src/components/SlashMenu.css | 219 +++++++------ playground/src/components/SlashMenu.tsx | 289 ++++-------------- 18 files changed, 792 insertions(+), 408 deletions(-) create mode 100644 packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx create mode 100644 packages/rendering/react/src/primitives/slash-menu/content.tsx create mode 100644 packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts diff --git a/packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx b/packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx new file mode 100644 index 0000000..d5c45a6 --- /dev/null +++ b/packages/rendering/react/src/__tests__/textEntrySurfaceSemantics.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import type { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; +import { FIELD_EDITOR_SLOT_KEY } from "../constants/fieldEditor"; +import { Pen } from "../primitives/index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createEditor(options: Parameters[0] = {}) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderEditor(editor: ReturnType): Promise<{ + container: HTMLDivElement; + root: Root; +}> { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + return { container, root }; +} + +async function cleanupEditor( + editor: ReturnType, + root: Root, + container: HTMLElement, +): Promise { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function getFieldEditor(editor: ReturnType): FieldEditorImpl { + const fieldEditor = editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ); + if (!fieldEditor) { + throw new Error("Missing attached field editor"); + } + return fieldEditor; +} + +describe("@pen/react text entry surface semantics", () => { + it("marks only the active inline edit surface as a multiline textbox", async () => { + const editor = createEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = "semantic-second-block"; + + editor.apply([ + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + ]); + + const { container, root } = await renderEditor(editor); + const fieldEditor = getFieldEditor(editor); + const firstSurface = container.querySelector( + `[data-block-id="${firstBlockId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + const secondSurface = container.querySelector( + `[data-block-id="${secondBlockId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + + expect(firstSurface).not.toBeNull(); + expect(secondSurface).not.toBeNull(); + expect(container.querySelectorAll('[role="textbox"]')).toHaveLength(0); + + await act(async () => { + fieldEditor.activate(firstBlockId); + await flushAnimationFrames(2); + }); + + expect(firstSurface?.getAttribute("role")).toBe("textbox"); + expect(firstSurface?.getAttribute("aria-multiline")).toBe("true"); + expect(secondSurface?.hasAttribute("role")).toBe(false); + + await cleanupEditor(editor, root, container); + }); + + it("marks the active table cell edit surface as a multiline textbox", async () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "semantic-table", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-table-cell-text", + blockId: "semantic-table", + row: 0, + col: 0, + offset: 0, + text: "Alpha", + }, + { + type: "insert-table-cell-text", + blockId: "semantic-table", + row: 0, + col: 1, + offset: 0, + text: "Beta", + }, + ]); + + const { container, root } = await renderEditor(editor); + const fieldEditor = getFieldEditor(editor); + const firstCellSurface = container.querySelector( + `[data-block-id="semantic-table"] [data-cell-row="0"][data-cell-col="0"] [data-pen-field-editor-surface]`, + ) as HTMLElement | null; + const secondCellSurface = container.querySelector( + `[data-block-id="semantic-table"] [data-cell-row="0"][data-cell-col="1"] [data-pen-field-editor-surface]`, + ) as HTMLElement | null; + + expect(firstCellSurface).not.toBeNull(); + expect(secondCellSurface).not.toBeNull(); + expect(container.querySelectorAll('[role="textbox"]')).toHaveLength(0); + + await act(async () => { + editor.selectCell("semantic-table", 0, 0); + fieldEditor.activateCellFromElement?.( + "semantic-table", + 0, + 0, + firstCellSurface!, + ); + await flushAnimationFrames(2); + }); + + expect(firstCellSurface?.getAttribute("role")).toBe("textbox"); + expect(firstCellSurface?.getAttribute("aria-multiline")).toBe("true"); + expect(secondCellSurface?.hasAttribute("role")).toBe(false); + + await cleanupEditor(editor, root, container); + }); + + it("marks the expanded edit surface as a multiline textbox", async () => { + const editor = createEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = "semantic-expanded-second"; + + editor.apply([ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { type: "insert-text", blockId: secondBlockId, offset: 0, text: "World" }, + ]); + + const { container, root } = await renderEditor(editor); + const fieldEditor = getFieldEditor(editor); + const blocksHost = container.querySelector( + "[data-pen-editor-blocks-host]", + ) as HTMLElement | null; + + expect(blocksHost).not.toBeNull(); + expect(blocksHost?.hasAttribute("role")).toBe(false); + + await act(async () => { + fieldEditor.activate(firstBlockId); + editor.selectTextRange( + { blockId: firstBlockId, offset: 1 }, + { blockId: secondBlockId, offset: 2 }, + ); + await flushAnimationFrames(2); + }); + + expect(fieldEditor.getSnapshot().mode).toBe("expanded"); + expect(blocksHost?.getAttribute("role")).toBe("textbox"); + expect(blocksHost?.getAttribute("aria-multiline")).toBe("true"); + + await cleanupEditor(editor, root, container); + }); +}); diff --git a/packages/rendering/react/src/index.ts b/packages/rendering/react/src/index.ts index ae67334..78fc304 100644 --- a/packages/rendering/react/src/index.ts +++ b/packages/rendering/react/src/index.ts @@ -53,17 +53,22 @@ export { type ToolbarButtonProps, type ToolbarToggleProps, type ToolbarSelectProps, + type ToolbarSeparatorProps, } from "./primitives/toolbar/index"; // ── Slash menu primitives ─────────────────────────────────── export { SlashMenuRoot, + SlashMenuContent, SlashMenuInput, SlashMenuList, SlashMenuGroup, SlashMenuItem, SlashMenuEmpty, + useSlashMenuContext, + type SlashMenuContextValue, type SlashMenuRootProps, + type SlashMenuContentProps, type SlashMenuInputProps, type SlashMenuListProps, type SlashMenuGroupProps, @@ -371,9 +376,6 @@ export type { HistoryState, } from "@pen/history"; export type { MultiplayerState, PeerState } from "@pen/multiplayer"; -export type { - RemoteCursorState, - RemoteSelectionState, -} from "@pen/multiplayer"; +export type { RemoteCursorState, RemoteSelectionState } from "@pen/multiplayer"; export type { CreateEditorOptions } from "@pen/types"; diff --git a/packages/rendering/react/src/primitives/editor/content.tsx b/packages/rendering/react/src/primitives/editor/content.tsx index efce06b..b7b54da 100644 --- a/packages/rendering/react/src/primitives/editor/content.tsx +++ b/packages/rendering/react/src/primitives/editor/content.tsx @@ -37,6 +37,7 @@ import { } from "../../utils/flowCapabilities"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; import { AIStructuredTargetPreviewItem } from "../ai/structuredTargetPreview"; import { AutocompletePreviewBlock } from "./autocompletePreviewBlock"; import { EditorBlock } from "./block"; @@ -1362,7 +1363,7 @@ export function EditorContent(props: EditorContentProps) { {...(fieldEditorState.mode === "expanded" ? { [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: "", + ...fieldEditorTextEntryAttrs(true), } : {})} ref={blocksHostRef} diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 552c591..20c8542 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -12,6 +12,7 @@ import { useBlockTextSnapshot } from "../../hooks/useBlockTextSnapshot"; import { useFieldEditorState } from "../../hooks/useFieldEditorState"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; import { applyInlineDecorationsToDeltas } from "../../utils/inlineDecorations"; export interface InlineContentProps extends AsChildProps { @@ -145,7 +146,7 @@ export function InlineContent(props: InlineContentProps) { const primitiveProps: Record = { [DATA_ATTRS.inlineContent]: "", [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: isActiveSurface ? "" : undefined, + ...fieldEditorTextEntryAttrs(isActiveSurface), [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, "data-placeholder": showPlaceholder ? placeholder : undefined, style: showPlaceholder diff --git a/packages/rendering/react/src/primitives/editor/root.tsx b/packages/rendering/react/src/primitives/editor/root.tsx index c945945..2701f79 100644 --- a/packages/rendering/react/src/primitives/editor/root.tsx +++ b/packages/rendering/react/src/primitives/editor/root.tsx @@ -260,10 +260,7 @@ export function EditorRoot(props: EditorRootProps) { [DATA_ATTRS.focused]: focused || undefined, [DATA_ATTRS.readonly]: readonly || undefined, [DATA_ATTRS.empty]: isEmpty || undefined, - role: "textbox", tabIndex: -1, - "aria-multiline": "true", - "aria-readonly": readonly, }; return ( diff --git a/packages/rendering/react/src/primitives/editor/tableCellContent.tsx b/packages/rendering/react/src/primitives/editor/tableCellContent.tsx index 012b0b5..74f8c7d 100644 --- a/packages/rendering/react/src/primitives/editor/tableCellContent.tsx +++ b/packages/rendering/react/src/primitives/editor/tableCellContent.tsx @@ -5,6 +5,7 @@ import { useFieldEditorState } from "../../hooks/useFieldEditorState"; import { fullReconcileDeltasToDOM } from "../../field-editor/reconciler"; import { useCellTextSnapshot } from "../../hooks/useCellTextSnapshot"; import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; const TABLE_CELL_MIN_WIDTH = "6rem"; @@ -85,7 +86,7 @@ function cellSurfaceAttrs( return { [DATA_ATTRS.inlineContent]: "", [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: isActiveCell ? "" : undefined, + ...fieldEditorTextEntryAttrs(isActiveCell), [DATA_ATTRS.ignorePointerGesture]: isActiveCell ? "" : undefined, [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, [DATA_ATTRS.tableCellRow]: row, diff --git a/packages/rendering/react/src/primitives/index.ts b/packages/rendering/react/src/primitives/index.ts index 6dd61c9..4b75bb8 100644 --- a/packages/rendering/react/src/primitives/index.ts +++ b/packages/rendering/react/src/primitives/index.ts @@ -22,6 +22,7 @@ export { export { SlashMenuRoot, + SlashMenuContent, SlashMenuInput, SlashMenuList, SlashMenuGroup, @@ -81,10 +82,7 @@ export { AIInlineSession, AIInlineSessionActions, } from "./ai/index"; -export { - AISuggestionsRoot, - AISuggestionsPopover, -} from "./aiSuggestions/index"; +export { AISuggestionsRoot, AISuggestionsPopover } from "./aiSuggestions/index"; export { MultiplayerPresenceList, MultiplayerRemoteCursors, @@ -117,6 +115,7 @@ import { import { SlashMenuRoot, + SlashMenuContent, SlashMenuInput, SlashMenuList, SlashMenuGroup, @@ -176,10 +175,7 @@ import { AIInlineSession, AIInlineSessionActions, } from "./ai/index"; -import { - AISuggestionsRoot, - AISuggestionsPopover, -} from "./aiSuggestions/index"; +import { AISuggestionsRoot, AISuggestionsPopover } from "./aiSuggestions/index"; import { MultiplayerPresenceList, MultiplayerRemoteCursors, @@ -209,6 +205,7 @@ export const Pen = { }, SlashMenu: { Root: SlashMenuRoot, + Content: SlashMenuContent, Input: SlashMenuInput, List: SlashMenuList, Group: SlashMenuGroup, diff --git a/packages/rendering/react/src/primitives/selection-toolbar/root.tsx b/packages/rendering/react/src/primitives/selection-toolbar/root.tsx index 328fae9..861b481 100644 --- a/packages/rendering/react/src/primitives/selection-toolbar/root.tsx +++ b/packages/rendering/react/src/primitives/selection-toolbar/root.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext } from "react"; import type { Editor } from "@pen/types"; -import { EditorContext } from "../../context/editorContext"; +import { useEditorContext } from "../../context/editorContext"; import { ToolbarContext, type ToolbarContextValue, @@ -36,23 +36,12 @@ export function useSelectionToolbarContext(): SelectionToolbarContextValue { } export interface SelectionToolbarRootProps extends AsChildProps { - editor?: Editor; ref?: React.Ref; } export function SelectionToolbarRoot(props: SelectionToolbarRootProps) { - const { editor: editorProp, ...rest } = props; - const editorContext = useContext(EditorContext); - const editor = editorProp ?? editorContext?.editor; - - if (!editor) { - if (isDevelopmentEnvironment()) { - console.error( - "Pen: must be used within or receive an editor prop.", - ); - } - throw new Error("Missing editor for Pen.SelectionToolbar.Root"); - } + const { ...rest } = props; + const { editor } = useEditorContext(); const toolbarState = useToolbar(editor); const selectionToolbar = useSelectionToolbar(editor); diff --git a/packages/rendering/react/src/primitives/slash-menu/content.tsx b/packages/rendering/react/src/primitives/slash-menu/content.tsx new file mode 100644 index 0000000..1195ff5 --- /dev/null +++ b/packages/rendering/react/src/primitives/slash-menu/content.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useRef, useState } from "react"; +import type { Editor } from "@pen/types"; +import { EditorContext } from "../../context/editorContext"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; +import { composeRefs } from "../../utils/composeRefs"; +import { isDevelopmentEnvironment } from "../../utils/environment"; +import { useSlashMenuContext } from "./root"; + +type Side = "top" | "bottom"; + +interface SlashMenuPosition { + top: number; + left: number; + maxHeight: number; + side: Side; +} + +export interface SlashMenuContentProps extends AsChildProps { + /** + * Preferred placement side relative to the caret. + * @default "bottom" + */ + side?: Side; + /** Horizontal offset in px from the caret. @default 14 */ + alignOffset?: number; + /** Gap in px between the caret and menu. @default 10 */ + sideOffset?: number; + /** Minimum max-height in px when viewport space is tight. @default 120 */ + minHeight?: number; + /** Viewport padding in px. @default 16 */ + viewportPadding?: number; + ref?: React.Ref; +} + +export function SlashMenuContent(props: SlashMenuContentProps) { + const { + alignOffset = 14, + minHeight = 120, + ref, + side: preferredSide = "bottom", + sideOffset = 10, + viewportPadding = 16, + ...rest + } = props; + const editorContext = React.useContext(EditorContext); + const { + dismiss, + editor: controllerEditor, + items, + open, + query, + selectedIndex, + } = useSlashMenuContext(); + const editor = controllerEditor ?? editorContext?.editor; + const contentRef = useRef(null); + const [position, setPosition] = useState(null); + + useEffect(() => { + if (!open || !editor) { + setPosition(null); + return; + } + + let frame = 0; + const syncPosition = () => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + setPosition( + resolveMenuPosition({ + alignOffset, + editor, + element: contentRef.current, + minHeight, + preferredSide, + sideOffset, + viewportPadding, + }), + ); + }); + }; + + syncPosition(); + window.addEventListener("resize", syncPosition); + window.addEventListener("scroll", syncPosition, true); + document.addEventListener("selectionchange", syncPosition); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", syncPosition); + window.removeEventListener("scroll", syncPosition, true); + document.removeEventListener("selectionchange", syncPosition); + }; + }, [ + alignOffset, + editor, + items.length, + minHeight, + open, + preferredSide, + query, + sideOffset, + viewportPadding, + ]); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + if (contentRef.current?.contains(event.target as Node)) return; + dismiss(); + }; + + document.addEventListener("mousedown", handlePointerDown, true); + return () => + document.removeEventListener("mousedown", handlePointerDown, true); + }, [dismiss, open]); + + useEffect(() => { + if (!open) return; + + const selectedItemElement = + contentRef.current?.querySelector( + "[data-pen-slash-menu-item][data-selected]", + ); + selectedItemElement?.scrollIntoView({ block: "nearest" }); + }, [open, items.length, selectedIndex]); + + if (!editor) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: must be used within or .", + ); + } + throw new Error("Missing editor for Pen.SlashMenu.Content"); + } + + if (!open) return null; + + const primitiveProps: Record = { + "data-pen-slash-menu-content": "", + "data-side": position?.side ?? preferredSide, + style: { + position: "fixed" as const, + top: 0, + left: 0, + transform: position + ? `translate3d(${Math.round(position.left)}px, ${Math.round(position.top)}px, 0)` + : undefined, + maxHeight: position + ? `${Math.round(position.maxHeight)}px` + : undefined, + willChange: "transform", + zIndex: 60, + visibility: position ? ("visible" as const) : ("hidden" as const), + }, + }; + + return renderAsChild( + { ...rest, ref: composeRefs(ref, contentRef) }, + "div", + primitiveProps, + ); +} + +function resolveMenuPosition(options: { + alignOffset: number; + editor: Editor; + element: HTMLElement | null; + minHeight: number; + preferredSide: Side; + sideOffset: number; + viewportPadding: number; +}): SlashMenuPosition | null { + const { + alignOffset, + editor, + element, + minHeight, + preferredSide, + sideOffset, + viewportPadding, + } = options; + + if (typeof window === "undefined") return null; + + const anchorRect = getAnchorRect(editor); + if (!anchorRect) return null; + + const elementRect = element?.getBoundingClientRect(); + const menuWidth = elementRect?.width || 320; + const menuHeight = elementRect?.height || minHeight; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let side = preferredSide; + let top = + side === "top" + ? anchorRect.top - sideOffset - menuHeight + : anchorRect.bottom + sideOffset; + + if ( + side === "bottom" && + top + menuHeight > viewportHeight - viewportPadding + ) { + side = "top"; + top = anchorRect.top - sideOffset - menuHeight; + } + + if (side === "top" && top < viewportPadding) { + side = "bottom"; + top = anchorRect.bottom + sideOffset; + } + + const left = clamp( + anchorRect.left - alignOffset, + viewportPadding, + viewportWidth - menuWidth - viewportPadding, + ); + const availableHeight = + side === "bottom" + ? viewportHeight - top - viewportPadding + : anchorRect.top - sideOffset - viewportPadding; + + return { + top: Math.max(viewportPadding, top), + left, + maxHeight: Math.max(minHeight, availableHeight), + side, + }; +} + +function getAnchorRect(editor: Editor): DOMRect | null { + if (typeof window === "undefined") return null; + + const domSelection = window.getSelection(); + if (domSelection?.rangeCount) { + const range = domSelection.getRangeAt(0).cloneRange(); + range.collapse(false); + const rect = + Array.from(range.getClientRects()).at(-1) ?? + range.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + return rect; + } + } + + const editorSelection = editor.selection; + if (editorSelection?.type !== "text") return null; + + const blockElement = document.querySelector( + `[data-block-id="${escapeCssAttributeValue(editorSelection.anchor.blockId)}"]`, + ); + return blockElement?.getBoundingClientRect() ?? null; +} + +function escapeCssAttributeValue(value: string): string { + return value.replace(/["\\]/g, "\\$&"); +} + +function clamp(value: number, min: number, max: number) { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +} diff --git a/packages/rendering/react/src/primitives/slash-menu/index.ts b/packages/rendering/react/src/primitives/slash-menu/index.ts index 9f813d2..1556417 100644 --- a/packages/rendering/react/src/primitives/slash-menu/index.ts +++ b/packages/rendering/react/src/primitives/slash-menu/index.ts @@ -1,4 +1,10 @@ -export { SlashMenuRoot, useSlashMenuContext, type SlashMenuRootProps } from "./root"; +export { + SlashMenuRoot, + useSlashMenuContext, + type SlashMenuContextValue, + type SlashMenuRootProps, +} from "./root"; +export { SlashMenuContent, type SlashMenuContentProps } from "./content"; export { SlashMenuInput, type SlashMenuInputProps } from "./input"; export { SlashMenuList, type SlashMenuListProps } from "./list"; export { SlashMenuGroup, type SlashMenuGroupProps } from "./group"; diff --git a/packages/rendering/react/src/primitives/slash-menu/item.tsx b/packages/rendering/react/src/primitives/slash-menu/item.tsx index 68dcad6..62b0593 100644 --- a/packages/rendering/react/src/primitives/slash-menu/item.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/item.tsx @@ -4,28 +4,38 @@ import { renderAsChild, type AsChildProps } from "../../utils/asChild"; export interface SlashMenuItemProps extends AsChildProps { blockType?: string; + index?: number; onSelect?: () => void; ref?: React.Ref; [key: string]: unknown; } export function SlashMenuItem(props: SlashMenuItemProps) { - const { blockType, onSelect, ...rest } = props; - const { confirm } = useSlashMenuContext(); + const { blockType, index, onSelect, ...rest } = props; + const { confirm, select, selectedIndex } = useSlashMenuContext(); + const isSelected = index != null && index === selectedIndex; const handleClick = () => { if (onSelect) { onSelect(); } else { - confirm(); + confirm(index); } }; + const handleMouseEnter = () => { + if (index == null) return; + select(index); + }; + const primitiveProps: Record = { "data-pen-slash-menu-item": "", "data-block-type": blockType, + "data-selected": isSelected || undefined, role: "option", + "aria-selected": isSelected, onClick: handleClick, + onMouseEnter: handleMouseEnter, }; return renderAsChild(rest, "div", primitiveProps); diff --git a/packages/rendering/react/src/primitives/slash-menu/list.tsx b/packages/rendering/react/src/primitives/slash-menu/list.tsx index a855326..9c97e0d 100644 --- a/packages/rendering/react/src/primitives/slash-menu/list.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/list.tsx @@ -15,7 +15,7 @@ export interface SlashMenuListProps extends AsChildProps { */ export function SlashMenuList(props: SlashMenuListProps) { const { children, ...rest } = props; - const { items, selectedIndex, confirm } = useSlashMenuContext(); + const { items } = useSlashMenuContext(); const hasManualChildren = React.Children.count(children) > 0; @@ -40,8 +40,7 @@ export function SlashMenuList(props: SlashMenuListProps) { confirm(idx)} + index={idx} > {item.display.title} diff --git a/packages/rendering/react/src/primitives/slash-menu/root.tsx b/packages/rendering/react/src/primitives/slash-menu/root.tsx index ed0603b..138b802 100644 --- a/packages/rendering/react/src/primitives/slash-menu/root.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/root.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useEffect } from "react"; -import { useEditorContext } from "../../context/editorContext"; +import React, { createContext, useContext, useEffect, useRef } from "react"; +import type { Editor } from "@pen/types"; +import { EditorContext } from "../../context/editorContext"; import { useSlashMenu, type SlashMenuState, @@ -8,7 +9,10 @@ import { import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { isDevelopmentEnvironment } from "../../utils/environment"; -type SlashMenuContextValue = SlashMenuState & SlashMenuActions; +export type SlashMenuContextValue = SlashMenuState & + SlashMenuActions & { + editor?: Editor; + }; const SlashMenuContext = createContext(null); @@ -26,57 +30,121 @@ export function useSlashMenuContext(): SlashMenuContextValue { } export interface SlashMenuRootProps extends AsChildProps { + controller?: SlashMenuContextValue; + editor?: Editor; open?: boolean; onOpenChange?: (open: boolean) => void; ref?: React.Ref; } export function SlashMenuRoot(props: SlashMenuRootProps) { - const { open: controlledOpen, onOpenChange, ...rest } = props; - const { editor } = useEditorContext(); + const { controller, editor, ...rest } = props; + if (controller) { + return ( + + ); + } + + return ; +} + +type UncontrolledSlashMenuRootProps = Omit; + +function UncontrolledSlashMenuRoot(props: UncontrolledSlashMenuRootProps) { + const { editor: editorProp, ...rest } = props; + const editorContext = useContext(EditorContext); + const editor = editorProp ?? editorContext?.editor; + + if (!editor) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: must be used within or receive an editor prop.", + ); + } + throw new Error("Missing editor for Pen.SlashMenu.Root"); + } + const menuState = useSlashMenu(editor); - const isOpen = controlledOpen ?? menuState.open; + return ( + + ); +} + +type SlashMenuRootContentProps = Omit< + SlashMenuRootProps, + "controller" | "editor" +> & { + controller: SlashMenuContextValue; + editor?: Editor; +}; + +function SlashMenuRootContent(props: SlashMenuRootContentProps) { + const { + controller, + editor: editorProp, + open: controlledOpen, + onOpenChange, + ...rest + } = props; + const editorContext = useContext(EditorContext); + const editor = editorProp ?? controller.editor ?? editorContext?.editor; + + const isOpen = controlledOpen ?? controller.open; const wrappedState: SlashMenuContextValue = { - ...menuState, + ...controller, + editor, + open: isOpen, dismiss: () => { - menuState.dismiss(); + controller.dismiss(); onOpenChange?.(false); }, - confirm: () => { - menuState.confirm(); + confirm: (index?: number) => { + controller.confirm(index); onOpenChange?.(false); }, }; + const wrappedStateRef = useRef(wrappedState); + wrappedStateRef.current = wrappedState; useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { + const currentState = wrappedStateRef.current; + switch (event.key) { case "ArrowDown": event.preventDefault(); - wrappedState.select( + currentState.select( Math.min( - wrappedState.selectedIndex + 1, - wrappedState.items.length - 1, + currentState.selectedIndex + 1, + currentState.items.length - 1, ), ); break; case "ArrowUp": event.preventDefault(); - wrappedState.select( - Math.max(wrappedState.selectedIndex - 1, 0), + currentState.select( + Math.max(currentState.selectedIndex - 1, 0), ); break; case "Enter": event.preventDefault(); - wrappedState.confirm(); + currentState.confirm(); break; case "Escape": event.preventDefault(); - wrappedState.dismiss(); + currentState.dismiss(); break; } }; @@ -84,10 +152,10 @@ export function SlashMenuRoot(props: SlashMenuRootProps) { document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); - }); + }, [isOpen]); const primitiveProps: Record = { - role: "listbox", + role: "dialog", "data-pen-slash-menu": "", "data-open": isOpen || undefined, }; diff --git a/packages/rendering/react/src/primitives/toolbar/index.ts b/packages/rendering/react/src/primitives/toolbar/index.ts index 7d5626c..55ce3d3 100644 --- a/packages/rendering/react/src/primitives/toolbar/index.ts +++ b/packages/rendering/react/src/primitives/toolbar/index.ts @@ -3,4 +3,4 @@ export { ToolbarGroup, type ToolbarGroupProps } from "./group"; export { ToolbarButton, type ToolbarButtonProps } from "./button"; export { ToolbarToggle, type ToolbarToggleProps } from "./toggle"; export { ToolbarSelect, type ToolbarSelectProps } from "./select"; -export { ToolbarSeparator } from "./separator"; +export { ToolbarSeparator, type ToolbarSeparatorProps } from "./separator"; diff --git a/packages/rendering/react/src/primitives/toolbar/separator.tsx b/packages/rendering/react/src/primitives/toolbar/separator.tsx index 5deef73..f947390 100644 --- a/packages/rendering/react/src/primitives/toolbar/separator.tsx +++ b/packages/rendering/react/src/primitives/toolbar/separator.tsx @@ -1,5 +1,13 @@ import React from "react"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; -export function ToolbarSeparator() { - return
; +export interface ToolbarSeparatorProps extends AsChildProps { + ref?: React.Ref; +} + +export function ToolbarSeparator(props: ToolbarSeparatorProps) { + return renderAsChild(props, "div", { + role: "separator", + "data-pen-toolbar-separator": "", + }); } diff --git a/packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts b/packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts new file mode 100644 index 0000000..ad689ce --- /dev/null +++ b/packages/rendering/react/src/utils/fieldEditorTextEntryAttrs.ts @@ -0,0 +1,9 @@ +import { DATA_ATTRS } from "./dataAttributes"; + +export function fieldEditorTextEntryAttrs(isActive: boolean): Record { + return { + [DATA_ATTRS.fieldEditorActiveSurface]: isActive ? "" : undefined, + role: isActive ? "textbox" : undefined, + "aria-multiline": isActive ? true : undefined, + }; +} diff --git a/playground/src/components/SlashMenu.css b/playground/src/components/SlashMenu.css index 4476180..496939c 100644 --- a/playground/src/components/SlashMenu.css +++ b/playground/src/components/SlashMenu.css @@ -1,156 +1,151 @@ -[data-pen-slash-menu] { - display: none; -} - -[data-pen-slash-menu][data-open] { - display: flex; - flex-direction: column; - position: fixed; - z-index: 60; - width: min(300px, calc(100vw - 24px)); - padding: 4px; - background: color-mix(in srgb, var(--surface) 94%, var(--bg)); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: - 0 12px 28px rgba(15, 23, 42, 0.14), - 0 2px 6px rgba(15, 23, 42, 0.08); - backdrop-filter: blur(14px); +[data-pen-slash-menu-content] { + display: flex; + flex-direction: column; + width: min(300px, calc(100vw - 24px)); + padding: 4px; + background: color-mix(in srgb, var(--surface) 94%, var(--bg)); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: + 0 12px 28px rgba(15, 23, 42, 0.14), + 0 2px 6px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(14px); + overflow: hidden; } [data-pen-slash-menu-list] { - display: flex; - flex-direction: column; - gap: 6px; - overflow-y: auto; - padding: 2px 1px; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + padding: 2px 1px; } [data-pen-slash-menu-group] { - display: flex; - flex-direction: column; - gap: 2px; + display: flex; + flex-direction: column; + gap: 2px; } [data-pen-slash-menu-group-heading] { - padding: 4px 8px 1px; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-muted); - opacity: 0.82; + padding: 4px 8px 1px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + opacity: 0.82; } [data-pen-slash-menu-item] { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - width: 100%; - padding: 6px 8px; - font-family: inherit; - color: var(--text); - background: transparent; - border: 0; - border-radius: 8px; - cursor: pointer; - text-align: left; - transition: - background 0.12s ease, - box-shadow 0.12s ease; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + font-family: inherit; + color: var(--text); + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: + background 0.12s ease, + box-shadow 0.12s ease; } [data-pen-slash-menu-item]:hover, [data-pen-slash-menu-item][data-selected] { - background: color-mix(in srgb, var(--bg) 76%, transparent); + background: color-mix(in srgb, var(--bg) 76%, transparent); } [data-pen-slash-menu-item]:focus-visible { - outline: none; - background: color-mix(in srgb, var(--bg) 76%, transparent); - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--border) 80%, transparent), - 0 0 0 3px rgba(113, 113, 122, 0.08); + outline: none; + background: color-mix(in srgb, var(--bg) 76%, transparent); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--border) 80%, transparent), + 0 0 0 3px rgba(113, 113, 122, 0.08); } .slash-menu-item-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 6px; - background: color-mix(in srgb, var(--bg) 92%, transparent); - border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 9px; - font-weight: 650; - line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: color-mix(in srgb, var(--bg) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 650; + line-height: 1; } [data-pen-slash-menu-item][data-selected] .slash-menu-item-icon { - color: color-mix(in srgb, var(--accent-fg) 92%, var(--text)); - background: color-mix(in srgb, var(--accent) 18%, var(--bg)); - border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + color: color-mix(in srgb, var(--accent-fg) 92%, var(--text)); + background: color-mix(in srgb, var(--accent) 18%, var(--bg)); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } .slash-menu-item-content { - display: flex; - flex-direction: column; - min-width: 0; - gap: 1px; + display: flex; + flex-direction: column; + min-width: 0; + gap: 1px; } .slash-menu-item-title { - font-size: 11px; - font-weight: 550; - line-height: 1.2; - color: var(--text); + font-size: 11px; + font-weight: 550; + line-height: 1.2; + color: var(--text); } .slash-menu-item-description { - font-size: 10px; - line-height: 1.2; - color: var(--text-muted); - opacity: 0.9; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-size: 10px; + line-height: 1.2; + color: var(--text-muted); + opacity: 0.9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .slash-menu-item-alias { - flex-shrink: 0; - padding-left: 6px; - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 9px; - font-weight: 500; - opacity: 0.72; + flex-shrink: 0; + padding-left: 6px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 500; + opacity: 0.72; } [data-pen-slash-menu-empty] { - padding: 12px 10px; - color: var(--text-muted); - text-align: center; - border: 1px dashed var(--border); - border-radius: 8px; - background: color-mix(in srgb, var(--bg) 80%, transparent); - font-size: 11px; + padding: 12px 10px; + color: var(--text-muted); + text-align: center; + border: 1px dashed var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--bg) 80%, transparent); + font-size: 11px; } @media (prefers-color-scheme: dark) { - [data-pen-slash-menu][data-open] { - background: color-mix(in srgb, var(--surface) 94%, black); - box-shadow: - 0 14px 32px rgba(0, 0, 0, 0.34), - 0 2px 8px rgba(0, 0, 0, 0.22); - } - - [data-pen-slash-menu-item]:focus-visible { - box-shadow: - inset 0 0 0 1px var(--border), - 0 0 0 3px rgba(161, 161, 170, 0.12); - } + [data-pen-slash-menu-content] { + background: color-mix(in srgb, var(--surface) 94%, black); + box-shadow: + 0 14px 32px rgba(0, 0, 0, 0.34), + 0 2px 8px rgba(0, 0, 0, 0.22); + } + + [data-pen-slash-menu-item]:focus-visible { + box-shadow: + inset 0 0 0 1px var(--border), + 0 0 0 3px rgba(161, 161, 170, 0.12); + } } diff --git a/playground/src/components/SlashMenu.tsx b/playground/src/components/SlashMenu.tsx index 2ccc9e6..b865a4f 100644 --- a/playground/src/components/SlashMenu.tsx +++ b/playground/src/components/SlashMenu.tsx @@ -1,29 +1,14 @@ import "./SlashMenu.css"; import type { Editor } from "@pen/types"; -import { useEffect, useRef, useState } from "react"; -import { useSlashMenu, type SlashMenuState } from "@pen/react"; +import { Pen, useSlashMenuContext, type SlashMenuState } from "@pen/react"; interface SlashMenuProps { editor: Editor; } -interface SlashMenuPosition { - top: number; - left: number; - maxHeight: number; -} - type SlashMenuItemData = SlashMenuState["items"][number]; const EMPTY_RESULTS_MESSAGE = "No matching commands"; -const MENU_GAP = 10; -const VIEWPORT_PADDING = 16; -const MIN_MENU_HEIGHT = 120; -const FALLBACK_POSITION: SlashMenuPosition = { - top: 96, - left: 0, - maxHeight: 320, -}; const BLOCK_TYPE_ICONS: Record = { paragraph: "P", heading: "H", @@ -39,10 +24,6 @@ const BLOCK_TYPE_ICONS: Record = { toggle: ">>", }; -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - function formatGroupLabel(group?: string) { if (!group) return "Other"; return group @@ -60,53 +41,20 @@ function getItemIcon(item: SlashMenuItemData) { ); } -function getSlashQuery(editor: Editor) { - const selection = editor.selection; - if (selection?.type !== "text") return null; - - const isCollapsed = - selection.anchor.blockId === selection.focus.blockId && - selection.anchor.offset === selection.focus.offset; - if (!isCollapsed) return null; - - const block = editor.getBlock(selection.anchor.blockId); - const text = block?.textContent() ?? ""; - if (!text.startsWith("/")) return null; - - return text.slice(1); -} - -function getAnchorRect(editor: Editor) { - if (typeof window === "undefined") return null; - - const domSelection = window.getSelection(); - if (domSelection?.rangeCount) { - const range = domSelection.getRangeAt(0).cloneRange(); - range.collapse(false); - - const rect = - Array.from(range.getClientRects()).at(-1) ?? range.getBoundingClientRect(); - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - - const editorSelection = editor.selection; - if (editorSelection?.type !== "text") return null; - - const blockElement = document.querySelector( - `[data-block-id="${editorSelection.anchor.blockId}"]`, +export function SlashMenu({ editor }: SlashMenuProps) { + return ( + + +
+ +
+
+
); - return blockElement?.getBoundingClientRect() ?? null; } -export function SlashMenu({ editor }: SlashMenuProps) { - const { open, query, items, selectedIndex, setQuery, select, confirm, dismiss } = - useSlashMenu(editor); - - const menuRef = useRef(null); - const [position, setPosition] = useState(FALLBACK_POSITION); - +function SlashMenuContent() { + const { items } = useSlashMenuContext(); const groupedItems = new Map< string, Array<{ index: number; item: SlashMenuItemData }> @@ -118,195 +66,66 @@ export function SlashMenu({ editor }: SlashMenuProps) { groupedItems.set(groupLabel, groupEntries); }); - const slashMenuGroups = Array.from(groupedItems.entries()).map( + const slashMenuGroupItems = Array.from(groupedItems.entries()).map( ([groupLabel, groupItems]) => { const groupItemElements = groupItems.map(({ item, index }) => { const itemAlias = item.display.aliases?.[0]; - const isSelected = index === selectedIndex; return ( - + {itemAlias ? ( + + ) : null} + + ); }); return ( -
-
- {groupLabel} -
+ {groupItemElements} -
+ ); }, ); - const menuStyle = { - top: `${position.top}px`, - left: `${position.left}px`, - maxHeight: `${position.maxHeight}px`, - }; - - function updatePosition() { - const anchorRect = getAnchorRect(editor); - if (!anchorRect) return; - - const menuWidth = menuRef.current?.offsetWidth ?? 320; - const nextLeft = clamp( - anchorRect.left - 14, - VIEWPORT_PADDING, - window.innerWidth - menuWidth - VIEWPORT_PADDING, - ); - const nextTop = anchorRect.bottom + MENU_GAP; - const availableHeight = - window.innerHeight - nextTop - VIEWPORT_PADDING; - - setPosition({ - top: nextTop, - left: nextLeft, - maxHeight: Math.max(MIN_MENU_HEIGHT, availableHeight), - }); - } - - useEffect(() => { - if (!open) return; - - const syncQueryFromEditor = () => { - const nextQuery = getSlashQuery(editor); - if (nextQuery === null) { - dismiss(); - return; - } - setQuery(nextQuery); - }; - - syncQueryFromEditor(); - const unsubscribeDocument = editor.onDocumentCommit(syncQueryFromEditor); - const unsubscribeSelection = editor.onSelectionChange(syncQueryFromEditor); - - return () => { - unsubscribeDocument(); - unsubscribeSelection(); - }; - }, [dismiss, editor, open, setQuery]); - - useEffect(() => { - if (!open) return; - - const syncPosition = () => { - window.requestAnimationFrame(updatePosition); - }; - - syncPosition(); - window.addEventListener("resize", syncPosition); - window.addEventListener("scroll", syncPosition, true); - document.addEventListener("selectionchange", syncPosition); - - return () => { - window.removeEventListener("resize", syncPosition); - window.removeEventListener("scroll", syncPosition, true); - document.removeEventListener("selectionchange", syncPosition); - }; - }, [editor, open, query, items.length]); - - useEffect(() => { - if (!open) return; - - const handleKeyDown = (event: KeyboardEvent) => { - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - select(selectedIndex + 1); - break; - case "ArrowUp": - event.preventDefault(); - select(selectedIndex - 1); - break; - case "Enter": - event.preventDefault(); - confirm(); - break; - case "Escape": - event.preventDefault(); - dismiss(); - break; - } - }; - - document.addEventListener("keydown", handleKeyDown, true); - return () => { - document.removeEventListener("keydown", handleKeyDown, true); - }; - }, [confirm, dismiss, open, select, selectedIndex]); - - useEffect(() => { - if (!open) return; - - const handlePointerDown = (event: MouseEvent) => { - if (menuRef.current?.contains(event.target as Node)) return; - dismiss(); - }; - - document.addEventListener("mousedown", handlePointerDown, true); - return () => { - document.removeEventListener("mousedown", handlePointerDown, true); - }; - }, [dismiss, open]); - - useEffect(() => { - if (!open) return; - - const selectedItemElement = menuRef.current?.querySelector( - "[data-pen-slash-menu-item][data-selected]", - ); - selectedItemElement?.scrollIntoView({ block: "nearest" }); - }, [open, selectedIndex]); - - if (!open) return null; - return ( -
-
- {slashMenuGroups.length > 0 ? ( - slashMenuGroups - ) : ( -
- {EMPTY_RESULTS_MESSAGE} -
- )} -
-
+ <> + +
{slashMenuGroupItems}
+
+ +
{EMPTY_RESULTS_MESSAGE}
+
+ ); } From 1c3edb8963bfed6e460a147c9e5ffce9d30bce7f Mon Sep 17 00:00:00 2001 From: krijn Date: Sat, 9 May 2026 11:09:08 +0200 Subject: [PATCH 04/20] Add headless editor functionality and update pnpm-lock.yaml - Introduced `createHeadlessEditor` to support server-side and test workflows without a UI. - Updated README.md to include usage examples for the new headless editor. - Added `@pen/export-json` to pnpm-lock.yaml for workspace integration. - Refactored code for improved readability and consistency across various files. --- packages/core/README.md | 16 + .../core/src/__tests__/editorCore.test.ts | 141 +- .../src/__tests__/undoHistoryMetadata.test.ts | 106 +- packages/core/src/editor/apply.ts | 88 +- packages/core/src/editor/editor.ts | 183 +- packages/core/src/index.ts | 92 +- packages/crdt/yjs/README.md | 65 + .../yjs/src/__tests__/extensionRoots.test.ts | 76 + .../yjs/src/__tests__/fieldAdapters.test.ts | 107 + .../yjs/src/__tests__/stateVector.test.ts | 100 + packages/crdt/yjs/src/extensionRoots.ts | 161 + packages/crdt/yjs/src/fieldAdapters.ts | 215 ++ packages/crdt/yjs/src/index.ts | 37 + packages/crdt/yjs/src/stateVector.ts | 155 + packages/crdt/yjs/src/undo.ts | 186 +- packages/extensions/ai/src/extension.ts | 2657 +++++++++++------ .../ai/src/suggestions/suggestMode.ts | 12 +- packages/extensions/export-json/README.md | 20 + .../src/__tests__/textExporter.test.ts | 112 + packages/extensions/export-json/src/index.ts | 29 +- .../export-json/src/textExporter.ts | 124 + packages/tooling/test/README.md | 20 + packages/tooling/test/package.json | 1 + .../test/src/__tests__/fixtures.test.ts | 66 + packages/tooling/test/src/contracts.ts | 140 + packages/tooling/test/src/fixtures.ts | 216 ++ packages/tooling/test/src/index.ts | 35 +- packages/tooling/test/src/types.ts | 88 +- packages/types/src/types/crdt.ts | 340 +-- packages/types/src/types/index.ts | 24 +- packages/types/src/types/ops.ts | 63 +- pnpm-lock.yaml | 3 + spec/README.md | 5 + spec/packages/core.md | 10 +- spec/packages/crdt/yjs.md | 17 +- spec/packages/extensions/export-json.md | 10 +- spec/packages/tooling/test.md | 12 +- spec/packages/types.md | 12 + .../headless-collaboration-ai-waves.md | 579 ++++ 39 files changed, 4783 insertions(+), 1540 deletions(-) create mode 100644 packages/crdt/yjs/src/__tests__/extensionRoots.test.ts create mode 100644 packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts create mode 100644 packages/crdt/yjs/src/__tests__/stateVector.test.ts create mode 100644 packages/crdt/yjs/src/extensionRoots.ts create mode 100644 packages/crdt/yjs/src/fieldAdapters.ts create mode 100644 packages/crdt/yjs/src/stateVector.ts create mode 100644 packages/extensions/export-json/src/__tests__/textExporter.test.ts create mode 100644 packages/extensions/export-json/src/textExporter.ts create mode 100644 packages/tooling/test/src/__tests__/fixtures.test.ts create mode 100644 packages/tooling/test/src/contracts.ts create mode 100644 packages/tooling/test/src/fixtures.ts create mode 100644 spec/roadmap/headless-collaboration-ai-waves.md diff --git a/packages/core/README.md b/packages/core/README.md index 5d89c3d..13cef4b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,9 +13,25 @@ pnpm add @pen/core ## What It Provides - `createEditor(...)` to create editor instances +- `createHeadlessEditor(...)` for server-side, worker, and test workflows that need editor semantics without a renderer - document state, selection, normalization, and mutation orchestration - the canonical `editor.apply(...)` document mutation boundary +## Headless Usage + +```ts +import { createHeadlessEditor } from "@pen/core"; +import { yjsAdapter, wrapYjsDocument } from "@pen/crdt-yjs"; + +const adapter = yjsAdapter(); +const editor = createHeadlessEditor({ + crdt: adapter, + document: wrapYjsDocument(adapter, ydoc), +}); +``` + +Use this shape for migrations, AI workers, export workers, and tests that should run through Pen's mutation pipeline without mounting a UI. + ## Typical Pairing Most apps use `@pen/core` with: diff --git a/packages/core/src/__tests__/editorCore.test.ts b/packages/core/src/__tests__/editorCore.test.ts index 1e7fc7b..c628d4d 100644 --- a/packages/core/src/__tests__/editorCore.test.ts +++ b/packages/core/src/__tests__/editorCore.test.ts @@ -6,6 +6,7 @@ import { defineExtension, type DocumentSession, type PenStreamPart, + getOpOriginType, } from "@pen/types"; import { describe, expect, it, vi } from "vitest"; @@ -13,6 +14,7 @@ import { createDecorationSet, createDocumentSession, createEditor as createCoreEditor, + createHeadlessEditor, } from "../index"; const noDefaultExtensionsPreset = { @@ -27,9 +29,7 @@ const undoOnlyPreset = { }, }; -function createEditor( - options: Parameters[0] = {}, -) { +function createEditor(options: Parameters[0] = {}) { return createCoreEditor({ ...options, preset: options.preset ?? noDefaultExtensionsPreset, @@ -116,7 +116,10 @@ describe("@pen/core createEditor", () => { defineExtension({ name: "preset-test-extension", activateClient: async (ctx) => { - ctx.editor.internals.setSlot("test:preset-installed", true); + ctx.editor.internals.setSlot( + "test:preset-installed", + true, + ); }, }), ], @@ -172,6 +175,27 @@ describe("@pen/core createEditor", () => { session.destroy(); }); + it("creates headless editors around caller-owned documents without default undo behavior", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editor = createHeadlessEditor({ crdt: adapter, document }); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Server edit", + }, + ]); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Server edit"); + expect(editor.undoManager.undo()).toBe(false); + + editor.destroy(); + }); + it("does not destroy caller-owned documents on editor teardown", () => { const adapter = yjsAdapter(); const document = adapter.createDocument(); @@ -208,7 +232,9 @@ describe("@pen/core createEditor", () => { expect(editor.documentState.documentProfile).toBe("flow"); expect(editor.editorViewMode).toBe("flow"); expect( - editor.internals.adapter.getDocumentProfile?.(editor.internals.crdtDoc), + editor.internals.adapter.getDocumentProfile?.( + editor.internals.crdtDoc, + ), ).toBe("flow"); editor.destroy(); @@ -425,7 +451,9 @@ describe("@pen/core createEditor", () => { expect(childEditor.getBlock(childBlockId)?.textContent()).toBe( "Nested content", ); - expect(childEditor.documentScope.parentId).toBe(rootEditor.documentScope.id); + expect(childEditor.documentScope.parentId).toBe( + rootEditor.documentScope.id, + ); expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); childEditor.apply([ @@ -465,7 +493,8 @@ describe("@pen/core createEditor", () => { baseSession.getScopeForBlock(blockId, options), listScopes: () => baseSession.listScopes(), getAwareness: (scopeId) => baseSession.getAwareness(scopeId), - observe: (scopeId, callback) => baseSession.observe(scopeId, callback), + observe: (scopeId, callback) => + baseSession.observe(scopeId, callback), observeAll: (callback) => baseSession.observeAll(callback), createSubdocument: (blockId, options) => baseSession.createSubdocument(blockId, options), @@ -481,10 +510,15 @@ describe("@pen/core createEditor", () => { const originalDoc = editor.internals.crdtDoc; const replacementSource = createEditor(); const replacementDoc = delegatedSession.adapter.loadDocument( - delegatedSession.adapter.encodeState(replacementSource.internals.crdtDoc), + delegatedSession.adapter.encodeState( + replacementSource.internals.crdtDoc, + ), ); - delegatedSession.replaceScopeDocument(editor.documentScope.id, replacementDoc); + delegatedSession.replaceScopeDocument( + editor.documentScope.id, + replacementDoc, + ); await flushMicrotasks(); expect(editor.internals.crdtDoc).toBe(replacementDoc); @@ -565,14 +599,19 @@ describe("@pen/core createEditor", () => { }, ]); - session.replaceScopeDocument(rootEditor.documentScope.id, replacementSession.rootScope.doc); + session.replaceScopeDocument( + rootEditor.documentScope.id, + replacementSession.rootScope.doc, + ); await flushMicrotasks(); expect(childEditor.firstBlock()?.textContent()).toBe( "Replacement nested content", ); expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); - expect(childEditor.documentScope.parentId).toBe(rootEditor.documentScope.id); + expect(childEditor.documentScope.parentId).toBe( + rootEditor.documentScope.id, + ); replacementChildEditor.destroy(); replacementRootEditor.destroy(); @@ -689,10 +728,7 @@ describe("@pen/core createEditor", () => { { type: "insert-text", blockId, offset: 0, text: "abcd" }, ]); - editor.selectTextRange( - { blockId, offset: 1 }, - { blockId, offset: 3 }, - ); + editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 3 }); expect(editor.selection).toMatchObject({ type: "text", @@ -835,10 +871,7 @@ describe("@pen/core createEditor", () => { }); const blockId = editor.firstBlock()!.id; - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 0 }, - ); + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 0 }); editor.apply( [ @@ -851,10 +884,7 @@ describe("@pen/core createEditor", () => { ], { origin: "user" }, ); - editor.selectTextRange( - { blockId, offset: 1 }, - { blockId, offset: 1 }, - ); + editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 1 }); editor.apply( [ { @@ -905,7 +935,9 @@ describe("@pen/core createEditor", () => { ); await flushMicrotasks(); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe("hello"); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "hello", + ); expect(editor.getBlock(blockId)?.textDeltas()).toEqual([ { insert: "hello", @@ -962,9 +994,10 @@ describe("@pen/core createEditor", () => { origin: "user", affectedBlocks: [blockId], }); - expect((documentCommits[0] as { blockRevisions: Record }).blockRevisions[blockId]).toBe( - editor.getBlockRevision(blockId), - ); + expect( + (documentCommits[0] as { blockRevisions: Record }) + .blockRevisions[blockId], + ).toBe(editor.getBlockRevision(blockId)); expect(observed).toHaveLength(1); expect(observed[0]).toHaveLength(1); @@ -1026,9 +1059,10 @@ describe("@pen/core createEditor", () => { commitId: 2, affectedBlocks: [blockId], }); - expect((documentCommits[0] as { blockRevisions: Record }).blockRevisions[blockId]).toBe( - editor.getBlockRevision(blockId), - ); + expect( + (documentCommits[0] as { blockRevisions: Record }) + .blockRevisions[blockId], + ).toBe(editor.getBlockRevision(blockId)); expect(observed).toHaveLength(1); expect(observed[0]).toHaveLength(1); @@ -1422,12 +1456,12 @@ describe("@pen/core createEditor", () => { editor.destroy(); }); -it("rebinds undo manager after loadDocument", async () => { + it("rebinds undo manager after loadDocument", async () => { const editor = createDefaultEditor(); const newDoc = editor.internals.adapter.createDocument(); editor.loadDocument(newDoc); - await flushMicrotasks(); + await flushMicrotasks(); expect(editor.undoManager).toBe( editor.internals.getSlot("undo:manager"), @@ -1486,19 +1520,24 @@ it("rebinds undo manager after loadDocument", async () => { redo: () => false, canUndo: () => false, canRedo: () => false, - stopCapturing: () => { }, - syncExplicitUndoGroup: () => { }, - setGroupTimeout: () => { }, - registerTrackedOrigins: () => () => { }, - onStackChange: () => () => { }, + stopCapturing: () => {}, + syncExplicitUndoGroup: () => {}, + setGroupTimeout: () => {}, + registerTrackedOrigins: () => () => {}, + onStackChange: () => () => {}, }; const editor = createEditor({ extensions: [ defineExtension({ name: "test-undo-slot", activateClient: async ({ editor }) => { - expect(editor.undoManager).not.toBe(registeredUndoManager); - editor.internals.setSlot("undo:manager", registeredUndoManager); + expect(editor.undoManager).not.toBe( + registeredUndoManager, + ); + editor.internals.setSlot( + "undo:manager", + registeredUndoManager, + ); expect(editor.undoManager).toBe(registeredUndoManager); }, }), @@ -1823,8 +1862,12 @@ it("rebinds undo manager after loadDocument", async () => { expect(editor.undoManager.undo()).toBe(true); expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe(""); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe(""); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); editor.destroy(); }); @@ -1836,7 +1879,11 @@ it("rebinds undo manager after loadDocument", async () => { await processStream( (async function* (): AsyncIterable { yield { type: "gen-start", zoneId: "zone-shared", blockId }; - yield { type: "gen-delta", zoneId: "zone-shared", delta: "AI " }; + yield { + type: "gen-delta", + zoneId: "zone-shared", + delta: "AI ", + }; editor.apply( [ @@ -1910,7 +1957,7 @@ it("rebinds undo manager after loadDocument", async () => { const commitOrigins: string[] = []; editor.on("documentCommit", (event) => { - commitOrigins.push(event.origin); + commitOrigins.push(getOpOriginType(event.origin)); }); editor.apply([ @@ -2009,8 +2056,12 @@ describe("@pen/core table operations", () => { }, ]); - const blockMap = editor.internals.doc.blocks.get("t1") as TestBlockMapLike; - const tableContent = blockMap.get("tableContent") as TestTableContentLike; + const blockMap = editor.internals.doc.blocks.get( + "t1", + ) as TestBlockMapLike; + const tableContent = blockMap.get( + "tableContent", + ) as TestTableContentLike; const firstRow = tableContent.get(0); firstRow.get("cells").delete(2, 1); diff --git a/packages/core/src/__tests__/undoHistoryMetadata.test.ts b/packages/core/src/__tests__/undoHistoryMetadata.test.ts index 1544fa7..c1f6831 100644 --- a/packages/core/src/__tests__/undoHistoryMetadata.test.ts +++ b/packages/core/src/__tests__/undoHistoryMetadata.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { + type MutationGroupMetadata, type UndoHistoryMetadataController, + MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, } from "@pen/types"; import { createEditor } from "../index"; @@ -19,14 +21,16 @@ describe("@pen/core undo history metadata", () => { const restoredValues: Array = []; expect(controller).not.toBeNull(); - controller!.registerMetadataRestorer("test", (value: string | null) => { - restoredValues.push(value); - }); - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "A" }], - { origin: "user" }, + controller!.registerMetadataRestorer( + "test", + (value: string | null) => { + restoredValues.push(value); + }, ); + + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: "user", + }); controller!.setCurrentEntryMetadata("test", { before: "step-1-before", after: "step-1-after", @@ -38,10 +42,9 @@ describe("@pen/core undo history metadata", () => { before: "step-2-before", after: "step-2-after", }); - editor.apply( - [{ type: "insert-text", blockId, offset: 1, text: "B" }], - { origin: "user" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: "user", + }); expect(editor.undoManager.undo()).toBe(true); expect(restoredValues).toEqual(["step-2-before"]); @@ -59,14 +62,16 @@ describe("@pen/core undo history metadata", () => { const restoredValues: Array = []; expect(controller).not.toBeNull(); - controller!.registerMetadataRestorer("test", (value: string | null) => { - restoredValues.push(value); - }); - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "A" }], - { origin: "user" }, + controller!.registerMetadataRestorer( + "test", + (value: string | null) => { + restoredValues.push(value); + }, ); + + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: "user", + }); controller!.setCurrentEntryMetadata("test", { before: "step-1-before", after: "step-1-after", @@ -74,10 +79,9 @@ describe("@pen/core undo history metadata", () => { editor.undoManager.stopCapturing(); - editor.apply( - [{ type: "insert-text", blockId, offset: 1, text: "B" }], - { origin: "user" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: "user", + }); expect(editor.undoManager.undo()).toBe(true); expect(restoredValues).toEqual([null]); @@ -90,15 +94,15 @@ describe("@pen/core undo history metadata", () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "A" }], - { origin: "user", undoGroupId: "group-1" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: "user", + undoGroupId: "group-1", + }); await vi.advanceTimersByTimeAsync(550); - editor.apply( - [{ type: "insert-text", blockId, offset: 1, text: "B" }], - { origin: "user", undoGroupId: "group-1" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: "user", + undoGroupId: "group-1", + }); expect(editor.getBlock(blockId)?.textContent()).toBe("AB"); expect(editor.undoManager.undo()).toBe(true); @@ -108,4 +112,46 @@ describe("@pen/core undo history metadata", () => { editor.destroy(); vi.useRealTimers(); }); + + it("records structured origin group metadata and uses it as the undo group", async () => { + vi.useFakeTimers(); + const editor = createEditor(); + const controller = getHistoryMetadataController(editor); + const blockId = editor.firstBlock()!.id; + + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: { + type: "ai", + groupId: "group-ai-1", + requestId: "request-1", + actorId: "assistant-1", + source: "test", + }, + }); + await vi.advanceTimersByTimeAsync(550); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: { + type: "ai", + groupId: "group-ai-1", + requestId: "request-1", + }, + }); + + const metadata = + controller!.getCurrentEntryMetadata( + MUTATION_GROUP_METADATA_KEY, + ); + expect(metadata?.after).toMatchObject({ + groupId: "group-ai-1", + originType: "ai", + requestId: "request-1", + }); + expect(editor.getBlock(blockId)?.textContent()).toBe("AB"); + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.undoManager.undo()).toBe(false); + + editor.destroy(); + vi.useRealTimers(); + }); }); diff --git a/packages/core/src/editor/apply.ts b/packages/core/src/editor/apply.ts index 057eb0e..2779809 100644 --- a/packages/core/src/editor/apply.ts +++ b/packages/core/src/editor/apply.ts @@ -29,7 +29,7 @@ import type { SetSelectionOp, UpdateTableColumnsOp, } from "@pen/types"; -import { generateId } from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; import { resolveRuntimeContentType } from "../schema/contentType"; import type { SchemaEngineImpl } from "../schema/normalize"; import { @@ -114,10 +114,7 @@ export class ApplyPipeline { priority: number; }> = []; private _finalBeforeApplyHook: - | (( - ops: DocumentOp[], - options: { origin?: OpOrigin }, - ) => DocumentOp[]) + | ((ops: DocumentOp[], options: { origin?: OpOrigin }) => DocumentOp[]) | null = null; get suppressObserver(): boolean { @@ -265,7 +262,9 @@ export class ApplyPipeline { } if (this._finalBeforeApplyHook) { try { - transformedOps = this._finalBeforeApplyHook(transformedOps, { origin }); + transformedOps = this._finalBeforeApplyHook(transformedOps, { + origin, + }); } catch (err) { this._emitter.emit("diagnostic", { code: "PEN_APPLY_007", @@ -345,7 +344,7 @@ export class ApplyPipeline { this._engine.normalizeDirty(); }, - origin, + getOpOriginType(origin), ); } finally { this._suppressObserver = false; @@ -589,7 +588,10 @@ export class ApplyPipeline { if (typeof op.position === "object" && "parent" in op.position) { const parentMap = this._getMutableBlockMap(op.position.parent); if (parentMap) { - const children = this._getOrCreateStringArrayProp(parentMap, "children"); + const children = this._getOrCreateStringArrayProp( + parentMap, + "children", + ); const idx = Math.min(op.position.index, children.length); children.insert(idx, [op.blockId]); } @@ -638,7 +640,10 @@ export class ApplyPipeline { if (typeof op.position === "object" && "parent" in op.position) { const parentMap = this._getMutableBlockMap(op.position.parent); if (parentMap) { - const children = this._getOrCreateStringArrayProp(parentMap, "children"); + const children = this._getOrCreateStringArrayProp( + parentMap, + "children", + ); const idx = Math.min(op.position.index, children.length); children.insert(idx, [op.blockId]); } @@ -741,14 +746,17 @@ export class ApplyPipeline { const hasHeaderRow = propsMap?.get("hasHeaderRow") !== false; const existingColumns = getTableColumns(blockMap); if (!existingColumns || existingColumns.length === 0) { - const columnCount = this._tableGrid.resolveGridColumnCount(blockMap); + const columnCount = + this._tableGrid.resolveGridColumnCount(blockMap); const columns = Array.from({ length: columnCount }, (_, index) => { const title = hasHeaderRow && tableContent.length > 0 - ? this._tableGrid.readTableCellText( - tableContent.get(0) as CRDTUnknownMap, - index, - ).trim() || `Column ${index + 1}` + ? this._tableGrid + .readTableCellText( + tableContent.get(0) as CRDTUnknownMap, + index, + ) + .trim() || `Column ${index + 1}` : `Column ${index + 1}`; return { id: `column-${index + 1}`, @@ -1137,9 +1145,7 @@ export class ApplyPipeline { ); } - private _getPreservedInlineDeltas( - content: CRDTText | undefined, - ): Array<{ + private _getPreservedInlineDeltas(content: CRDTText | undefined): Array<{ insert: string; attributes?: Record; }> { @@ -1147,15 +1153,16 @@ export class ApplyPipeline { return []; } - return content - .toDelta() - .filter( - (delta): delta is { - insert: string; - attributes?: Record; - } => - typeof delta.insert === "string" && delta.insert !== ZERO_WIDTH_SPACE, - ); + return content.toDelta().filter( + ( + delta, + ): delta is { + insert: string; + attributes?: Record; + } => + typeof delta.insert === "string" && + delta.insert !== ZERO_WIDTH_SPACE, + ); } // ── Meta Op ────────────────────────────────────────────── @@ -1187,14 +1194,22 @@ export class ApplyPipeline { } private _getMutableBlockMap(blockId: string): MutableMap | null { - return (this.blocks.get(blockId) as unknown as MutableMap | undefined) ?? null; + return ( + (this.blocks.get(blockId) as unknown as MutableMap | undefined) ?? + null + ); } private _getMutableAppMap(appId: string): MutableMap | null { - return (this.apps.get(appId) as unknown as MutableMap | undefined) ?? null; + return ( + (this.apps.get(appId) as unknown as MutableMap | undefined) ?? null + ); } - private _getOrCreateMapProp(container: CRDTUnknownMap, key: string): MutableMap { + private _getOrCreateMapProp( + container: CRDTUnknownMap, + key: string, + ): MutableMap { const existing = getMapProp(container, key); if (existing) { return existing as MutableMap; @@ -1242,7 +1257,10 @@ export class ApplyPipeline { if (!children) { continue; } - this._removeBlockIdFromArray(children as MutableStringArray, blockId); + this._removeBlockIdFromArray( + children as MutableStringArray, + blockId, + ); } } @@ -1254,16 +1272,20 @@ export class ApplyPipeline { typeof (content as { delete?: unknown }).delete === "function" && typeof (content as { format?: unknown }).format === "function" && typeof (content as { toDelta?: unknown }).toDelta === "function" && - typeof (content as { toString?: unknown }).toString === "function" && + typeof (content as { toString?: unknown }).toString === + "function" && typeof (content as { length?: unknown }).length === "number" ? (content as CRDTText) : undefined; } - private _getInlineTextContent(blockMap: CRDTUnknownMap): CRDTInlineText | undefined { + private _getInlineTextContent( + blockMap: CRDTUnknownMap, + ): CRDTInlineText | undefined { const content = this._getTextContent(blockMap); return content && - typeof (content as { insertEmbed?: unknown }).insertEmbed === "function" + typeof (content as { insertEmbed?: unknown }).insertEmbed === + "function" ? (content as CRDTInlineText) : undefined; } diff --git a/packages/core/src/editor/editor.ts b/packages/core/src/editor/editor.ts index 58da3b5..7abb732 100644 --- a/packages/core/src/editor/editor.ts +++ b/packages/core/src/editor/editor.ts @@ -17,6 +17,8 @@ import type { Extension, DocumentOp, ApplyOptions, + OpOrigin, + MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, @@ -35,6 +37,10 @@ import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, + createMutationGroupMetadata, + getApplyOptionsGroupId, + MUTATION_GROUP_METADATA_KEY, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, } from "@pen/types"; import { yjsAdapter } from "@pen/crdt-yjs"; import { undoExtension } from "@pen/undo"; @@ -49,7 +55,12 @@ import { ApplyPipeline } from "./apply"; import { resolveCellSelectionMatrix } from "./cellSelection"; import { filterOpsForDocumentProfile } from "./profilePolicy"; import type { CRDTUnknownMap } from "./crdtShapes"; -import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { + getTextProp, + getTableContent, + getCellText as getCellTextFromRow, + isCRDTMap, +} from "./crdtShapes"; import { ExtensionManagerImpl } from "./extensionManager"; import { SelectionManagerImpl } from "./selection"; import { DocumentStateImpl } from "./documentState"; @@ -84,11 +95,11 @@ const NOOP_UNDO: UndoManager = { redo: () => false, canUndo: () => false, canRedo: () => false, - stopCapturing: () => { }, - syncExplicitUndoGroup: () => { }, - setGroupTimeout: () => { }, - registerTrackedOrigins: () => () => { }, - onStackChange: () => () => { }, + stopCapturing: () => {}, + syncExplicitUndoGroup: () => {}, + setGroupTimeout: () => {}, + registerTrackedOrigins: () => () => {}, + onStackChange: () => () => {}, }; class EditorImpl implements Editor { @@ -124,7 +135,8 @@ class EditorImpl implements Editor { constructor(options: CreateEditorOptions = {}) { this._registry = options.schema ?? builtInDefaultSchema; this._explicitEditorViewMode = options.editorViewMode ?? null; - this._adapter = options.documentSession?.adapter ?? options.crdt ?? yjsAdapter(); + this._adapter = + options.documentSession?.adapter ?? options.crdt ?? yjsAdapter(); const documentSession = options.documentSession ?? createDocumentSession({ @@ -134,7 +146,9 @@ class EditorImpl implements Editor { ownsDocuments: options.document == null, }); this._bindSession(documentSession, options.documentScopeId); - this._documentProfile = this._resolveDocumentProfile(options.documentProfile); + this._documentProfile = this._resolveDocumentProfile( + options.documentProfile, + ); this._editorViewMode = this._explicitEditorViewMode ?? this._documentProfile; this._clientId = this._adapter.getClientId(this._crdtDoc); @@ -238,7 +252,8 @@ class EditorImpl implements Editor { emit: (event, ...args) => { this._emitter.emit(event, ...args); }, - onApplyBoundary: (hook) => this._pipeline.addApplyBoundaryHook(hook), + onApplyBoundary: (hook) => + this._pipeline.addApplyBoundaryHook(hook), getSlot: (key: string): T | undefined => this._slots.get(key) as T | undefined, setSlot: (key: string, value: unknown): void => { @@ -252,11 +267,16 @@ class EditorImpl implements Editor { if (!blockMap) return null; return getTextProp(blockMap, "content"); }, - getCellText: (blockId: string, row: number, col: number): unknown => { + getCellText: ( + blockId: string, + row: number, + col: number, + ): unknown => { const blockMap = this._getRawBlockMap(blockId); if (!blockMap) return null; const tableContent = getTableContent(blockMap); - if (!tableContent || row < 0 || row >= tableContent.length) return null; + if (!tableContent || row < 0 || row >= tableContent.length) + return null; const rowMap = tableContent.get(row); if (!rowMap || !isCRDTMap(rowMap)) return null; return getCellTextFromRow(rowMap, col); @@ -268,17 +288,43 @@ class EditorImpl implements Editor { apply(ops: DocumentOp[], options?: ApplyOptions): void { const origin = options?.origin ?? "user"; - const undo = this._slots.get("undo:manager") as - | UndoManager - | undefined; + const groupId = getApplyOptionsGroupId(origin, options); + const undo = this._slots.get("undo:manager") as UndoManager | undefined; - undo?.syncExplicitUndoGroup(options?.undoGroupId ?? null); + undo?.syncExplicitUndoGroup(groupId ?? null); - if (options?.undoGroup && !options?.undoGroupId) { + if (options?.undoGroup && !groupId) { undo?.stopCapturing(); } this._pipeline.apply(ops, origin); + this._recordMutationGroupMetadata(origin, groupId); + } + + private _recordMutationGroupMetadata( + origin: OpOrigin, + groupId: string | undefined, + ): void { + if (!groupId) { + return; + } + const controller = this._slots.get( + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + ) as + | { + setCurrentEntryMetadata( + key: string, + value: { before: T | null; after: T | null }, + ): boolean; + } + | undefined; + controller?.setCurrentEntryMetadata( + MUTATION_GROUP_METADATA_KEY, + { + before: null, + after: createMutationGroupMetadata(origin, groupId), + }, + ); } loadDocument(doc: CRDTDocument): void { @@ -477,10 +523,10 @@ class EditorImpl implements Editor { firstIndex === 0 ? "first" : { - after: ( - this._doc.blockOrder as CRDTArray - ).get(firstIndex - 1) as string, - }; + after: ( + this._doc.blockOrder as CRDTArray + ).get(firstIndex - 1) as string, + }; if (typeof content === "string") { const newId = createGeneratedBlockId(); @@ -728,20 +774,21 @@ class EditorImpl implements Editor { if (without.size > 0 && !hasWarnedAboutWithoutOption) { hasWarnedAboutWithoutOption = true; console.warn( - 'Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.', + "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", ); } - const defaultExtensions = - options.preset?.resolve({ - schema: this._registry, - documentProfile: this._documentProfile, - }).extensions ?? [ - documentOpsExtension(), - deltaStreamExtension(), - undoExtension(), - richTextShortcutsExtension(), - ]; - const defaults = defaultExtensions.filter((ext) => !without.has(ext.name)); + const defaultExtensions = options.preset?.resolve({ + schema: this._registry, + documentProfile: this._documentProfile, + }).extensions ?? [ + documentOpsExtension(), + deltaStreamExtension(), + undoExtension(), + richTextShortcutsExtension(), + ]; + const defaults = defaultExtensions.filter( + (ext) => !without.has(ext.name), + ); const userExtensions = options.extensions ?? []; return [...defaults, ...userExtensions]; @@ -788,14 +835,12 @@ class EditorImpl implements Editor { ); this._slots.set( COLLECT_KEY_BINDINGS_SLOT_KEY, - (registry: SchemaRegistry) => this._extensions.collectKeyBindings(registry), + (registry: SchemaRegistry) => + this._extensions.collectKeyBindings(registry), ); } - private _bindSession( - session: DocumentSession, - scopeId?: string, - ): void { + private _bindSession(session: DocumentSession, scopeId?: string): void { this._bindScope(session, scopeId); this._releaseSession = session.attachEditor({ onScopeReplaced: (event) => { @@ -804,10 +849,7 @@ class EditorImpl implements Editor { }); } - private _bindScope( - session: DocumentSession, - scopeId?: string, - ): void { + private _bindScope(session: DocumentSession, scopeId?: string): void { this._documentSession = session; const scope = (scopeId ? session.getScope(scopeId) : null) ?? session.rootScope; @@ -840,7 +882,8 @@ class EditorImpl implements Editor { ): DocumentProfile { const persistedProfile = this._adapter.getDocumentProfile?.(this._crdtDoc) ?? null; - const resolvedProfile = persistedProfile ?? requestedProfile ?? "structured"; + const resolvedProfile = + persistedProfile ?? requestedProfile ?? "structured"; if (persistedProfile == null) { this._adapter.setDocumentProfile?.(this._crdtDoc, resolvedProfile); } @@ -911,7 +954,10 @@ class EditorImpl implements Editor { } }; - this._extensionLifecycle = this._extensionLifecycle.then(runTask, runTask); + this._extensionLifecycle = this._extensionLifecycle.then( + runTask, + runTask, + ); } private _ensureInitialParagraph(): void { @@ -991,10 +1037,13 @@ class EditorImpl implements Editor { return; } - this._unsubObserve = this._adapter.observe(this._crdtDoc, (event: CRDTEvent) => { - if (this._pipeline.suppressObserver) return; - this._dispatchCRDTEvent(event); - }); + this._unsubObserve = this._adapter.observe( + this._crdtDoc, + (event: CRDTEvent) => { + if (this._pipeline.suppressObserver) return; + this._dispatchCRDTEvent(event); + }, + ); } private _teardownObservation(): void { @@ -1192,7 +1241,10 @@ class EditorImpl implements Editor { const startInline = this._usesInlineTextSelection(startId); const endInline = this._usesInlineTextSelection(endId); if (startInline && endInline) { - const { ops, caret } = this._buildMultiBlockTextReplacement(range, ""); + const { ops, caret } = this._buildMultiBlockTextReplacement( + range, + "", + ); this.apply(ops, options); this._collapseToPoint(caret); return caret; @@ -1240,13 +1292,7 @@ class EditorImpl implements Editor { length: range.end.offset, }); } - } else if ( - this._isWholeBlockSelection( - endId, - 0, - range.end.offset, - ) - ) { + } else if (this._isWholeBlockSelection(endId, 0, range.end.offset)) { ops.push({ type: "delete-block", blockId: endId, @@ -1287,3 +1333,30 @@ class EditorImpl implements Editor { export function createEditor(options?: CreateEditorOptions): Editor { return new EditorImpl(options); } + +const headlessPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +export interface CreateHeadlessEditorOptions extends CreateEditorOptions { + /** + * Headless server/workflow editors default to the core apply pipeline only. + * Enable default extensions when a host explicitly needs undo, shortcuts, or + * delta stream behavior in a non-rendered environment. + */ + useDefaultExtensions?: boolean; +} + +export function createHeadlessEditor( + options: CreateHeadlessEditorOptions = {}, +): Editor { + const { useDefaultExtensions = false, ...editorOptions } = options; + return createEditor({ + ...editorOptions, + preset: + editorOptions.preset ?? + (useDefaultExtensions ? undefined : headlessPreset), + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3884cd8..3c031f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,56 +1,51 @@ import { - filterOpsForDocumentProfile, - filterPendingBlocksForDocumentProfile, - createImportResult, - isContinuousTextFlowCapability, - normalizePendingBlocksForImport, - reportPendingBlockImportViolations, - reportPendingBlockProfileViolations, - resolveBlockFlowCapability, - shouldAllowDirectBlockPaste, - shouldAllowFlowInsertionInSlashMenu, - shouldFallbackMixedSelectionToBlock, - shouldForceBlockScopedSelectAll, + filterOpsForDocumentProfile, + filterPendingBlocksForDocumentProfile, + createImportResult, + isContinuousTextFlowCapability, + normalizePendingBlocksForImport, + reportPendingBlockImportViolations, + reportPendingBlockProfileViolations, + resolveBlockFlowCapability, + shouldAllowDirectBlockPaste, + shouldAllowFlowInsertionInSlashMenu, + shouldFallbackMixedSelectionToBlock, + shouldForceBlockScopedSelectAll, } from "./editor/profilePolicy"; // Contracts live in @pen/types. // Keep @pen/core focused on runtime entrypoints and advanced internals. // Schema engine runtime -export { - SchemaRegistryImpl, - mergeSchemas, -} from "./schema/registry"; +export { SchemaRegistryImpl, mergeSchemas } from "./schema/registry"; export type { SchemaRegistryConfig } from "./schema/registry"; export { - SchemaEngineImpl, - deepEqual, - sortDeltaAttributes, + SchemaEngineImpl, + deepEqual, + sortDeltaAttributes, } from "./schema/normalize"; -export { - createBlockHandle, - createAppHandle, -} from "./schema/handles"; +export { createBlockHandle, createAppHandle } from "./schema/handles"; export { suggestion } from "./schema/system-marks/suggestion"; // Editor runtime -export { createEditor } from "./editor/editor"; +export { createEditor, createHeadlessEditor } from "./editor/editor"; +export type { CreateHeadlessEditorOptions } from "./editor/editor"; export { - createDocumentSession, - DocumentSessionImpl, + createDocumentSession, + DocumentSessionImpl, } from "./editor/documentSession"; export { EventEmitter } from "./editor/events"; export { - createDecorationSet, - emptyDecorationSet, - mergeDecorationSets, + createDecorationSet, + emptyDecorationSet, + mergeDecorationSets, } from "./editor/decorations"; export { - ensureInlineCompletionController, - getInlineCompletionController, + ensureInlineCompletionController, + getInlineCompletionController, } from "./editor/inlineCompletion"; export { DocumentStateImpl } from "./editor/documentState"; export { DocumentRangeImpl } from "./editor/range"; @@ -64,28 +59,31 @@ export { } from "./editor/cellSelection"; export { getNumberedListItemValue } from "./editor/orderedList"; export { - createImportResult, - filterOpsForDocumentProfile, - filterPendingBlocksForDocumentProfile, - isContinuousTextFlowCapability, - normalizePendingBlocksForImport, - reportPendingBlockImportViolations, - reportPendingBlockProfileViolations, - resolveBlockFlowCapability, - shouldAllowDirectBlockPaste, - shouldAllowFlowInsertionInSlashMenu, - shouldFallbackMixedSelectionToBlock, - shouldForceBlockScopedSelectAll, + createImportResult, + filterOpsForDocumentProfile, + filterPendingBlocksForDocumentProfile, + isContinuousTextFlowCapability, + normalizePendingBlocksForImport, + reportPendingBlockImportViolations, + reportPendingBlockProfileViolations, + resolveBlockFlowCapability, + shouldAllowDirectBlockPaste, + shouldAllowFlowInsertionInSlashMenu, + shouldFallbackMixedSelectionToBlock, + shouldForceBlockScopedSelectAll, }; export type { - PendingBlockImportPolicyViolation, - PendingBlockProfilePolicyViolation, - ProfilePolicyViolation, + PendingBlockImportPolicyViolation, + PendingBlockProfilePolicyViolation, + ProfilePolicyViolation, } from "./editor/profilePolicy"; // Importer utilities (used by Wave 4 importers) export { blocksToOps } from "./importerUtils"; -export type { PendingBlock, ImportOptions as ImporterOptions } from "./importerUtils"; +export type { + PendingBlock, + ImportOptions as ImporterOptions, +} from "./importerUtils"; // Exporter utilities (shared by Wave 4 exporters) export { buildTableChildren, buildDatabaseData } from "./exporterUtils"; diff --git a/packages/crdt/yjs/README.md b/packages/crdt/yjs/README.md index de43d3f..7b3509a 100644 --- a/packages/crdt/yjs/README.md +++ b/packages/crdt/yjs/README.md @@ -7,9 +7,74 @@ This package provides: - the Pen Yjs CRDT adapter via `yjsAdapter()` - Yjs awareness helpers - a thin provider wrapper for multiplayer sessions +- Yjs state-vector helpers for sync/workflow barriers +- generic Yjs text and array field adapters for host-owned CRDT fields +- generic extension-root helpers for app-owned Yjs maps under `apps` It does **not** implement WebSocket transport or a custom Yjs sync provider. +## State barriers + +```ts +import { + encodeYjsStateVectorBase64, + isYjsStateVectorBase64Satisfied, +} from "@pen/crdt-yjs"; + +const required = encodeYjsStateVectorBase64(ydoc); +const ready = isYjsStateVectorBase64Satisfied(currentStateVector, required); +``` + +Use state-vector helpers when a host workflow needs to wait until a synced document includes a known local edit. + +## Field adapters + +```ts +import { + createYArrayFieldAdapter, + createYTextFieldAdapter, +} from "@pen/crdt-yjs"; + +const title = createYTextFieldAdapter({ + doc: ydoc, + root: ydoc.getMap("app"), + key: "title", + normalize: (value) => value.trim(), +}); + +const tags = createYArrayFieldAdapter({ + doc: ydoc, + root: ydoc.getMap("app"), + key: "tags", + getId: (tag) => tag.id, +}); +``` + +Adapters are storage helpers only. Product validation, labels, contacts, auth, and delivery semantics belong in the host app. + +## Extension roots + +```ts +import { ensureExtensionRoot, readExtensionRoot } from "@pen/crdt-yjs"; + +const root = ensureExtensionRoot({ + doc: ydoc, + namespace: "com.example.workflow", + version: 1, + shape: { + title: "text", + requests: "array", + }, +}); + +const existing = readExtensionRoot({ + doc: ydoc, + namespace: "com.example.workflow", +}); +``` + +Extension roots give host apps a predictable place for CRDT-backed data that travels with the Pen document while staying outside Pen's core block model. + ## Collaboration boundary When using multiplayer with Yjs, Pen expects the application to choose the provider and hand Pen a `MultiplayerSession`. diff --git a/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts b/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts new file mode 100644 index 0000000..03d1b4b --- /dev/null +++ b/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import { ensureExtensionRoot, readExtensionRoot } from "../extensionRoots"; + +describe("extensionRoots", () => { + it("initializes a namespaced root with version and shape", () => { + const doc = new Y.Doc(); + + const root = ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + shape: { + title: "text", + tags: "array", + settings: "map", + ready: "value", + }, + }); + + expect(root.map.get("version")).toBe(1); + expect(root.map.get("title")).toBeInstanceOf(Y.Text); + expect(root.map.get("tags")).toBeInstanceOf(Y.Array); + expect(root.map.get("settings")).toBeInstanceOf(Y.Map); + expect(root.map.get("ready")).toBeNull(); + }); + + it("is idempotent and reads existing roots", () => { + const doc = new Y.Doc(); + const first = ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + }); + const second = ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + }); + + expect(second.map).toBe(first.map); + expect(readExtensionRoot({ doc, namespace: "example.tags" })).toEqual({ + namespace: "example.tags", + version: 1, + map: first.map, + }); + }); + + it("fails safely on version or shape mismatches", () => { + const doc = new Y.Doc(); + ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + shape: { tags: "array" }, + }); + + expect(() => + ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 2, + }), + ).toThrow("version mismatch"); + + expect(() => + ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + shape: { tags: "map" }, + }), + ).toThrow('field "tags" exists but is not map'); + }); +}); diff --git a/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts b/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts new file mode 100644 index 0000000..7a6744a --- /dev/null +++ b/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; + +import { + createYArrayFieldAdapter, + createYTextFieldAdapter, +} from "../fieldAdapters"; + +type TestItem = { + id: string; + label: string; + value?: string; +}; + +describe("fieldAdapters", () => { + it("reads, writes, normalizes, and observes Y.Text fields", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const onChange = vi.fn(); + const field = createYTextFieldAdapter({ + doc, + root, + key: "title", + normalize: (value) => value.trim(), + }); + + const unsubscribe = field.observe(onChange); + field.replace(" Hello "); + + expect(field.read()).toBe("Hello"); + expect(onChange).toHaveBeenCalledTimes(1); + unsubscribe(); + field.replace("Bye"); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("replaces, inserts, updates, and removes array items by stable id", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const field = createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + normalizeItem: (item) => ({ + ...item, + label: item.label.trim(), + }), + }); + + field.replace([{ id: "a", label: " A " }]); + field.insert({ id: "b", label: "B" }); + expect(field.update("a", { value: "updated" })).toBe(true); + expect(field.remove("b")).toBe(true); + + expect(field.read()).toEqual([ + { id: "a", label: "A", value: "updated" }, + ]); + }); + + it("updates an array item without replacing sibling Y.Map instances", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const field = createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + }); + + field.replace([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ]); + const array = root.get("items") as Y.Array>; + const siblingBefore = array.get(1); + + field.update("a", { label: "A+" }); + + expect(array.get(1)).toBe(siblingBefore); + expect(field.read()).toEqual([ + { id: "a", label: "A+" }, + { id: "b", label: "B" }, + ]); + }); + + it("observes array item updates", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const onChange = vi.fn(); + const field = createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + }); + + field.replace([{ id: "a", label: "A" }]); + const unsubscribe = field.observe(onChange); + field.update("a", { label: "A+" }); + + expect(onChange).toHaveBeenCalledTimes(1); + unsubscribe(); + field.update("a", { label: "A++" }); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/crdt/yjs/src/__tests__/stateVector.test.ts b/packages/crdt/yjs/src/__tests__/stateVector.test.ts new file mode 100644 index 0000000..2814f86 --- /dev/null +++ b/packages/crdt/yjs/src/__tests__/stateVector.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import { + compareYjsStateVectorBase64, + compareYjsStateVectors, + encodeYjsStateVector, + encodeYjsStateVectorBase64, + isYjsStateVectorBase64Satisfied, + isYjsStateVectorSatisfied, +} from "../stateVector"; + +describe("stateVector", () => { + it("treats an absent required vector as satisfied", () => { + const doc = new Y.Doc(); + + expect(isYjsStateVectorSatisfied(encodeYjsStateVector(doc))).toBe(true); + }); + + it("satisfies identical state vectors", () => { + const doc = new Y.Doc(); + doc.getText("body").insert(0, "Hello"); + const stateVector = encodeYjsStateVector(doc); + + expect(compareYjsStateVectors(stateVector, stateVector)).toEqual({ + satisfied: true, + missingClients: [], + }); + }); + + it("satisfies when the current vector has higher clocks", () => { + const doc = new Y.Doc(); + const text = doc.getText("body"); + text.insert(0, "A"); + const required = encodeYjsStateVector(doc); + + text.insert(1, "B"); + const current = encodeYjsStateVector(doc); + + expect(isYjsStateVectorSatisfied(current, required)).toBe(true); + }); + + it("reports missing client clocks", () => { + const doc = new Y.Doc(); + const text = doc.getText("body"); + text.insert(0, "A"); + const current = encodeYjsStateVector(doc); + + text.insert(1, "B"); + const required = encodeYjsStateVector(doc); + + const result = compareYjsStateVectors(current, required); + expect(result.satisfied).toBe(false); + expect(result.missingClients).toHaveLength(1); + expect(result.missingClients[0]?.currentClock).toBeLessThan( + result.missingClients[0]?.requiredClock ?? 0, + ); + }); + + it("ignores extra current clients", () => { + const requiredDoc = new Y.Doc(); + requiredDoc.getText("body").insert(0, "Required"); + const requiredUpdate = Y.encodeStateAsUpdate(requiredDoc); + const required = encodeYjsStateVector(requiredDoc); + + const currentDoc = new Y.Doc(); + Y.applyUpdate(currentDoc, requiredUpdate); + currentDoc.getText("local").insert(0, "Extra"); + + expect( + isYjsStateVectorSatisfied( + encodeYjsStateVector(currentDoc), + required, + ), + ).toBe(true); + }); + + it("round-trips base64 state vectors", () => { + const doc = new Y.Doc(); + doc.getText("body").insert(0, "Hello"); + const stateVector = encodeYjsStateVectorBase64(doc); + + expect(isYjsStateVectorBase64Satisfied(stateVector, stateVector)).toBe( + true, + ); + }); + + it("fails closed for malformed state vectors", () => { + const doc = new Y.Doc(); + doc.getText("body").insert(0, "Hello"); + + const result = compareYjsStateVectorBase64( + "not-a-state-vector", + encodeYjsStateVectorBase64(doc), + ); + + expect(result.satisfied).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/packages/crdt/yjs/src/extensionRoots.ts b/packages/crdt/yjs/src/extensionRoots.ts new file mode 100644 index 0000000..b767802 --- /dev/null +++ b/packages/crdt/yjs/src/extensionRoots.ts @@ -0,0 +1,161 @@ +import * as Y from "yjs"; + +export type YjsExtensionRootFieldType = "array" | "map" | "text" | "value"; + +export type YjsExtensionRootShape = Record; + +export interface YjsExtensionRootOptions { + doc: Y.Doc; + namespace: string; + version: number; + shape?: YjsExtensionRootShape; + rootName?: string; + origin?: unknown; +} + +export interface YjsExtensionRoot { + namespace: string; + version: number; + map: Y.Map; +} + +export interface YjsExtensionRootReadOptions { + doc: Y.Doc; + namespace: string; + rootName?: string; +} + +export class YjsExtensionRootError extends Error { + constructor(message: string) { + super(message); + this.name = "YjsExtensionRootError"; + } +} + +const DEFAULT_ROOT_NAME = "apps"; +const VERSION_KEY = "version"; + +export function ensureExtensionRoot( + options: YjsExtensionRootOptions, +): YjsExtensionRoot { + const apps = options.doc.getMap>( + options.rootName ?? DEFAULT_ROOT_NAME, + ); + let root = apps.get(options.namespace); + + options.doc.transact( + () => { + if (root !== undefined && !(root instanceof Y.Map)) { + throw new YjsExtensionRootError( + `Extension root "${options.namespace}" exists but is not a Y.Map.`, + ); + } + + if (!root) { + root = new Y.Map(); + apps.set(options.namespace, root); + } + + const existingVersion = root.get(VERSION_KEY); + if ( + existingVersion !== undefined && + existingVersion !== options.version + ) { + throw new YjsExtensionRootError( + `Extension root "${options.namespace}" version mismatch: expected ${options.version}, got ${String(existingVersion)}.`, + ); + } + + root.set(VERSION_KEY, options.version); + ensureExtensionRootShape(root, options.shape); + }, + options.origin ?? `pen:extension-root:${options.namespace}`, + ); + + if (!root) { + throw new YjsExtensionRootError( + `Extension root "${options.namespace}" was not initialized.`, + ); + } + + return { + namespace: options.namespace, + version: options.version, + map: root, + }; +} + +export function readExtensionRoot( + options: YjsExtensionRootReadOptions, +): YjsExtensionRoot | undefined { + const apps = options.doc.getMap>( + options.rootName ?? DEFAULT_ROOT_NAME, + ); + const root = apps.get(options.namespace); + if (!(root instanceof Y.Map)) { + return undefined; + } + + const version = root.get(VERSION_KEY); + return { + namespace: options.namespace, + version: typeof version === "number" ? version : 0, + map: root, + }; +} + +function ensureExtensionRootShape( + root: Y.Map, + shape: YjsExtensionRootShape | undefined, +): void { + if (!shape) { + return; + } + + for (const [key, type] of Object.entries(shape)) { + if (key === VERSION_KEY) { + continue; + } + + const current = root.get(key); + if (current !== undefined) { + if (!isExpectedFieldType(current, type)) { + throw new YjsExtensionRootError( + `Extension root field "${key}" exists but is not ${type}.`, + ); + } + continue; + } + + root.set(key, createFieldValue(type)); + } +} + +function isExpectedFieldType( + value: unknown, + type: YjsExtensionRootFieldType, +): boolean { + if (type === "array") { + return value instanceof Y.Array; + } + if (type === "map") { + return value instanceof Y.Map; + } + if (type === "text") { + return value instanceof Y.Text; + } + return !(value instanceof Y.AbstractType); +} + +function createFieldValue(type: YjsExtensionRootFieldType): unknown { + if (type === "array") { + return new Y.Array(); + } + if (type === "map") { + return new Y.Map(); + } + if (type === "text") { + return new Y.Text(); + } + return null; +} diff --git a/packages/crdt/yjs/src/fieldAdapters.ts b/packages/crdt/yjs/src/fieldAdapters.ts new file mode 100644 index 0000000..37b4566 --- /dev/null +++ b/packages/crdt/yjs/src/fieldAdapters.ts @@ -0,0 +1,215 @@ +import * as Y from "yjs"; + +export type YjsFieldObserver = () => void; +export type YjsFieldUnsubscribe = () => void; + +export interface YTextFieldAdapter { + read(): string; + replace(value: string): void; + observe(callback: YjsFieldObserver): YjsFieldUnsubscribe; +} + +export interface CreateYTextFieldAdapterOptions { + doc: Y.Doc; + root: Y.Map; + key: string; + normalize?: (value: string) => string; + origin?: unknown; +} + +export interface YArrayFieldAdapter { + read(): T[]; + replace(value: readonly T[]): void; + insert(item: T, index?: number): void; + update(id: string, patch: Partial | ((item: T) => T)): boolean; + remove(id: string): boolean; + observe(callback: YjsFieldObserver): YjsFieldUnsubscribe; +} + +export interface CreateYArrayFieldAdapterOptions { + doc: Y.Doc; + root: Y.Map; + key: string; + getId: (item: T) => string; + normalizeItem?: (item: T) => T; + fromYMap?: (item: Y.Map) => T | undefined; + toYMap?: (item: T) => Y.Map; + origin?: unknown; +} + +export function createYTextFieldAdapter( + options: CreateYTextFieldAdapterOptions, +): YTextFieldAdapter { + const text = ensureYText(options.root, options.key); + + return { + read() { + return text.toString(); + }, + replace(value) { + const normalized = options.normalize?.(value) ?? value; + options.doc.transact( + () => { + text.delete(0, text.length); + text.insert(0, normalized); + }, + options.origin ?? `pen:y-text-field:${options.key}`, + ); + }, + observe(callback) { + text.observe(callback); + return () => text.unobserve(callback); + }, + }; +} + +export function createYArrayFieldAdapter( + options: CreateYArrayFieldAdapterOptions, +): YArrayFieldAdapter { + const array = ensureYArray>(options.root, options.key); + const readItem = options.fromYMap ?? defaultFromYMap; + const serializeItem = options.toYMap ?? defaultToYMap; + const writeItem = (item: T) => + serializeItem(normalizeArrayItem(item, options)); + + return { + read() { + return array.toArray().flatMap((item) => { + const value = readItem(item); + return value ? [normalizeArrayItem(value, options)] : []; + }); + }, + replace(value) { + options.doc.transact( + () => { + array.delete(0, array.length); + array.push(value.map((item) => writeItem(item))); + }, + options.origin ?? `pen:y-array-field:${options.key}:replace`, + ); + }, + insert(item, index = array.length) { + options.doc.transact( + () => { + array.insert(Math.min(Math.max(index, 0), array.length), [ + writeItem(item), + ]); + }, + options.origin ?? `pen:y-array-field:${options.key}:insert`, + ); + }, + update(id, patch) { + const match = findArrayItem(array, readItem, options.getId, id); + if (!match) { + return false; + } + + const current = normalizeArrayItem(match.item, options); + const next = + typeof patch === "function" + ? patch(current) + : ({ ...current, ...patch } as T); + const normalized = normalizeArrayItem(next, options); + options.doc.transact( + () => { + if (options.toYMap) { + array.delete(match.index, 1); + array.insert(match.index, [writeItem(normalized)]); + return; + } + replaceYMapContents(match.yMap, normalized); + }, + options.origin ?? `pen:y-array-field:${options.key}:update`, + ); + return true; + }, + remove(id) { + const match = findArrayItem(array, readItem, options.getId, id); + if (!match) { + return false; + } + + options.doc.transact( + () => { + array.delete(match.index, 1); + }, + options.origin ?? `pen:y-array-field:${options.key}:remove`, + ); + return true; + }, + observe(callback) { + array.observeDeep(callback); + return () => array.unobserveDeep(callback); + }, + }; +} + +function ensureYText(root: Y.Map, key: string): Y.Text { + const current = root.get(key); + if (current instanceof Y.Text) { + return current; + } + const next = new Y.Text(); + root.set(key, next); + return next; +} + +function ensureYArray(root: Y.Map, key: string): Y.Array { + const current = root.get(key); + if (current instanceof Y.Array) { + return current as Y.Array; + } + const next = new Y.Array(); + root.set(key, next); + return next; +} + +function normalizeArrayItem( + item: T, + options: CreateYArrayFieldAdapterOptions, +): T { + return options.normalizeItem?.(item) ?? item; +} + +function defaultToYMap(item: T): Y.Map { + const yMap = new Y.Map(); + for (const [key, value] of Object.entries(item)) { + yMap.set(key, value); + } + return yMap; +} + +function defaultFromYMap( + item: Y.Map, +): T | undefined { + return Object.fromEntries(item.entries()) as T; +} + +function findArrayItem( + array: Y.Array>, + readItem: (item: Y.Map) => T | undefined, + getId: (item: T) => string, + id: string, +): { index: number; yMap: Y.Map; item: T } | undefined { + const values = array.toArray(); + for (let index = 0; index < values.length; index += 1) { + const yMap = values[index]!; + const item = readItem(yMap); + if (item && getId(item) === id) { + return { index, yMap, item }; + } + } + return undefined; +} + +function replaceYMapContents( + target: Y.Map, + source: T, +): void { + for (const key of Array.from(target.keys())) { + target.delete(key); + } + for (const [key, value] of Object.entries(source)) { + target.set(key, value); + } +} diff --git a/packages/crdt/yjs/src/index.ts b/packages/crdt/yjs/src/index.ts index 078c32f..1904e75 100644 --- a/packages/crdt/yjs/src/index.ts +++ b/packages/crdt/yjs/src/index.ts @@ -37,3 +37,40 @@ export type { DocumentValidationResult, DocumentValidationError, } from "./document"; +export { + compareYjsStateVectorBase64, + compareYjsStateVectors, + decodeYjsStateVectorBase64, + encodeYjsStateVector, + encodeYjsStateVectorBase64, + isYjsStateVectorBase64Satisfied, + isYjsStateVectorSatisfied, +} from "./stateVector"; +export type { + YjsStateVectorComparison, + YjsStateVectorMissingClient, +} from "./stateVector"; +export { + createYArrayFieldAdapter, + createYTextFieldAdapter, +} from "./fieldAdapters"; +export type { + CreateYArrayFieldAdapterOptions, + CreateYTextFieldAdapterOptions, + YArrayFieldAdapter, + YTextFieldAdapter, + YjsFieldObserver, + YjsFieldUnsubscribe, +} from "./fieldAdapters"; +export { + YjsExtensionRootError, + ensureExtensionRoot, + readExtensionRoot, +} from "./extensionRoots"; +export type { + YjsExtensionRoot, + YjsExtensionRootFieldType, + YjsExtensionRootOptions, + YjsExtensionRootReadOptions, + YjsExtensionRootShape, +} from "./extensionRoots"; diff --git a/packages/crdt/yjs/src/stateVector.ts b/packages/crdt/yjs/src/stateVector.ts new file mode 100644 index 0000000..9e7c1f3 --- /dev/null +++ b/packages/crdt/yjs/src/stateVector.ts @@ -0,0 +1,155 @@ +import * as Y from "yjs"; + +type Base64Buffer = { + toString(encoding: "base64"): string; +}; + +type BufferConstructorLike = { + from(value: Uint8Array): Base64Buffer; + from(value: string, encoding: "base64"): Uint8Array; +}; + +type Base64Globals = typeof globalThis & { + Buffer?: BufferConstructorLike; + atob?: (value: string) => string; + btoa?: (value: string) => string; +}; + +export interface YjsStateVectorMissingClient { + clientId: number; + currentClock: number; + requiredClock: number; +} + +export interface YjsStateVectorComparison { + satisfied: boolean; + missingClients: YjsStateVectorMissingClient[]; + error?: string; +} + +export function encodeYjsStateVector(doc: Y.Doc): Uint8Array { + return Y.encodeStateVector(doc); +} + +export function encodeYjsStateVectorBase64(doc: Y.Doc): string { + return encodeUint8ArrayToBase64(encodeYjsStateVector(doc)); +} + +export function decodeYjsStateVectorBase64(value: string): Uint8Array { + return decodeBase64ToUint8Array(value); +} + +export function isYjsStateVectorSatisfied( + currentStateVector: Uint8Array, + requiredStateVector?: Uint8Array, +): boolean { + return compareYjsStateVectors(currentStateVector, requiredStateVector) + .satisfied; +} + +export function isYjsStateVectorBase64Satisfied( + currentStateVector: string, + requiredStateVector?: string, +): boolean { + return compareYjsStateVectorBase64(currentStateVector, requiredStateVector) + .satisfied; +} + +export function compareYjsStateVectorBase64( + currentStateVector: string, + requiredStateVector?: string, +): YjsStateVectorComparison { + try { + return compareYjsStateVectors( + decodeYjsStateVectorBase64(currentStateVector), + requiredStateVector + ? decodeYjsStateVectorBase64(requiredStateVector) + : undefined, + ); + } catch (error) { + return invalidStateVectorComparison(error); + } +} + +export function compareYjsStateVectors( + currentStateVector: Uint8Array, + requiredStateVector?: Uint8Array, +): YjsStateVectorComparison { + if (!requiredStateVector) { + return { satisfied: true, missingClients: [] }; + } + + try { + const current = Y.decodeStateVector(currentStateVector); + const required = Y.decodeStateVector(requiredStateVector); + const missingClients: YjsStateVectorMissingClient[] = []; + + for (const [clientId, requiredClock] of required.entries()) { + const currentClock = current.get(clientId) ?? 0; + if (currentClock < requiredClock) { + missingClients.push({ clientId, currentClock, requiredClock }); + } + } + + return { + satisfied: missingClients.length === 0, + missingClients, + }; + } catch (error) { + return invalidStateVectorComparison(error); + } +} + +function invalidStateVectorComparison( + error: unknown, +): YjsStateVectorComparison { + return { + satisfied: false, + missingClients: [], + error: + error instanceof Error ? error.message : "Invalid Yjs state vector", + }; +} + +function encodeUint8ArrayToBase64(value: Uint8Array): string { + const buffer = getBuffer(); + if (buffer) { + return buffer.from(value).toString("base64"); + } + + const btoa = getBase64Global("btoa"); + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function decodeBase64ToUint8Array(value: string): Uint8Array { + const buffer = getBuffer(); + if (buffer) { + return new Uint8Array(buffer.from(value, "base64")); + } + + const atob = getBase64Global("atob"); + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function getBuffer(): BufferConstructorLike | undefined { + return (globalThis as Base64Globals).Buffer; +} + +function getBase64Global(name: "atob" | "btoa"): (value: string) => string { + const fn = (globalThis as Base64Globals)[name]; + if (!fn) { + throw new Error( + `globalThis.${name} is required to encode Yjs state vectors as base64`, + ); + } + return fn; +} diff --git a/packages/crdt/yjs/src/undo.ts b/packages/crdt/yjs/src/undo.ts index a77fb08..6ff18a1 100644 --- a/packages/crdt/yjs/src/undo.ts +++ b/packages/crdt/yjs/src/undo.ts @@ -1,7 +1,8 @@ import type { - CRDTUndoManager, - CRDTUndoStackItem, - UndoManagerOptions, + CRDTUndoManager, + CRDTUndoStackItem, + OpOrigin, + UndoManagerOptions, } from "@pen/types"; import { HISTORY_ORIGIN_TAG } from "@pen/types"; import * as Y from "yjs"; @@ -9,100 +10,103 @@ import * as Y from "yjs"; import type { YjsCRDTDocument } from "./document"; export function createYjsUndoManager( - doc: YjsCRDTDocument, - options?: UndoManagerOptions, + doc: YjsCRDTDocument, + options?: UndoManagerOptions, ): CRDTUndoManager { - const { blockOrder, blocks } = doc.penDocument; - const trackedOrigins = new Set( - options?.trackedOrigins ?? ["user", "ai"], - ); + const { blockOrder, blocks } = doc.penDocument; + const trackedOrigins = new Set( + options?.trackedOrigins ?? ["user", "ai"], + ); - const undoManager = new Y.UndoManager([blockOrder, blocks], { - trackedOrigins, - captureTimeout: options?.captureTimeout ?? 0, - doc: doc.ydoc, - }); + const undoManager = new Y.UndoManager([blockOrder, blocks], { + trackedOrigins, + captureTimeout: options?.captureTimeout ?? 0, + doc: doc.ydoc, + }); - (undoManager as unknown as Record)[HISTORY_ORIGIN_TAG] = true; + (undoManager as unknown as Record)[HISTORY_ORIGIN_TAG] = + true; - const wrapStackItem = (stackItem: { - meta: Map; - }): CRDTUndoStackItem => ({ - getMeta(key: string): T | undefined { - return stackItem.meta.get(key) as T | undefined; - }, - setMeta(key: string, value: unknown): void { - stackItem.meta.set(key, value); - }, - }); + const wrapStackItem = (stackItem: { + meta: Map; + }): CRDTUndoStackItem => ({ + getMeta(key: string): T | undefined { + return stackItem.meta.get(key) as T | undefined; + }, + setMeta(key: string, value: unknown): void { + stackItem.meta.set(key, value); + }, + }); - return { - addTrackedOrigin(origin) { - undoManager.addTrackedOrigin(origin); - }, - removeTrackedOrigin(origin) { - undoManager.removeTrackedOrigin(origin); - }, - undo() { - if (undoManager.undoStack.length === 0) return false; - undoManager.undo(); - return true; - }, - redo() { - if (undoManager.redoStack.length === 0) return false; - undoManager.redo(); - return true; - }, - canUndo() { - return undoManager.undoStack.length > 0; - }, - canRedo() { - return undoManager.redoStack.length > 0; - }, - stopCapturing() { - undoManager.stopCapturing(); - }, - setCaptureTimeout(ms) { - (undoManager as Y.UndoManager & { captureTimeout?: number }).captureTimeout = ms; - }, - onStackItemAdded(callback) { - const handler = (event: { - stackItem: { meta: Map }; - type: "undo" | "redo"; - }) => { - callback(wrapStackItem(event.stackItem), event.type); - }; + return { + addTrackedOrigin(origin) { + undoManager.addTrackedOrigin(origin); + }, + removeTrackedOrigin(origin) { + undoManager.removeTrackedOrigin(origin); + }, + undo() { + if (undoManager.undoStack.length === 0) return false; + undoManager.undo(); + return true; + }, + redo() { + if (undoManager.redoStack.length === 0) return false; + undoManager.redo(); + return true; + }, + canUndo() { + return undoManager.undoStack.length > 0; + }, + canRedo() { + return undoManager.redoStack.length > 0; + }, + stopCapturing() { + undoManager.stopCapturing(); + }, + setCaptureTimeout(ms) { + ( + undoManager as Y.UndoManager & { captureTimeout?: number } + ).captureTimeout = ms; + }, + onStackItemAdded(callback) { + const handler = (event: { + stackItem: { meta: Map }; + type: "undo" | "redo"; + }) => { + callback(wrapStackItem(event.stackItem), event.type); + }; - undoManager.on("stack-item-added", handler); - return () => { - undoManager.off("stack-item-added", handler); - }; - }, - onStackItemUpdated(callback) { - const handler = (event: { - stackItem: { meta: Map }; - type: "undo" | "redo"; - }) => { - callback(wrapStackItem(event.stackItem), event.type); - }; + undoManager.on("stack-item-added", handler); + return () => { + undoManager.off("stack-item-added", handler); + }; + }, + onStackItemUpdated(callback) { + const handler = (event: { + stackItem: { meta: Map }; + type: "undo" | "redo"; + }) => { + callback(wrapStackItem(event.stackItem), event.type); + }; - undoManager.on("stack-item-updated", handler); - return () => { - undoManager.off("stack-item-updated", handler); - }; - }, - onStackItemPopped(callback) { - const handler = (event: { - stackItem: { meta: Map }; - type: "undo" | "redo"; - }) => { - callback(wrapStackItem(event.stackItem), event.type); - }; + undoManager.on("stack-item-updated", handler); + return () => { + undoManager.off("stack-item-updated", handler); + }; + }, + onStackItemPopped(callback) { + const handler = (event: { + stackItem: { meta: Map }; + type: "undo" | "redo"; + }) => { + callback(wrapStackItem(event.stackItem), event.type); + }; - undoManager.on("stack-item-popped", handler); - return () => { - undoManager.off("stack-item-popped", handler); - }; - }, - }; + undoManager.on("stack-item-popped", handler); + return () => { + undoManager.off("stack-item-popped", handler); + }; + }, + }; } diff --git a/packages/extensions/ai/src/extension.ts b/packages/extensions/ai/src/extension.ts index e860d1c..18763b4 100644 --- a/packages/extensions/ai/src/extension.ts +++ b/packages/extensions/ai/src/extension.ts @@ -3,7 +3,10 @@ import { ensureInlineCompletionController, getInlineCompletionController as getInlineCompletionControllerFromCore, } from "@pen/core"; -import { buildDocumentWriteOps, getDocumentToolRuntime } from "@pen/document-ops"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; import type { Decoration, DocumentOp, @@ -14,6 +17,7 @@ import type { ModelAdapter, ModelOperationScopedRangeTarget, ModelOperationSelectionTarget, + OpOrigin, SelectionState, StreamingTarget, TextSelection, @@ -28,6 +32,7 @@ import { AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, defineExtension, + getOpOriginType, isScopedSelectionTarget, renderSelectionTargetBlockText, resolveSelectionTargetBlockIds, @@ -178,14 +183,14 @@ const AI_SHORTCUT_KEY_BINDINGS: readonly KeyBinding[] = [ type GenerationTarget = | { - type: "block"; - blockId: string; - offset: number; - } + type: "block"; + blockId: string; + offset: number; + } | { - type: "selection"; - selection: TextSelection; - }; + type: "selection"; + selection: TextSelection; + }; interface GenerationExecutionContext { sessionId?: string; @@ -231,21 +236,17 @@ function isLocalRequestedOperation( operation?.kind === "rewrite-selection" || operation?.kind === "rewrite-block" || operation?.kind === "continue-block" || - ( - operation?.kind === "document-transform" && + (operation?.kind === "document-transform" && operation.target.kind === "document" && - ( - operation.target.transform === "rewrite" || + (operation.target.transform === "rewrite" || operation.target.transform === "remove" || - operation.target.placement === "replace-blocks" - ) - ) + operation.target.placement === "replace-blocks")) ); } const EMPTY_TOOL_RUNTIME: ToolRuntime = { - registerTool(_def: ToolDefinition): void { }, - unregisterTool(_name: string): void { }, + registerTool(_def: ToolDefinition): void {}, + unregisterTool(_name: string): void {}, listTools(): readonly ToolDefinition[] { return []; }, @@ -296,7 +297,7 @@ class AIInlineHistoryService implements AIInlineHistoryController { undoInlineHistory: () => boolean; redoInlineHistory: () => boolean; }, - ) { } + ) {} canUndoInlineHistory(): boolean { return this._handlers.canUndoInlineHistory(); @@ -332,7 +333,7 @@ class AIReviewService implements AIReviewController { acceptAllSuggestions: () => void; rejectAllSuggestions: () => void; }, - ) { } + ) {} getSuggestions(): readonly PersistentSuggestion[] { return this._handlers.getSuggestions(); @@ -382,8 +383,10 @@ class AIControllerImpl implements AIController { private readonly _undoHistoryMetadata: UndoHistoryMetadataController | null; private _inlineHistory: AIInlineHistorySnapshot[] = []; private _inlineHistoryIndex = -1; - private _pendingInlineHistoryRestore: AIInlineHistoryRestoreRequest | null = null; - private _queuedInlineHistoryShortcutDirections: AIInlineHistoryDirection[] = []; + private _pendingInlineHistoryRestore: AIInlineHistoryRestoreRequest | null = + null; + private _queuedInlineHistoryShortcutDirections: AIInlineHistoryDirection[] = + []; private _queuedInlineHistoryShortcutFlushScheduled = false; private _isRestoringInlineHistory = false; private _handledUndoHistoryRequestId: number | null = null; @@ -427,14 +430,19 @@ class AIControllerImpl implements AIController { this._syncSuggestionsFromDocument(); - this._unsubscribeInlineCompletion = this._inlineCompletion.subscribe(() => { - this._setState({ - ephemeralSuggestion: this._inlineCompletion.getState().visibleSuggestion, - }); - }); - this._unsubscribeHistoryApplied = this._editor.onHistoryApplied((event) => { - this._handleHistoryApplied(event); - }); + this._unsubscribeInlineCompletion = this._inlineCompletion.subscribe( + () => { + this._setState({ + ephemeralSuggestion: + this._inlineCompletion.getState().visibleSuggestion, + }); + }, + ); + this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( + (event) => { + this._handleHistoryApplied(event); + }, + ); this._unsubscribeUndoHistoryMetadata = this._undoHistoryMetadata?.registerMetadataRestorer( AI_UNDO_HISTORY_METADATA_KEY, @@ -475,7 +483,11 @@ class AIControllerImpl implements AIController { if (!activeSessionId) { return null; } - return this._state.sessions.find((session) => session.id === activeSessionId) ?? null; + return ( + this._state.sessions.find( + (session) => session.id === activeSessionId, + ) ?? null + ); } subscribeSessions(listener: () => void): () => void { @@ -506,7 +518,9 @@ class AIControllerImpl implements AIController { selection?.type === "text" ? resolveSelectionText(this._editor, selection) : "", - blockType: blockId ? this._editor.getBlock(blockId)?.type ?? null : null, + blockType: blockId + ? (this._editor.getBlock(blockId)?.type ?? null) + : null, blockId, }; } @@ -553,7 +567,10 @@ class AIControllerImpl implements AIController { target?: "auto" | "selection" | "block" | "document"; }): AISession | null { const surface = input?.surface ?? "inline-edit"; - const target = resolveSessionTarget(this._editor, input?.target ?? "selection"); + const target = resolveSessionTarget( + this._editor, + input?.target ?? "selection", + ); if (surface === "inline-edit" && target.kind !== "selection") { return null; } @@ -602,7 +619,9 @@ class AIControllerImpl implements AIController { } updateContextualPromptDraft(sessionId: string, draftPrompt: string): void { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session?.contextualPrompt) { return; } @@ -621,7 +640,9 @@ class AIControllerImpl implements AIController { sessionId: string, rect: AIContextualPromptRect | null, ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session?.contextualPrompt) { return; } @@ -657,9 +678,13 @@ class AIControllerImpl implements AIController { prompt: string, options?: AICommandExecutionOptions, ): Promise { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session) { - return Promise.reject(new Error(`Unknown AI session "${sessionId}"`)); + return Promise.reject( + new Error(`Unknown AI session "${sessionId}"`), + ); } this._recordInlinePromptSubmissionCheckpoint(sessionId, prompt); @@ -673,41 +698,51 @@ class AIControllerImpl implements AIController { this._documentVersion, ); if (operation.kind === "rewrite-selection") { - const selection = resolveSelectionForRequestedOperation(this._editor, operation); + const selection = resolveSelectionForRequestedOperation( + this._editor, + operation, + ); if (!selection) { return Promise.reject( - new Error("Cannot run a session prompt without a valid text selection"), + new Error( + "Cannot run a session prompt without a valid text selection", + ), ); } - return this._runSelectionGeneration(prompt, selection, undefined, options?.maxSteps, { - sessionId, - surface: session.surface, - operation, - }); + return this._runSelectionGeneration( + prompt, + selection, + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + }, + ); } if (operation.kind === "document-transform") { const targetBlockIds = operation.target.kind === "document" && - (operation.target.blockIds?.length ?? 0) > 0 + (operation.target.blockIds?.length ?? 0) > 0 ? [...(operation.target.blockIds ?? [])] : undefined; - const replacePreviousGeneratedBlocks = shouldReplacePreviousGeneratedBlocks( - session, - prompt, - ); + const replacePreviousGeneratedBlocks = + shouldReplacePreviousGeneratedBlocks(session, prompt); return this._runDocumentGeneration( prompt, options?.blockId ?? - (operation.target.kind === "document" - ? operation.target.activeBlockId - : null), + (operation.target.kind === "document" + ? operation.target.activeBlockId + : null), undefined, options?.maxSteps, { sessionId, surface: session.surface, operation, - replaceBlockIds: targetBlockIds ?? + replaceBlockIds: + targetBlockIds ?? (replacePreviousGeneratedBlocks ? resolvePreviousGeneratedBlockIds(session) : undefined), @@ -715,12 +750,15 @@ class AIControllerImpl implements AIController { ); } const blockId = - options?.blockId ?? resolveBlockIdForRequestedOperation(operation) ?? + options?.blockId ?? + resolveBlockIdForRequestedOperation(operation) ?? this._editor.lastBlock()?.id ?? this._editor.firstBlock()?.id; if (!blockId) { return Promise.reject( - new Error("Cannot run an AI session prompt without a target block"), + new Error( + "Cannot run an AI session prompt without a target block", + ), ); } return this._runBlockGeneration( @@ -741,7 +779,9 @@ class AIControllerImpl implements AIController { prompt: string, options?: AICommandExecutionOptions, ): boolean { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session) { return false; } @@ -757,11 +797,19 @@ class AIControllerImpl implements AIController { options, this._documentVersion, ); - return canReuseBottomChatSessionOperation(session.operation, nextOperation); + return canReuseBottomChatSessionOperation( + session.operation, + nextOperation, + ); } - resolveSession(sessionId: string, resolution: AISessionResolution): boolean { - const session = this._state.sessions.find((item) => item.id === sessionId); + resolveSession( + sessionId: string, + resolution: AISessionResolution, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session) { return false; } @@ -774,7 +822,8 @@ class AIControllerImpl implements AIController { } if (resolved) { const nextSession = - this._state.sessions.find((item) => item.id === sessionId) ?? session; + this._state.sessions.find((item) => item.id === sessionId) ?? + session; this._updateSession(sessionId, { status: "complete", pendingSuggestionIds: [], @@ -797,18 +846,20 @@ class AIControllerImpl implements AIController { if (this._state.activeGeneration?.sessionId === sessionId) { this.cancelActiveGeneration(); } - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); this._updateSession(sessionId, { status: "cancelled", contextualPrompt: session?.contextualPrompt ? { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: false, - isSubmitting: false, - }, - } + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: false, + isSubmitting: false, + }, + } : undefined, }); } @@ -828,7 +879,10 @@ class AIControllerImpl implements AIController { } canRedoInlineHistory(): boolean { - return this._inlineHistoryIndex >= 0 && this._inlineHistoryIndex < this._inlineHistory.length - 1; + return ( + this._inlineHistoryIndex >= 0 && + this._inlineHistoryIndex < this._inlineHistory.length - 1 + ); } undoInlineHistory(): boolean { @@ -845,12 +899,12 @@ class AIControllerImpl implements AIController { if (this._pendingInlineHistoryRestore) { return true; } - return this._canHandleInlineHistoryShortcut(direction, { shortcutOnly: true }); + return this._canHandleInlineHistoryShortcut(direction, { + shortcutOnly: true, + }); } - handleInlineHistoryShortcut( - direction: AIInlineHistoryDirection, - ): boolean { + handleInlineHistoryShortcut(direction: AIInlineHistoryDirection): boolean { if (this._pendingInlineHistoryRestore) { this._queuedInlineHistoryShortcutDirections.push(direction); return true; @@ -868,15 +922,26 @@ class AIControllerImpl implements AIController { throw new Error(`Unknown AI command "${commandId}"`); } if (command.guard && !command.guard(ctx)) { - throw new Error(`AI command "${command.label}" is not available in this context`); + throw new Error( + `AI command "${command.label}" is not available in this context`, + ); } const prompt = this._registry.resolvePrompt(command, ctx); this._lastPrompt = prompt; this._lastCommandId = command.id; - if (command.target === "selection" && ctx.selection?.type === "text" && !ctx.selection.isCollapsed) { - return this._runSelectionGeneration(prompt, ctx.selection, command.id, options?.maxSteps); + if ( + command.target === "selection" && + ctx.selection?.type === "text" && + !ctx.selection.isCollapsed + ) { + return this._runSelectionGeneration( + prompt, + ctx.selection, + command.id, + options?.maxSteps, + ); } const targetBlockId = @@ -887,7 +952,12 @@ class AIControllerImpl implements AIController { if (!targetBlockId) { throw new Error("Cannot run AI command without a target block"); } - return this._runBlockGeneration(prompt, targetBlockId, command.id, options?.maxSteps); + return this._runBlockGeneration( + prompt, + targetBlockId, + command.id, + options?.maxSteps, + ); } async runPrompt( @@ -896,13 +966,23 @@ class AIControllerImpl implements AIController { ): Promise { this._lastPrompt = prompt; this._lastCommandId = null; - const promptTarget = resolvePromptTarget(this._editor.selection, options?.target); + const promptTarget = resolvePromptTarget( + this._editor.selection, + options?.target, + ); if (promptTarget === "selection") { const selection = this._editor.selection; if (selection?.type !== "text" || selection.isCollapsed) { - throw new Error("Cannot run a selection prompt without selected text"); + throw new Error( + "Cannot run a selection prompt without selected text", + ); } - return this._runSelectionGeneration(prompt, selection, undefined, options?.maxSteps); + return this._runSelectionGeneration( + prompt, + selection, + undefined, + options?.maxSteps, + ); } if (promptTarget === "document") { return this._runDocumentGeneration( @@ -920,7 +1000,12 @@ class AIControllerImpl implements AIController { if (!blockId) { throw new Error("Cannot run AI prompt without a target block"); } - return this._runBlockGeneration(prompt, blockId, undefined, options?.maxSteps); + return this._runBlockGeneration( + prompt, + blockId, + undefined, + options?.maxSteps, + ); } async retryActiveGeneration(): Promise { @@ -941,7 +1026,7 @@ class AIControllerImpl implements AIController { const retryTarget = activeSession?.target.kind === "document" ? "document" - : active?.target ?? "block"; + : (active?.target ?? "block"); return this.runSessionPrompt(active.sessionId, prompt, { blockId: retryTarget === "document" ? null : blockId, target: retryTarget, @@ -966,26 +1051,32 @@ class AIControllerImpl implements AIController { const existingSession = generation.sessionId != null ? (this._state.sessions.find( - (session) => session.id === generation.sessionId, - ) ?? null) + (session) => session.id === generation.sessionId, + ) ?? null) : null; const existingTurn = generation.turnId != null - ? (existingSession?.turns.find((turn) => turn.id === generation.turnId) ?? null) + ? (existingSession?.turns.find( + (turn) => turn.id === generation.turnId, + ) ?? null) : null; - const refreshSuggestionIds = - existingTurn?.suggestionIds.length - ? existingTurn.suggestionIds - : generation.suggestionIds; + const refreshSuggestionIds = existingTurn?.suggestionIds.length + ? existingTurn.suggestionIds + : generation.suggestionIds; const refreshedInlineSelectionTarget = generation.surface === "inline-edit" - ? resolveAcceptedInlineSelectionTarget( + ? (resolveAcceptedInlineSelectionTarget( this._editor, - existingTurn?.operation ?? generation.operation ?? undefined, + existingTurn?.operation ?? + generation.operation ?? + undefined, refreshSuggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor) + ) ?? resolveLiveInlineSelectionTarget(this._editor)) : null; - const accepted = acceptSuggestions(this._editor, generation.suggestionIds); + const accepted = acceptSuggestions( + this._editor, + generation.suggestionIds, + ); if (accepted) { this._resolveActiveGeneration({ suggestionIds: [], @@ -993,19 +1084,25 @@ class AIControllerImpl implements AIController { }); if (generation.sessionId) { if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "accepted", - suggestionIds: [], - structuredPreview: null, - anchor: refreshedInlineSelectionTarget - ? resolveSessionAnchor(refreshedInlineSelectionTarget.selection) - : undefined, - selection: refreshedInlineSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineSelectionTarget.selection, - ) - : undefined, - }); + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "accepted", + suggestionIds: [], + structuredPreview: null, + anchor: refreshedInlineSelectionTarget + ? resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + selection: refreshedInlineSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + }, + ); } this._updateSession(generation.sessionId, { status: "complete", @@ -1016,14 +1113,15 @@ class AIControllerImpl implements AIController { anchor: resolveSessionAnchor( refreshedInlineSelectionTarget.selection, ), - contextualPrompt: existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: resolveContextualPromptAnchor( - refreshedInlineSelectionTarget, - ), - } - : undefined, + contextualPrompt: + existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: resolveContextualPromptAnchor( + refreshedInlineSelectionTarget, + ), + } + : undefined, } : {}), }); @@ -1054,11 +1152,15 @@ class AIControllerImpl implements AIController { }); if (generation.sessionId) { if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "accepted", - reviewItemIds: [], - structuredPreview: null, - }); + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "accepted", + reviewItemIds: [], + structuredPreview: null, + }, + ); } this._updateSession(generation.sessionId, { status: "complete", @@ -1073,7 +1175,10 @@ class AIControllerImpl implements AIController { if (!generation) return false; if (generation.suggestionIds && generation.suggestionIds.length > 0) { - const rejected = rejectSuggestions(this._editor, generation.suggestionIds); + const rejected = rejectSuggestions( + this._editor, + generation.suggestionIds, + ); if (rejected) { this._resolveActiveGeneration({ suggestionIds: [], @@ -1082,11 +1187,15 @@ class AIControllerImpl implements AIController { }); if (generation.sessionId) { if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "rejected", - suggestionIds: [], - structuredPreview: null, - }); + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "rejected", + suggestionIds: [], + structuredPreview: null, + }, + ); } this._updateSession(generation.sessionId, { status: "complete", @@ -1105,11 +1214,15 @@ class AIControllerImpl implements AIController { }); if (generation.sessionId) { if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "rejected", - reviewItemIds: [], - structuredPreview: null, - }); + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "rejected", + reviewItemIds: [], + structuredPreview: null, + }, + ); } this._updateSession(generation.sessionId, { status: "complete", @@ -1156,7 +1269,10 @@ class AIControllerImpl implements AIController { return false; } - const reviewItems = resolveOrderedReviewItems(generation.reviewItems, ids); + const reviewItems = resolveOrderedReviewItems( + generation.reviewItems, + ids, + ); if (reviewItems.length === 0) { return false; } @@ -1169,20 +1285,19 @@ class AIControllerImpl implements AIController { return false; } const resolvedSelectedPlans = selectedPlans.filter( - ( - plan, - ): plan is NonNullable<(typeof selectedPlans)[number]> => plan != null, + (plan): plan is NonNullable<(typeof selectedPlans)[number]> => + plan != null, ); const selectedPlan = resolvedSelectedPlans.length === 1 ? resolvedSelectedPlans[0]! : { - kind: "review_bundle" as const, - label: "Bulk review selection", - reason: "Apply selected review items together.", - plans: resolvedSelectedPlans, - }; + kind: "review_bundle" as const, + label: "Bulk review selection", + reason: "Apply selected review items together.", + plans: resolvedSelectedPlans, + }; const execution = buildDocumentMutationPlanExecution( this._editor, selectedPlan, @@ -1191,7 +1306,10 @@ class AIControllerImpl implements AIController { return false; } - this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); + this._editor.apply(execution.ops, { + origin: "ai", + undoGroup: true, + }); } let nextPlan: GenerationState["plan"] = generation.plan; @@ -1206,23 +1324,37 @@ class AIControllerImpl implements AIController { : []; this._resolveActiveGeneration({ status: - nextPlan || action === "accept" ? generation.status : "cancelled", - planState: nextPlan ? "validated" : action === "accept" ? "none" : "rejected", + nextPlan || action === "accept" + ? generation.status + : "cancelled", + planState: nextPlan + ? "validated" + : action === "accept" + ? "none" + : "rejected", plan: nextPlan, reviewItems: nextReviewItems, structuredPreview: nextPlan ? buildGenerationStructuredPreviewState(this._editor, { - planState: "validated", - plan: nextPlan, - }) + planState: "validated", + plan: nextPlan, + }) : null, }); if (generation.sessionId) { if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: nextPlan ? "review" : action === "accept" ? "accepted" : "rejected", - reviewItemIds: nextReviewItems.map((item) => item.id), - }); + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: nextPlan + ? "review" + : action === "accept" + ? "accepted" + : "rejected", + reviewItemIds: nextReviewItems.map((item) => item.id), + }, + ); } this._updateSession(generation.sessionId, { status: @@ -1278,7 +1410,9 @@ class AIControllerImpl implements AIController { } showEphemeralSuggestion( - suggestion: Parameters[0], + suggestion: Parameters< + AIInlineCompletionController["showSuggestion"] + >[0], ): void { this._inlineCompletion.showSuggestion(suggestion); } @@ -1295,7 +1429,12 @@ class AIControllerImpl implements AIController { return this._suggestions; } - handleDocumentChange(events: readonly { origin: string; affectedBlocks: readonly string[] }[]): void { + handleDocumentChange( + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void { if (events.length > 0) { this._documentVersion += 1; } @@ -1373,7 +1512,12 @@ class AIControllerImpl implements AIController { return decorations; } - handleExternalCommit(events: readonly { origin: string; affectedBlocks: readonly string[] }[]): void { + handleExternalCommit( + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void { const active = this._state.activeGeneration; if (!active || active.status !== "streaming") return; if ( @@ -1383,13 +1527,16 @@ class AIControllerImpl implements AIController { ) { return; } - const touched = events.some((event) => - event.origin !== "ai" && - event.origin !== AI_SESSION_SUGGESTION_ORIGIN && - event.origin !== "system" && - event.origin !== "extension" && - event.affectedBlocks.includes(active.blockId), - ); + const touched = events.some((event) => { + const originType = getOpOriginType(event.origin); + return ( + originType !== "ai" && + originType !== AI_SESSION_SUGGESTION_ORIGIN && + originType !== "system" && + originType !== "extension" && + event.affectedBlocks.includes(active.blockId) + ); + }); if (!touched) return; this.cancelActiveGeneration(); } @@ -1411,7 +1558,13 @@ class AIControllerImpl implements AIController { blockId, offset: resolveBlockInsertionOffset(this._editor, blockId), }; - return this._executeGeneration(prompt, target, commandId, maxSteps, context); + return this._executeGeneration( + prompt, + target, + commandId, + maxSteps, + context, + ); } private async _runDocumentGeneration( @@ -1438,7 +1591,9 @@ class AIControllerImpl implements AIController { null, }); if (!insertionAnchor) { - throw new Error("Cannot run an AI document prompt without an insertion anchor"); + throw new Error( + "Cannot run an AI document prompt without an insertion anchor", + ); } return this._runBlockGeneration( @@ -1494,7 +1649,9 @@ class AIControllerImpl implements AIController { baselineSuggestionIds, operation, } = input; - const sessionTurnId = context?.sessionId ? crypto.randomUUID() : undefined; + const sessionTurnId = context?.sessionId + ? crypto.randomUUID() + : undefined; const mutationMode: NonNullable = "persistent-suggestions"; const contentFormat = resolveLocalOperationContentFormat( @@ -1508,18 +1665,14 @@ class AIControllerImpl implements AIController { contentFormat === "markdown" && operation.target.blockIds.length > 0; const applyStrategy: AIApplyStrategy | undefined = - ( - operation.kind === "rewrite-block" || + (operation.kind === "rewrite-block" || streamsMarkdownSelectionPreview || - ( - operation.kind === "document-transform" && + (operation.kind === "document-transform" && operation.target.kind === "document" && - ( - operation.target.placement === "replace-blocks" || - operation.target.placement === "replace-empty-block" - ) - ) - ) && contentFormat === "markdown" + (operation.target.placement === "replace-blocks" || + operation.target.placement === + "replace-empty-block"))) && + contentFormat === "markdown" ? "markdown-full-replace" : undefined; const seedGeneration: GenerationState = { @@ -1570,10 +1723,13 @@ class AIControllerImpl implements AIController { const existingSession = context?.sessionId != null ? (this._state.sessions.find( - (session) => session.id === context.sessionId, - ) ?? null) + (session) => session.id === context.sessionId, + ) ?? null) : null; - const executionPrompt = buildSessionExecutionPrompt(existingSession, prompt); + const executionPrompt = buildSessionExecutionPrompt( + existingSession, + prompt, + ); if (context?.sessionId) { const nextSelectionSnapshot = @@ -1604,43 +1760,48 @@ class AIControllerImpl implements AIController { ], turns: sessionTurnId ? [ - ...(existingSession?.turns ?? []), - { - id: sessionTurnId, - prompt, - createdAt: Date.now(), - undoGroupId: seedGeneration.undoGroupId, - generationId: seedGeneration.id, - target: target.type, - operation, - status: "streaming", - suggestionIds: [], - reviewItemIds: [], - generatedBlockIds: [], - structuredPreview: null, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : undefined, - selection: - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined, - }, - ] + ...(existingSession?.turns ?? []), + { + id: sessionTurnId, + prompt, + createdAt: Date.now(), + undoGroupId: seedGeneration.undoGroupId, + generationId: seedGeneration.id, + target: target.type, + operation, + status: "streaming", + suggestionIds: [], + reviewItemIds: [], + generatedBlockIds: [], + structuredPreview: null, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : undefined, + selection: + target.type === "selection" + ? resolveSessionSelectionSnapshot( + target.selection, + ) + : undefined, + }, + ] : existingSession?.turns, - contextualPrompt: - existingSession?.contextualPrompt - ? { + contextualPrompt: existingSession?.contextualPrompt + ? { ...existingSession.contextualPrompt, anchor: target.type === "selection" ? { - ...existingSession.contextualPrompt.anchor, - selectionSnapshot: nextSelectionSnapshot, - focusBlockId: target.selection.toRange().start.blockId, - status: "valid", - } + ...existingSession.contextualPrompt + .anchor, + selectionSnapshot: + nextSelectionSnapshot, + focusBlockId: + target.selection.toRange().start + .blockId, + status: "valid", + } : existingSession.contextualPrompt.anchor, composer: { ...existingSession.contextualPrompt.composer, @@ -1650,7 +1811,7 @@ class AIControllerImpl implements AIController { openReason: "user", }, } - : undefined, + : undefined, }); } @@ -1678,13 +1839,12 @@ class AIControllerImpl implements AIController { let sawStructuredFinalFrame = false; let streamedSelectionSuggestionIds: string[] = []; let lastStreamedSelectionPreviewText = ""; - const updatePreview = ( - text: string, - phase: "preview" | "final", - ) => { + const updatePreview = (text: string, phase: "preview" | "final") => { currentText = text; const nextStatus = - phase === "preview" && text.length > 0 ? "writing" : this._state.status; + phase === "preview" && text.length > 0 + ? "writing" + : this._state.status; if (phase === "preview" && text.length > 0) { this._setState({ status: "writing" }); this._appendStreamEvent( @@ -1755,19 +1915,23 @@ class AIControllerImpl implements AIController { operation.target.kind === "scoped-range" ) { updatePreview(currentText, "preview"); - const previewRefresh = this._refreshStreamingMarkdownBlockPreview( - operation.target.blockIds?.[0] ?? operation.target.anchor.blockId, - currentText, - mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedSelectionSuggestionIds, - lastStreamedSelectionPreviewText, - true, - operation.target.blockIds, - ); - streamedSelectionSuggestionIds = previewRefresh.suggestionIds; - lastStreamedSelectionPreviewText = previewRefresh.normalizedText; + const previewRefresh = + this._refreshStreamingMarkdownBlockPreview( + operation.target.blockIds?.[0] ?? + operation.target.anchor.blockId, + currentText, + mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedSelectionSuggestionIds, + lastStreamedSelectionPreviewText, + true, + operation.target.blockIds, + ); + streamedSelectionSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedSelectionPreviewText = + previewRefresh.normalizedText; } continue; } @@ -1785,19 +1949,23 @@ class AIControllerImpl implements AIController { streamsMarkdownSelectionPreview && operation.target.kind === "scoped-range" ) { - const previewRefresh = this._refreshStreamingMarkdownBlockPreview( - operation.target.blockIds?.[0] ?? operation.target.anchor.blockId, - event.text, - mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedSelectionSuggestionIds, - lastStreamedSelectionPreviewText, - true, - operation.target.blockIds, - ); - streamedSelectionSuggestionIds = previewRefresh.suggestionIds; - lastStreamedSelectionPreviewText = previewRefresh.normalizedText; + const previewRefresh = + this._refreshStreamingMarkdownBlockPreview( + operation.target.blockIds?.[0] ?? + operation.target.anchor.blockId, + event.text, + mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedSelectionSuggestionIds, + lastStreamedSelectionPreviewText, + true, + operation.target.blockIds, + ); + streamedSelectionSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedSelectionPreviewText = + previewRefresh.normalizedText; } continue; } @@ -1812,19 +1980,22 @@ class AIControllerImpl implements AIController { streamsMarkdownSelectionPreview && operation.target.kind === "scoped-range" ) { - this._rejectPreviewSuggestions(streamedSelectionSuggestionIds); + this._rejectPreviewSuggestions( + streamedSelectionSuggestionIds, + ); streamedSelectionSuggestionIds = []; lastStreamedSelectionPreviewText = ""; } - currentMutationReceipt = this._commitRequestedOperationResult( - operation, - event.text, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); + currentMutationReceipt = + this._commitRequestedOperationResult( + operation, + event.text, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); continue; } @@ -1887,7 +2058,9 @@ class AIControllerImpl implements AIController { blockClass: "flow", transportKind: "flow-text", }); - const finalStatus = abortController.signal.aborted ? "cancelled" : "complete"; + const finalStatus = abortController.signal.aborted + ? "cancelled" + : "complete"; this._setState({ status: "idle", activeGeneration: { @@ -1910,23 +2083,27 @@ class AIControllerImpl implements AIController { const localReceiptEvidence = mutationReceipt?.evidence; const localGeneratedBlockIds = localReceiptEvidence ? [ - ...new Set([ - ...localReceiptEvidence.affectedBlockIds, - ...localReceiptEvidence.createdBlockIds, - ]), - ] + ...new Set([ + ...localReceiptEvidence.affectedBlockIds, + ...localReceiptEvidence.createdBlockIds, + ]), + ] : operation.kind === "rewrite-selection" && - operation.target.kind === "scoped-range" + operation.target.kind === "scoped-range" ? [...operation.target.blockIds] : []; this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: finalStatus === "cancelled" ? "cancelled" : "complete", + status: + finalStatus === "cancelled" + ? "cancelled" + : "complete", suggestionIds, generatedBlockIds: localGeneratedBlockIds, }); } this._updateSession(context.sessionId, { - status: finalStatus === "cancelled" ? "cancelled" : "complete", + status: + finalStatus === "cancelled" ? "cancelled" : "complete", pendingSuggestionIds: suggestionIds, pendingReviewItemIds: [], }); @@ -1944,17 +2121,23 @@ class AIControllerImpl implements AIController { activeGeneration: { ...seedGeneration, text: currentText, - status: abortController.signal.aborted ? "cancelled" : "error", + status: abortController.signal.aborted + ? "cancelled" + : "error", }, }); if (context?.sessionId) { if (sessionTurnId) { this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: abortController.signal.aborted ? "cancelled" : "error", + status: abortController.signal.aborted + ? "cancelled" + : "error", }); } this._updateSession(context.sessionId, { - status: abortController.signal.aborted ? "cancelled" : "error", + status: abortController.signal.aborted + ? "cancelled" + : "error", }); } throw error; @@ -1978,12 +2161,13 @@ class AIControllerImpl implements AIController { this.cancelActiveGeneration(); const toolRuntime = - getDocumentToolRuntime(this._editor) ?? - EMPTY_TOOL_RUNTIME; + getDocumentToolRuntime(this._editor) ?? EMPTY_TOOL_RUNTIME; const abortController = new AbortController(); this._abortController = abortController; - const baselineSuggestionIds = new Set(this.getSuggestions().map((item) => item.id)); + const baselineSuggestionIds = new Set( + this.getSuggestions().map((item) => item.id), + ); const blockId = target.type === "block" ? target.blockId @@ -2042,8 +2226,9 @@ class AIControllerImpl implements AIController { const contentFormat = route.contentFormat; let currentText = ""; const streamingTarget = - this._editor.internals.getSlot("delta-stream:target") ?? - null; + this._editor.internals.getSlot( + "delta-stream:target", + ) ?? null; let blockStreamingStarted = false; const shouldStreamDirectly = route.shouldStreamDirectly; const selectionRange = @@ -2057,19 +2242,15 @@ class AIControllerImpl implements AIController { route.plannerMode !== "structured" && contentFormat === "text"; const shouldReplaceMarkdownTarget = - (context?.replaceTargetBlock === true) || - ( - route.plannerMode !== "structured" && + context?.replaceTargetBlock === true || + (route.plannerMode !== "structured" && contentFormat === "markdown" && target.type === "block" && - ( - route.targetKind === "table" || - ( - context?.surface === "bottom-chat" && - shouldReplaceEmptyMarkdownTarget(this._editor.getBlock(blockId)) - ) - ) - ); + (route.targetKind === "table" || + (context?.surface === "bottom-chat" && + shouldReplaceEmptyMarkdownTarget( + this._editor.getBlock(blockId), + )))); const canStreamSelectionSuggestions = shouldStreamSuggestedText && target.type === "selection" && @@ -2087,35 +2268,43 @@ class AIControllerImpl implements AIController { let streamedSuggestionLength = 0; let streamedMarkdownSuggestionIds: string[] = []; let lastStreamedMarkdownPreviewText = ""; - const sessionTurnId = context?.sessionId ? crypto.randomUUID() : undefined; + const sessionTurnId = context?.sessionId + ? crypto.randomUUID() + : undefined; const existingSession = context?.sessionId != null ? (this._state.sessions.find( - (session) => session.id === context.sessionId, - ) ?? null) + (session) => session.id === context.sessionId, + ) ?? null) : null; - const executionPrompt = buildSessionExecutionPrompt(existingSession, prompt); + const executionPrompt = buildSessionExecutionPrompt( + existingSession, + prompt, + ); let shouldTrimLeadingBlankBlockText = target.type === "block" && - shouldTrimLeadingBlankBlockGenerationText(this._editor.getBlock(blockId)); + shouldTrimLeadingBlankBlockGenerationText( + this._editor.getBlock(blockId), + ); const useStructuredIntentTransport = - adapter.transportKind !== "flow-text" && supportsStructuredIntent(this._model); + adapter.transportKind !== "flow-text" && + supportsStructuredIntent(this._model); const generationPrompt = useStructuredIntentTransport || - (adapter.id === "flow-markdown" && contentFormat === "markdown") + (adapter.id === "flow-markdown" && contentFormat === "markdown") ? adapter.buildPrompt({ - prompt: executionPrompt, - targetKind: route.targetKind, - activeBlockId: blockId, - workingSet, - applyStrategy: route.applyStrategy, - }) - : route.plannerMode === "structured" - ? buildPlannerPrompt({ prompt: executionPrompt, targetKind: route.targetKind, + activeBlockId: blockId, workingSet, + applyStrategy: route.applyStrategy, }) + : route.plannerMode === "structured" + ? buildPlannerPrompt({ + prompt: executionPrompt, + targetKind: route.targetKind, + workingSet, + }) : executionPrompt; const seedGeneration: GenerationState = { @@ -2198,43 +2387,48 @@ class AIControllerImpl implements AIController { ], turns: sessionTurnId ? [ - ...(existingSession?.turns ?? []), - { - id: sessionTurnId, - prompt, - createdAt: Date.now(), - undoGroupId: seedGeneration.undoGroupId, - generationId: seedGeneration.id, - target: target.type, - operation: requestedOperation ?? undefined, - status: "streaming", - suggestionIds: [], - reviewItemIds: [], - generatedBlockIds: [], - structuredPreview: null, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : undefined, - selection: - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined, - }, - ] + ...(existingSession?.turns ?? []), + { + id: sessionTurnId, + prompt, + createdAt: Date.now(), + undoGroupId: seedGeneration.undoGroupId, + generationId: seedGeneration.id, + target: target.type, + operation: requestedOperation ?? undefined, + status: "streaming", + suggestionIds: [], + reviewItemIds: [], + generatedBlockIds: [], + structuredPreview: null, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : undefined, + selection: + target.type === "selection" + ? resolveSessionSelectionSnapshot( + target.selection, + ) + : undefined, + }, + ] : existingSession?.turns, - contextualPrompt: - existingSession?.contextualPrompt - ? { + contextualPrompt: existingSession?.contextualPrompt + ? { ...existingSession.contextualPrompt, anchor: target.type === "selection" ? { - ...existingSession.contextualPrompt.anchor, - selectionSnapshot: nextSelectionSnapshot, - focusBlockId: target.selection.toRange().start.blockId, - status: "valid", - } + ...existingSession.contextualPrompt + .anchor, + selectionSnapshot: + nextSelectionSnapshot, + focusBlockId: + target.selection.toRange().start + .blockId, + status: "valid", + } : existingSession.contextualPrompt.anchor, composer: { ...existingSession.contextualPrompt.composer, @@ -2244,7 +2438,7 @@ class AIControllerImpl implements AIController { openReason: "user", }, } - : undefined, + : undefined, }); } this._setState({ @@ -2254,7 +2448,8 @@ class AIControllerImpl implements AIController { lastRoute: route.lane, activeSessionId: context?.sessionId ?? this._state.activeSessionId, }); - let currentStructuredPreview: GenerationStructuredPreviewState | null = null; + let currentStructuredPreview: GenerationStructuredPreviewState | null = + null; let currentStructuredIntent: GenerationState["structuredIntent"] = null; let currentMutationReceipt: AIMutationReceipt | null = null; this._setStreamEvents([ @@ -2273,12 +2468,16 @@ class AIControllerImpl implements AIController { const result = await runAgenticLoop({ model: this._model, editor: this._editor, - toolRuntime: route.allowToolUse ? toolRuntime : EMPTY_TOOL_RUNTIME, + toolRuntime: route.allowToolUse + ? toolRuntime + : EMPTY_TOOL_RUNTIME, prompt: generationPrompt, blockId, generationId: seedGeneration.id, zoneId: seedGeneration.zoneId, - maxSteps: route.allowToolUse ? (maxSteps ?? this._maxAgenticSteps) : 1, + maxSteps: route.allowToolUse + ? (maxSteps ?? this._maxAgenticSteps) + : 1, signal: abortController.signal, requestMode: resolveGenerationRequestMode({ ...context, @@ -2288,7 +2487,13 @@ class AIControllerImpl implements AIController { validateWorkingSet: (activeWorkingSet) => this._validateWorkingSet(route, target, activeWorkingSet), refreshWorkingSet: async () => - this._buildWorkingSet(toolRuntime, route, target, blockId, prompt), + this._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ), onStatusChange: (status) => { this._setState({ status }); this._appendStreamEvent( @@ -2310,10 +2515,14 @@ class AIControllerImpl implements AIController { }, onTextDelta: (delta) => { const nextDelta = - target.type === "block" && shouldTrimLeadingBlankBlockText + target.type === "block" && + shouldTrimLeadingBlankBlockText ? trimLeadingBlankBlockGenerationText(delta) : delta; - if (shouldTrimLeadingBlankBlockText && nextDelta.length > 0) { + if ( + shouldTrimLeadingBlankBlockText && + nextDelta.length > 0 + ) { shouldTrimLeadingBlankBlockText = false; } if (nextDelta.length === 0) { @@ -2322,16 +2531,23 @@ class AIControllerImpl implements AIController { currentText += nextDelta; if (target.type === "block" && shouldStreamDirectly) { streamingTarget?.appendDelta(nextDelta); - } else if (canStreamSelectionSuggestions && selectionRange) { + } else if ( + canStreamSelectionSuggestions && + selectionRange + ) { if (!streamedSuggestionInitialized) { this._applySuggestedAIOps( - [{ - type: "replace-text", - blockId: selectionRange.start.blockId, - offset: selectionRange.start.offset, - length: selectionRange.end.offset - selectionRange.start.offset, - text: nextDelta, - }], + [ + { + type: "replace-text", + blockId: selectionRange.start.blockId, + offset: selectionRange.start.offset, + length: + selectionRange.end.offset - + selectionRange.start.offset, + text: nextDelta, + }, + ], context?.sessionId, { undoGroupId: seedGeneration.undoGroupId }, ); @@ -2339,45 +2555,62 @@ class AIControllerImpl implements AIController { streamedSuggestionLength = nextDelta.length; } else if (nextDelta.length > 0) { this._applySuggestedAIOps( - [{ - type: "insert-text", - blockId: selectionRange.start.blockId, - offset: selectionRange.end.offset + streamedSuggestionLength, - text: nextDelta, - }], + [ + { + type: "insert-text", + blockId: selectionRange.start.blockId, + offset: + selectionRange.end.offset + + streamedSuggestionLength, + text: nextDelta, + }, + ], context?.sessionId, { undoGroupId: seedGeneration.undoGroupId }, ); streamedSuggestionLength += nextDelta.length; } - } else if (canStreamBlockSuggestions && target.type === "block") { + } else if ( + canStreamBlockSuggestions && + target.type === "block" + ) { if (nextDelta.length > 0) { this._applySuggestedAIOps( - [{ - type: "insert-text", - blockId: target.blockId, - offset: target.offset + streamedSuggestionLength, - text: nextDelta, - }], + [ + { + type: "insert-text", + blockId: target.blockId, + offset: + target.offset + + streamedSuggestionLength, + text: nextDelta, + }, + ], context?.sessionId, { undoGroupId: seedGeneration.undoGroupId }, ); streamedSuggestionLength += nextDelta.length; } - } else if (canStreamMarkdownBlockSuggestions && target.type === "block") { - const previewRefresh = this._refreshStreamingMarkdownBlockPreview( - target.blockId, - currentText, - route.mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedMarkdownSuggestionIds, - lastStreamedMarkdownPreviewText, - shouldReplaceMarkdownTarget, - context?.replaceBlockIds, - ); - streamedMarkdownSuggestionIds = previewRefresh.suggestionIds; - lastStreamedMarkdownPreviewText = previewRefresh.normalizedText; + } else if ( + canStreamMarkdownBlockSuggestions && + target.type === "block" + ) { + const previewRefresh = + this._refreshStreamingMarkdownBlockPreview( + target.blockId, + currentText, + route.mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedMarkdownSuggestionIds, + lastStreamedMarkdownPreviewText, + shouldReplaceMarkdownTarget, + context?.replaceBlockIds, + ); + streamedMarkdownSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedMarkdownPreviewText = + previewRefresh.normalizedText; } else if (target.type === "selection") { this._inlineCompletion.showSuggestion({ id: seedGeneration.id, @@ -2403,38 +2636,56 @@ class AIControllerImpl implements AIController { text: currentText, }), ); - if (route.plannerMode === "structured" && !useStructuredIntentTransport) { + if ( + route.plannerMode === "structured" && + !useStructuredIntentTransport + ) { const previewResult = parseStructuredPlanPreview( currentText, route.targetKind, ); if (previewResult?.plan) { const nextStructuredPreview = - buildGenerationStructuredPreviewState(this._editor, { - planState: previewResult.planState === "validated" - ? "validated" - : "drafted", - plan: previewResult.plan, - }); + buildGenerationStructuredPreviewState( + this._editor, + { + planState: + previewResult.planState === + "validated" + ? "validated" + : "drafted", + plan: previewResult.plan, + }, + ); if ( !areStructuredValuesEqual( currentStructuredPreview, nextStructuredPreview, ) ) { - const patches = buildStructuredPreviewPatchOperations( - currentStructuredPreview, - nextStructuredPreview, - ); - currentStructuredPreview = nextStructuredPreview; + const patches = + buildStructuredPreviewPatchOperations( + currentStructuredPreview, + nextStructuredPreview, + ); + currentStructuredPreview = + nextStructuredPreview; this._resolveActiveGeneration({ structuredPreview: nextStructuredPreview, }); if (context?.sessionId && sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - reviewItemIds: nextStructuredPreview.reviewItems.map((item) => item.id), - structuredPreview: nextStructuredPreview, - }); + this._updateSessionTurn( + context.sessionId, + sessionTurnId, + { + reviewItemIds: + nextStructuredPreview.reviewItems.map( + (item) => item.id, + ), + structuredPreview: + nextStructuredPreview, + }, + ); } this._appendStreamEvent( createAIStreamEvent(seedGeneration, { @@ -2451,32 +2702,34 @@ class AIControllerImpl implements AIController { if (!useStructuredIntentTransport) { return; } - const previewResult = adapter.parsePreview?.({ - value: event.data, - targetKind: route.targetKind, - activeBlockId: blockId, - }) ?? null; + const previewResult = + adapter.parsePreview?.({ + value: event.data, + targetKind: route.targetKind, + activeBlockId: blockId, + }) ?? null; if (!previewResult?.intent) { return; } currentStructuredIntent = previewResult.intent; - const compilation = compileStructuredIntentToPlan(previewResult.intent, { - activeBlockId: blockId, - }); + const compilation = compileStructuredIntentToPlan( + previewResult.intent, + { + activeBlockId: blockId, + }, + ); if (!compilation.plan) { return; } - const nextStructuredPreview = buildGenerationStructuredPreviewState( - this._editor, - { + const nextStructuredPreview = + buildGenerationStructuredPreviewState(this._editor, { planState: previewResult.intentState === "validated" && - compilation.issues.length === 0 + compilation.issues.length === 0 ? "validated" : "drafted", plan: compilation.plan, - }, - ); + }); if ( areStructuredValuesEqual( currentStructuredPreview, @@ -2495,10 +2748,17 @@ class AIControllerImpl implements AIController { structuredPreview: nextStructuredPreview, }); if (context?.sessionId && sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - reviewItemIds: nextStructuredPreview.reviewItems.map((item) => item.id), - structuredPreview: nextStructuredPreview, - }); + this._updateSessionTurn( + context.sessionId, + sessionTurnId, + { + reviewItemIds: + nextStructuredPreview.reviewItems.map( + (item) => item.id, + ), + structuredPreview: nextStructuredPreview, + }, + ); } this._appendStreamEvent( createAIStreamEvent(seedGeneration, { @@ -2558,12 +2818,22 @@ class AIControllerImpl implements AIController { }); }, onStreamingStart: (zoneId, targetBlockId) => { - if (target.type !== "block" || !shouldStreamDirectly || blockStreamingStarted) return; + if ( + target.type !== "block" || + !shouldStreamDirectly || + blockStreamingStarted + ) + return; streamingTarget?.beginStreaming(zoneId, targetBlockId); blockStreamingStarted = true; }, onStreamingEnd: (status) => { - if (target.type !== "block" || !shouldStreamDirectly || !blockStreamingStarted) return; + if ( + target.type !== "block" || + !shouldStreamDirectly || + !blockStreamingStarted + ) + return; streamingTarget?.endStreaming(status); blockStreamingStarted = false; }, @@ -2622,33 +2892,39 @@ class AIControllerImpl implements AIController { .map((item) => item.id) .filter((id) => !baselineSuggestionIds.has(id)); const structuredPlanResult = - route.plannerMode === "structured" && !useStructuredIntentTransport + route.plannerMode === "structured" && + !useStructuredIntentTransport ? parseStructuredPlanResult(currentText, route.targetKind) : null; - const structuredIntentResolution = - useStructuredIntentTransport - ? adapter.resolveResult?.({ + const structuredIntentResolution = useStructuredIntentTransport + ? (adapter.resolveResult?.({ value: currentStructuredIntent, targetKind: route.targetKind, activeBlockId: blockId, - }) ?? null - : null; - const structuredIntentResult = structuredIntentResolution?.parseResult ?? null; + }) ?? null) + : null; + const structuredIntentResult = + structuredIntentResolution?.parseResult ?? null; const structuredIntentCompilation = structuredIntentResolution?.compilation ?? null; const resolvedStructuredPlan = - structuredIntentCompilation?.plan ?? structuredPlanResult?.plan ?? null; + structuredIntentCompilation?.plan ?? + structuredPlanResult?.plan ?? + null; const planExecution = resolvedStructuredPlan ? buildDocumentMutationPlanExecution( - this._editor, - resolvedStructuredPlan, - ) + this._editor, + resolvedStructuredPlan, + ) : null; const reviewItems = resolvedStructuredPlan && - route.mutationMode !== "direct-stream" && - (!planExecution || !planExecution.reviewSafe) - ? buildStructuralReviewItems(this._editor, resolvedStructuredPlan) + route.mutationMode !== "direct-stream" && + (!planExecution || !planExecution.reviewSafe) + ? buildStructuralReviewItems( + this._editor, + resolvedStructuredPlan, + ) : []; if ( @@ -2688,17 +2964,21 @@ class AIControllerImpl implements AIController { }; const resolvedDebug = this._state.activeGeneration?.id === seedGeneration.id - ? (this._state.activeGeneration.debug ?? result.debug ?? seedGeneration.debug!) + ? (this._state.activeGeneration.debug ?? + result.debug ?? + seedGeneration.debug!) : (result.debug ?? seedGeneration.debug!); const resolvedPlanState: GenerationState["planState"] = planExecution && planExecution.issues.length > 0 ? "rejected" : structuredIntentResult?.intentState === "validated" && - (structuredIntentCompilation?.issues.length ?? 0) === 0 + (structuredIntentCompilation?.issues.length ?? 0) === + 0 ? "validated" : structuredIntentResult?.intentState === "drafted" ? "drafted" - : structuredPlanResult?.planState ?? seedGeneration.planState; + : (structuredPlanResult?.planState ?? + seedGeneration.planState); const finalGeneration: GenerationState = { ...result, @@ -2716,18 +2996,20 @@ class AIControllerImpl implements AIController { planState: resolvedPlanState, plan: resolvedStructuredPlan, structuredIntent: - structuredIntentResult?.intent ?? currentStructuredIntent ?? null, + structuredIntentResult?.intent ?? + currentStructuredIntent ?? + null, reviewItems, - structuredPreview: - resolvedStructuredPlan - ? buildGenerationStructuredPreviewState(this._editor, { + structuredPreview: resolvedStructuredPlan + ? buildGenerationStructuredPreviewState(this._editor, { planState: - planExecution && planExecution.issues.length === 0 + planExecution && + planExecution.issues.length === 0 ? "validated" : "drafted", plan: resolvedStructuredPlan, }) - : currentStructuredPreview, + : currentStructuredPreview, targetKind: route.targetKind, blockClass: route.blockClass, adapterId: route.adapterId, @@ -2759,22 +3041,23 @@ class AIControllerImpl implements AIController { const lastStructuredPreviewEvent = structuredPreviewEvents[structuredPreviewEvents.length - 1]; const refreshedInlineReviewSelectionTarget = - context?.surface === "inline-edit" && suggestionIds.length > 0 - ? resolvePendingInlineSelectionTarget( + context?.surface === "inline-edit" && + suggestionIds.length > 0 + ? (resolvePendingInlineSelectionTarget( this._editor, requestedOperation ?? undefined, suggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor) + ) ?? resolveLiveInlineSelectionTarget(this._editor)) : null; if (sessionTurnId) { const receiptEvidence = currentMutationReceipt?.evidence; const generatedBlockIds = receiptEvidence ? [ - ...new Set([ - ...receiptEvidence.affectedBlockIds, - ...receiptEvidence.createdBlockIds, - ]), - ] + ...new Set([ + ...receiptEvidence.affectedBlockIds, + ...receiptEvidence.createdBlockIds, + ]), + ] : []; this._updateSessionTurn(context.sessionId, sessionTurnId, { status: @@ -2786,7 +3069,8 @@ class AIControllerImpl implements AIController { suggestionIds, reviewItemIds: reviewItems.map((item) => item.id), generatedBlockIds, - structuredPreview: finalGeneration.structuredPreview ?? null, + structuredPreview: + finalGeneration.structuredPreview ?? null, anchor: refreshedInlineReviewSelectionTarget ? resolveSessionAnchor( refreshedInlineReviewSelectionTarget.selection, @@ -2808,27 +3092,39 @@ class AIControllerImpl implements AIController { resolvedGenerationDebug?.fastApply, ); this._updateSession(context.sessionId, { - status: finalGeneration.status === "complete" ? "complete" : finalGeneration.status, + status: + finalGeneration.status === "complete" + ? "complete" + : finalGeneration.status, pendingSuggestionIds: suggestionIds, pendingReviewItemIds: reviewItems.map((item) => item.id), metrics: { - ...(this._state.sessions.find((session) => session.id === context.sessionId) - ?.metrics ?? { + ...(this._state.sessions.find( + (session) => session.id === context.sessionId, + )?.metrics ?? { streamEventCount: 0, patchCount: 0, fastApply: createDefaultSessionFastApplyMetrics(), }), - firstTokenMs: resolvedGenerationDebug?.firstVisibleTextMs ?? undefined, - totalMs: resolvedGenerationDebug?.messageAssemblyLatencyMs != null - ? resolvedGenerationDebug.messageAssemblyLatencyMs + - (resolvedGenerationDebug.toolExecutionMs ?? 0) - : undefined, - toolMs: resolvedGenerationDebug?.toolExecutionMs ?? undefined, + firstTokenMs: + resolvedGenerationDebug?.firstVisibleTextMs ?? + undefined, + totalMs: + resolvedGenerationDebug?.messageAssemblyLatencyMs != + null + ? resolvedGenerationDebug.messageAssemblyLatencyMs + + (resolvedGenerationDebug.toolExecutionMs ?? + 0) + : undefined, + toolMs: + resolvedGenerationDebug?.toolExecutionMs ?? + undefined, streamEventCount: this._streamEvents.filter( (event) => event.sessionId === context.sessionId, ).length, patchCount: - lastStructuredPreviewEvent?.type === "structured-preview" + lastStructuredPreviewEvent?.type === + "structured-preview" ? lastStructuredPreviewEvent.patches.length : 0, }, @@ -2938,13 +3234,15 @@ class AIControllerImpl implements AIController { adapterId: "flow-markdown", blockClass: "flow", transportKind: "flow-text", - issues: ["The requested selection rewrite target is no longer available."], + issues: [ + "The requested selection rewrite target is no longer available.", + ], }); } const markdownBlockIds = options.contentFormat === "markdown" && - operation.target.kind === "scoped-range" && - operation.target.blockIds.length > 0 + operation.target.kind === "scoped-range" && + operation.target.blockIds.length > 0 ? operation.target.blockIds : null; if (markdownBlockIds) { @@ -2970,7 +3268,8 @@ class AIControllerImpl implements AIController { } if (operation.kind === "rewrite-block") { - const target = operation.target.kind === "block" ? operation.target : null; + const target = + operation.target.kind === "block" ? operation.target : null; if (!target) { return buildMutationReceipt({ status: "invalid", @@ -3006,14 +3305,17 @@ class AIControllerImpl implements AIController { } if (operation.kind === "document-transform") { - const target = operation.target.kind === "document" ? operation.target : null; + const target = + operation.target.kind === "document" ? operation.target : null; if (!target) { return buildMutationReceipt({ status: "invalid", adapterId: "flow-markdown", blockClass: "flow", transportKind: "flow-text", - issues: ["The requested document transform target is invalid."], + issues: [ + "The requested document transform target is invalid.", + ], }); } const replaceBlockIds = target.blockIds?.filter( @@ -3024,8 +3326,9 @@ class AIControllerImpl implements AIController { replaceBlockIds && replaceBlockIds.length > 0 ? replaceBlockIds : this._editor.documentState.blockOrder.filter( - (blockId) => this._editor.getBlock(blockId) != null, - ); + (blockId) => + this._editor.getBlock(blockId) != null, + ); const ops = deleteBlockIds.map((blockId) => ({ type: "delete-block" as const, blockId, @@ -3059,7 +3362,9 @@ class AIControllerImpl implements AIController { adapterId: "flow-markdown", blockClass: "flow", transportKind: "flow-text", - issues: ["The requested document transform target is no longer available."], + issues: [ + "The requested document transform target is no longer available.", + ], }); } return this._commitBufferedBlockGeneration( @@ -3079,7 +3384,8 @@ class AIControllerImpl implements AIController { ); } - const target = operation.target.kind === "block" ? operation.target : null; + const target = + operation.target.kind === "block" ? operation.target : null; if (!target) { return buildMutationReceipt({ status: "invalid", @@ -3153,12 +3459,14 @@ class AIControllerImpl implements AIController { const caret = nextSelection.anchor; if (text.length > 0) { this._editor.apply( - [{ - type: "insert-text", - blockId: caret.blockId, - offset: caret.offset, - text, - }], + [ + { + type: "insert-text", + blockId: caret.blockId, + offset: caret.offset, + text, + }, + ], { origin: "ai" }, ); } @@ -3202,9 +3510,7 @@ class AIControllerImpl implements AIController { replaceBlockIds?: readonly string[]; }, ): AIMutationReceipt { - let fastApplyFallbackMode: - | "plain-markdown" - | null = null; + let fastApplyFallbackMode: "plain-markdown" | null = null; if ( contentFormat === "markdown" && options?.applyStrategy === "markdown-fast-apply" && @@ -3230,7 +3536,9 @@ class AIControllerImpl implements AIController { adapterId: "flow-markdown", blockClass: "flow", transportKind: "flow-text", - issues: ["Fast apply contract could not be compiled safely."], + issues: [ + "Fast apply contract could not be compiled safely.", + ], }); } } @@ -3241,11 +3549,11 @@ class AIControllerImpl implements AIController { : text; const scopedReplaceBlockIds = contentFormat === "markdown" - ? options?.replaceBlockIds?.filter( - (candidateBlockId, index, allBlockIds) => - allBlockIds.indexOf(candidateBlockId) === index && - this._editor.getBlock(candidateBlockId) != null, - ) ?? [] + ? (options?.replaceBlockIds?.filter( + (candidateBlockId, index, allBlockIds) => + allBlockIds.indexOf(candidateBlockId) === index && + this._editor.getBlock(candidateBlockId) != null, + ) ?? []) : []; if (contentFormat === "markdown" && scopedReplaceBlockIds.length > 0) { if (normalizedText.trim().length > 0) { @@ -3259,7 +3567,9 @@ class AIControllerImpl implements AIController { adapterId: "flow-markdown", blockClass: "flow", transportKind: "flow-text", - issues: ["Scoped markdown replacement could not be verified safely."], + issues: [ + "Scoped markdown replacement could not be verified safely.", + ], }); } } @@ -3267,11 +3577,12 @@ class AIControllerImpl implements AIController { scopedReplaceBlockIds, normalizedText, ); - const scopedReplacementFallback = this._summarizeFastApplyFallbackOps( - "scoped-replacement", - ops, - scopedReplaceBlockIds.length, - ); + const scopedReplacementFallback = + this._summarizeFastApplyFallbackOps( + "scoped-replacement", + ops, + scopedReplaceBlockIds.length, + ); if ( mutationMode === "persistent-suggestions" || mutationMode === "streaming-suggestions" || @@ -3336,16 +3647,16 @@ class AIControllerImpl implements AIController { const ops = contentFormat === "markdown" ? this._buildMarkdownBlockGenerationOps( - blockId, - normalizedText, - options?.replaceTargetBlock, - options?.replaceBlockIds, - ) + blockId, + normalizedText, + options?.replaceTargetBlock, + options?.replaceBlockIds, + ) : this._buildTextBlockGenerationOps( - blockId, - normalizedText, - options?.insertionOffset, - ); + blockId, + normalizedText, + options?.insertionOffset, + ); if (ops.length === 0) { if (fastApplyFallbackMode) { this._recordFastApplyDebug({ @@ -3413,7 +3724,10 @@ class AIControllerImpl implements AIController { sessionId: string | undefined, workingSet: AIWorkingSetEnvelope | null, ): AIMutationReceipt | null { - const fastApplyScope = this._resolveMarkdownFastApplyScope(blockId, workingSet); + const fastApplyScope = this._resolveMarkdownFastApplyScope( + blockId, + workingSet, + ); if (!fastApplyScope) { this._recordFastApplyDebug({ attempted: true, @@ -3427,7 +3741,10 @@ class AIControllerImpl implements AIController { if (patchPlan) { const validation = validateDocumentMutationPlanShape( patchPlan, - this._buildPlanValidationContext(blockId, fastApplyScope.blockIds), + this._buildPlanValidationContext( + blockId, + fastApplyScope.blockIds, + ), ); if (!validation.valid) { this._recordFastApplyDebug({ @@ -3440,7 +3757,10 @@ class AIControllerImpl implements AIController { return null; } - const execution = buildDocumentMutationPlanExecution(this._editor, patchPlan); + const execution = buildDocumentMutationPlanExecution( + this._editor, + patchPlan, + ); if (execution.issues.length > 0) { this._recordFastApplyDebug({ attempted: true, @@ -3467,7 +3787,8 @@ class AIControllerImpl implements AIController { diffChars: text.length, fallbackReason: "verification-failed", verificationFailureReason: verification.reason, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, alignment: execution.metrics?.flowPatchAlignment, executionPath: "native-fast-apply", }); @@ -3481,7 +3802,8 @@ class AIControllerImpl implements AIController { contextChars: fastApplyScope.markdown.length, diffChars: text.length, confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, alignment: execution.metrics?.flowPatchAlignment, executionPath: "native-fast-apply", }); @@ -3506,7 +3828,8 @@ class AIControllerImpl implements AIController { contextChars: fastApplyScope.markdown.length, diffChars: text.length, confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, alignment: execution.metrics?.flowPatchAlignment, executionPath: "native-fast-apply", }); @@ -3519,14 +3842,18 @@ class AIControllerImpl implements AIController { }); } - this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); + this._editor.apply(execution.ops, { + origin: "ai", + undoGroup: true, + }); this._recordFastApplyDebug({ attempted: true, succeeded: true, contextChars: fastApplyScope.markdown.length, diffChars: text.length, confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, alignment: execution.metrics?.flowPatchAlignment, executionPath: "native-fast-apply", }); @@ -3665,12 +3992,12 @@ class AIControllerImpl implements AIController { const context = workingSet?.context && typeof workingSet.context === "object" ? (workingSet.context as { - markdown?: string | null; - retrievedSpan?: AIWorkingSetRetrievedSpan | null; - markdownWindow?: { - blockIds?: string[]; - } | null; - }) + markdown?: string | null; + retrievedSpan?: AIWorkingSetRetrievedSpan | null; + markdownWindow?: { + blockIds?: string[]; + } | null; + }) : null; const markdown = context?.markdown?.trim() ?? ""; const blockIds = context?.retrievedSpan?.blockIds?.length @@ -3694,7 +4021,10 @@ class AIControllerImpl implements AIController { const knownBlockTypes = this._editor.schema .allBlocks() .filter((schema) => - shouldExposeBlockInTooling(this._editor.documentProfile, schema), + shouldExposeBlockInTooling( + this._editor.documentProfile, + schema, + ), ) .map((schema) => schema.type); const editableTargetBlockIds = scopeBlockIds.filter((targetBlockId) => { @@ -3703,7 +4033,10 @@ class AIControllerImpl implements AIController { return false; } const schema = this._editor.schema.resolve(block.type); - return shouldExposeBlockInTooling(this._editor.documentProfile, schema); + return shouldExposeBlockInTooling( + this._editor.documentProfile, + schema, + ); }); return { @@ -3741,13 +4074,20 @@ class AIControllerImpl implements AIController { surface: "ai-markdown-fast-apply-verify", }); if (verificationResult.blocks.length === 0) { - return { valid: false, reason: "markdown-parse-produced-no-blocks" }; + return { + valid: false, + reason: "markdown-parse-produced-no-blocks", + }; } return { valid: true }; } private _verifyFlowPatchPlanResult( - plan: { edits: Array<{ locator: { blockId?: string; blockIds?: string[] } }> }, + plan: { + edits: Array<{ + locator: { blockId?: string; blockIds?: string[] }; + }>; + }, ops: readonly DocumentOp[], scopeBlockIds: readonly string[], ): { @@ -3773,7 +4113,10 @@ class AIControllerImpl implements AIController { for (const blockId of this._readBlockIdsFromOp(op)) { if (scopeSet.has(blockId)) { mutatedExistingBlockIds.add(blockId); - } else if (!createdBlockIds.has(blockId) && op.type !== "insert-block") { + } else if ( + !createdBlockIds.has(blockId) && + op.type !== "insert-block" + ) { outOfScopeMutations.add(blockId); } } @@ -3816,10 +4159,13 @@ class AIControllerImpl implements AIController { }); return [ ...ops, - ...blockIds.map((currentBlockId) => ({ - type: "delete-block", - blockId: currentBlockId, - }) satisfies DocumentOp), + ...blockIds.map( + (currentBlockId) => + ({ + type: "delete-block", + blockId: currentBlockId, + }) satisfies DocumentOp, + ), ]; } @@ -3867,7 +4213,9 @@ class AIControllerImpl implements AIController { } private _recordFastApplyDebug( - overrides: Partial["fastApply"]>>, + overrides: Partial< + NonNullable["fastApply"]> + >, ): void { const activeGeneration = this._state.activeGeneration; if (!activeGeneration?.debug) { @@ -3973,7 +4321,9 @@ class AIControllerImpl implements AIController { return { suggestionIds: this.getSuggestions() .map((item) => item.id) - .filter((suggestionId) => !baselineSuggestionIds.has(suggestionId)), + .filter( + (suggestionId) => !baselineSuggestionIds.has(suggestionId), + ), normalizedText, }; } @@ -4075,9 +4425,13 @@ class AIControllerImpl implements AIController { blockId: string, prompt: string, ): Promise { - const selectionSignature = this._createSelectionSignature(this._editor.selection); + const selectionSignature = this._createSelectionSignature( + this._editor.selection, + ); if (target.type === "selection") { - const trackedBlockIds = [...new Set(target.selection.toRange().blockRange)]; + const trackedBlockIds = [ + ...new Set(target.selection.toRange().blockRange), + ]; return { documentVersion: this._documentVersion, viewMode: this._state.suggestMode ? "raw" : "resolved", @@ -4085,7 +4439,10 @@ class AIControllerImpl implements AIController { routeConfidence: route.confidence, context: { selection: target.selection, - selectedText: resolveSelectionText(this._editor, target.selection), + selectedText: resolveSelectionText( + this._editor, + target.selection, + ), }, trackedBlockIds, blockRevisions: this._captureBlockRevisions(trackedBlockIds), @@ -4094,14 +4451,18 @@ class AIControllerImpl implements AIController { } if (route.useCursorContext) { - const retrievedSpan = await this._resolveMarkdownFastApplyRetrievedSpan( - toolRuntime, - route, - blockId, - prompt, - ); - if (route.applyStrategy === "markdown-fast-apply" && retrievedSpan) { - const context = await toolRuntime.executeTool( + const retrievedSpan = + await this._resolveMarkdownFastApplyRetrievedSpan( + toolRuntime, + route, + blockId, + prompt, + ); + if ( + route.applyStrategy === "markdown-fast-apply" && + retrievedSpan + ) { + const context = (await toolRuntime.executeTool( "get_context", { format: "markdown", @@ -4110,7 +4471,7 @@ class AIControllerImpl implements AIController { range: retrievedSpan.range, }, {} as never, - ) as { + )) as { activeBlockType?: string | null; markdown?: string | null; surroundingBlocks?: Array<{ id: string }>; @@ -4133,18 +4494,21 @@ class AIControllerImpl implements AIController { surroundingBlockCount: retrievedSpan.blockIds.length, selectedTextLength: context.selectedText?.length ?? 0, activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, }).confidence, trackedBlockIds: [...new Set(retrievedSpan.blockIds)], - blockRevisions: this._captureBlockRevisions(retrievedSpan.blockIds), + blockRevisions: this._captureBlockRevisions( + retrievedSpan.blockIds, + ), selectionSignature, }; } - const context = await toolRuntime.executeTool( + const context = (await toolRuntime.executeTool( "get_cursor_context", { includeSuggestions: this._state.suggestMode }, {} as never, - ) as { + )) as { activeBlockType?: string | null; markdown?: string | null; surroundingBlocks?: Array<{ id: string }>; @@ -4165,10 +4529,12 @@ class AIControllerImpl implements AIController { source: "cursor-context", context, routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: context.surroundingBlocks?.length ?? 0, + surroundingBlockCount: + context.surroundingBlocks?.length ?? 0, selectedTextLength: context.selectedText?.length ?? 0, activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, }).confidence, trackedBlockIds: [...new Set(trackedBlockIds)], blockRevisions: this._captureBlockRevisions(trackedBlockIds), @@ -4177,14 +4543,18 @@ class AIControllerImpl implements AIController { } if (route.useDocumentSummary) { - const retrievedSpan = await this._resolveMarkdownFastApplyRetrievedSpan( - toolRuntime, - route, - blockId, - prompt, - ); - if (route.applyStrategy === "markdown-fast-apply" && retrievedSpan) { - const context = await toolRuntime.executeTool( + const retrievedSpan = + await this._resolveMarkdownFastApplyRetrievedSpan( + toolRuntime, + route, + blockId, + prompt, + ); + if ( + route.applyStrategy === "markdown-fast-apply" && + retrievedSpan + ) { + const context = (await toolRuntime.executeTool( "get_context", { format: "markdown", @@ -4193,7 +4563,7 @@ class AIControllerImpl implements AIController { range: retrievedSpan.range, }, {} as never, - ) as { + )) as { activeBlockType?: string | null; markdown?: string | null; surroundingBlocks?: Array<{ id: string }>; @@ -4216,14 +4586,17 @@ class AIControllerImpl implements AIController { surroundingBlockCount: retrievedSpan.blockIds.length, selectedTextLength: context.selectedText?.length ?? 0, activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, }).confidence, trackedBlockIds: [...new Set(retrievedSpan.blockIds)], - blockRevisions: this._captureBlockRevisions(retrievedSpan.blockIds), + blockRevisions: this._captureBlockRevisions( + retrievedSpan.blockIds, + ), selectionSignature, }; } - const context = await toolRuntime.executeTool( + const context = (await toolRuntime.executeTool( "get_context", { format: "markdown", @@ -4235,7 +4608,7 @@ class AIControllerImpl implements AIController { }, }, {} as never, - ) as { + )) as { activeBlockType?: string | null; markdown?: string | null; surroundingBlocks?: Array<{ id: string }>; @@ -4256,10 +4629,12 @@ class AIControllerImpl implements AIController { source: "document-summary", context, routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: context.surroundingBlocks?.length ?? 0, + surroundingBlockCount: + context.surroundingBlocks?.length ?? 0, selectedTextLength: context.selectedText?.length ?? 0, activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, }).confidence, trackedBlockIds: [...new Set(trackedBlockIds)], blockRevisions: this._captureBlockRevisions(trackedBlockIds), @@ -4301,7 +4676,8 @@ class AIControllerImpl implements AIController { surroundingBlockCount: context.surroundingBlocks?.length ?? 0, selectedTextLength: context.selectedText?.length ?? 0, activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, }); } @@ -4314,8 +4690,11 @@ class AIControllerImpl implements AIController { return { valid: true, canRefresh: false }; } - const selectionSignature = this._createSelectionSignature(this._editor.selection); - const selectionChanged = workingSet.selectionSignature !== selectionSignature; + const selectionSignature = this._createSelectionSignature( + this._editor.selection, + ); + const selectionChanged = + workingSet.selectionSignature !== selectionSignature; const revisionChanged = workingSet.documentVersion !== this._documentVersion || workingSet.trackedBlockIds.some( @@ -4328,7 +4707,10 @@ class AIControllerImpl implements AIController { return { valid: true, canRefresh: false }; } - if (route.lane === "selection-rewrite" || route.lane === "cursor-context") { + if ( + route.lane === "selection-rewrite" || + route.lane === "cursor-context" + ) { return { valid: false, canRefresh: false, @@ -4341,7 +4723,9 @@ class AIControllerImpl implements AIController { return { valid: false, canRefresh: target.type === "block", - reason: revisionChanged ? "document-revision-mismatch" : "selection-changed", + reason: revisionChanged + ? "document-revision-mismatch" + : "selection-changed", }; } @@ -4363,7 +4747,8 @@ class AIControllerImpl implements AIController { ? 0 : route.intent === "continue" ? 0 - : route.intent === "rewrite" || route.intent === "local-edit" + : route.intent === "rewrite" || + route.intent === "local-edit" ? 1 : 0; const startIndex = Math.max(0, blockIndex - radius); @@ -4391,7 +4776,7 @@ class AIControllerImpl implements AIController { } try { - const retrieved = await toolRuntime.executeTool( + const retrieved = (await toolRuntime.executeTool( "retrieve_document_spans", { query: prompt, @@ -4401,7 +4786,7 @@ class AIControllerImpl implements AIController { targetBlockId: blockId, }, {} as never, - ) as { + )) as { spans?: AIWorkingSetRetrievedSpan[]; }; const retrievedSpan = retrieved.spans?.[0] ?? null; @@ -4412,7 +4797,10 @@ class AIControllerImpl implements AIController { // Older test fixtures or stale builds may not register the retriever yet. } - const markdownWindow = this._resolveMarkdownFastApplyWindow(route, blockId); + const markdownWindow = this._resolveMarkdownFastApplyWindow( + route, + blockId, + ); if (!markdownWindow) { return null; } @@ -4440,22 +4828,21 @@ class AIControllerImpl implements AIController { ): void { const session = sessionId != null - ? this._state.sessions.find((item) => item.id === sessionId) ?? null + ? (this._state.sessions.find((item) => item.id === sessionId) ?? + null) : null; const activeGeneration = this._state.activeGeneration; const undoGroupId = options?.undoGroupId ?? (session?.surface === "bottom-chat" && - activeGeneration != null && - activeGeneration.sessionId === sessionId + activeGeneration != null && + activeGeneration.sessionId === sessionId ? activeGeneration.undoGroupId : undefined); if (this._state.suggestMode && !sessionId) { this._editor.apply(ops, { origin: "ai", - ...(undoGroupId - ? { undoGroupId } - : { undoGroup: true }), + ...(undoGroupId ? { undoGroupId } : { undoGroup: true }), }); return; } @@ -4471,9 +4858,7 @@ class AIControllerImpl implements AIController { const origin = sessionId ? AI_SESSION_SUGGESTION_ORIGIN : "extension"; this._editor.apply(intercepted, { origin, - ...(undoGroupId - ? { undoGroupId } - : { undoGroup: true }), + ...(undoGroupId ? { undoGroupId } : { undoGroup: true }), }); } @@ -4502,18 +4887,23 @@ class AIControllerImpl implements AIController { insertionOffset?: number, ): DocumentOp[] { const targetBlock = this._editor.getBlock(blockId); - const normalizedText = shouldTrimLeadingBlankBlockGenerationText(targetBlock) + const normalizedText = shouldTrimLeadingBlankBlockGenerationText( + targetBlock, + ) ? trimLeadingBlankBlockGenerationText(text) : text; if (normalizedText.length === 0) { return []; } - return [{ - type: "insert-text", - blockId, - offset: insertionOffset ?? targetBlock?.textContent().length ?? 0, - text: normalizedText, - }]; + return [ + { + type: "insert-text", + blockId, + offset: + insertionOffset ?? targetBlock?.textContent().length ?? 0, + text: normalizedText, + }, + ]; } private _buildMarkdownBlockGenerationOps( @@ -4554,7 +4944,9 @@ class AIControllerImpl implements AIController { ]; } - private _createSelectionSignature(selection: SelectionState): string | null { + private _createSelectionSignature( + selection: SelectionState, + ): string | null { if (!selection) { return null; } @@ -4591,7 +4983,10 @@ class AIControllerImpl implements AIController { return; } this._state = nextState; - if (!this._isRestoringInlineHistory && !this._pendingInlineHistoryRestore) { + if ( + !this._isRestoringInlineHistory && + !this._pendingInlineHistoryRestore + ) { this._recordInlineHistorySnapshot(previousState, nextState); } this._editor.requestDecorationUpdate(); @@ -4611,19 +5006,28 @@ class AIControllerImpl implements AIController { ...activeGeneration, ...overrides, plan: - overrides.planState === "none" || overrides.planState === "rejected" + overrides.planState === "none" || + overrides.planState === "rejected" ? null : (overrides.plan ?? activeGeneration.plan), reviewItems: - overrides.planState === "none" || overrides.planState === "rejected" + overrides.planState === "none" || + overrides.planState === "rejected" ? [] - : (overrides.reviewItems ?? activeGeneration.reviewItems ?? []), + : (overrides.reviewItems ?? + activeGeneration.reviewItems ?? + []), structuredPreview: - overrides.planState === "none" || overrides.planState === "rejected" + overrides.planState === "none" || + overrides.planState === "rejected" ? null - : (overrides.structuredPreview ?? activeGeneration.structuredPreview ?? null), + : (overrides.structuredPreview ?? + activeGeneration.structuredPreview ?? + null), suggestionIds: - overrides.suggestionIds ?? activeGeneration.suggestionIds ?? [], + overrides.suggestionIds ?? + activeGeneration.suggestionIds ?? + [], }, }); } @@ -4634,7 +5038,9 @@ class AIControllerImpl implements AIController { resolution: AISessionResolution, options?: { finalizeSession?: boolean }, ): boolean { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); const turn = session?.turns.find((item) => item.id === turnId); if (!session || !turn) { return false; @@ -4643,12 +5049,10 @@ class AIControllerImpl implements AIController { session.surface === "bottom-chat" && (turn.target === "document" || turn.operation?.kind === "document-transform" || - ( - turn.operation?.kind === "rewrite-selection" && + (turn.operation?.kind === "rewrite-selection" && turn.operation.target.kind === "scoped-range" && (turn.operation.target.scope === "document" || - turn.operation.target.contentFormat === "markdown") - )); + turn.operation.target.contentFormat === "markdown"))); const turnUndoGroupId = isBottomChatDocumentTurn ? turn.undoGroupId : undefined; @@ -4659,31 +5063,32 @@ class AIControllerImpl implements AIController { : null; const refreshedInlineSelectionTarget = session.surface === "inline-edit" && resolution === "accept" - ? resolveAcceptedInlineSelectionTarget( + ? (resolveAcceptedInlineSelectionTarget( this._editor, turn.operation, turn.suggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor) + ) ?? resolveLiveInlineSelectionTarget(this._editor)) : null; const resolveSuggestionsForTurn = resolution === "accept" ? (suggestionIds: readonly string[]) => - acceptSuggestions(this._editor, suggestionIds, { - origin: turnSuggestionResolutionOrigin, - undoGroupId: turnUndoGroupId, - }) + acceptSuggestions(this._editor, suggestionIds, { + origin: turnSuggestionResolutionOrigin, + undoGroupId: turnUndoGroupId, + }) : (suggestionIds: readonly string[]) => - rejectSuggestions(this._editor, suggestionIds, { - origin: turnSuggestionResolutionOrigin, - undoGroupId: turnUndoGroupId, - }); + rejectSuggestions(this._editor, suggestionIds, { + origin: turnSuggestionResolutionOrigin, + undoGroupId: turnUndoGroupId, + }); const resolveReviewItems = resolution === "accept" - ? (reviewItemIds: readonly string[]) => this.acceptReviewItems(reviewItemIds) - : (reviewItemIds: readonly string[]) => this.rejectReviewItems(reviewItemIds); + ? (reviewItemIds: readonly string[]) => + this.acceptReviewItems(reviewItemIds) + : (reviewItemIds: readonly string[]) => + this.rejectReviewItems(reviewItemIds); let resolved = false; - resolved = - resolveSuggestionsForTurn(turn.suggestionIds) || resolved; + resolved = resolveSuggestionsForTurn(turn.suggestionIds) || resolved; if ( this._state.activeGeneration?.sessionId === sessionId && this._state.activeGeneration.turnId === turnId && @@ -4710,16 +5115,20 @@ class AIControllerImpl implements AIController { : undefined, }); if (refreshedInlineSelectionTarget) { - this._updateSession(sessionId, { - target: refreshedInlineSelectionTarget, - anchor: resolveSessionAnchor(refreshedInlineSelectionTarget.selection), - contextualPrompt: session.contextualPrompt - ? { - ...session.contextualPrompt, - anchor: resolveContextualPromptAnchor(refreshedInlineSelectionTarget), - } - : undefined, - }); + this._updateSession(sessionId, { + target: refreshedInlineSelectionTarget, + anchor: resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ), + contextualPrompt: session.contextualPrompt + ? { + ...session.contextualPrompt, + anchor: resolveContextualPromptAnchor( + refreshedInlineSelectionTarget, + ), + } + : undefined, + }); } if (options?.finalizeSession === false) { if (undoHistoryBeforeSnapshot) { @@ -4740,7 +5149,8 @@ class AIControllerImpl implements AIController { return true; } const nextSession = - this._state.sessions.find((item) => item.id === sessionId) ?? session; + this._state.sessions.find((item) => item.id === sessionId) ?? + session; this._updateSession(sessionId, { status: "complete", contextualPrompt: closeInlineSessionPrompt(nextSession), @@ -4770,37 +5180,41 @@ class AIControllerImpl implements AIController { const session = this._state.sessions.find((item) => item.id === sessionId) ?? null; if (session?.surface === "inline-edit") { - const reviewSnapshot = this._findInlineHistorySnapshotForResolvedTurn( - session, - "undo", - ); + const reviewSnapshot = + this._findInlineHistorySnapshotForResolvedTurn(session, "undo"); if (reviewSnapshot) { - const restoredSessions = reviewSnapshot.sessions.map((snapshotSession) => { - if ( - snapshotSession.id !== sessionId || - snapshotSession.surface !== "inline-edit" || - !snapshotSession.contextualPrompt - ) { - return snapshotSession; - } - const snapshotTurn = - snapshotSession.turns.find((turn) => turn.id === turnId) ?? null; - if (!snapshotTurn) { - return snapshotSession; - } - return { - ...snapshotSession, - contextualPrompt: { - ...snapshotSession.contextualPrompt, - composer: { - ...snapshotSession.contextualPrompt.composer, - draftPrompt: - snapshotSession.contextualPrompt.composer.draftPrompt || - snapshotTurn.prompt, + const restoredSessions = reviewSnapshot.sessions.map( + (snapshotSession) => { + if ( + snapshotSession.id !== sessionId || + snapshotSession.surface !== "inline-edit" || + !snapshotSession.contextualPrompt + ) { + return snapshotSession; + } + const snapshotTurn = + snapshotSession.turns.find( + (turn) => turn.id === turnId, + ) ?? null; + if (!snapshotTurn) { + return snapshotSession; + } + return { + ...snapshotSession, + contextualPrompt: { + ...snapshotSession.contextualPrompt, + composer: { + ...snapshotSession.contextualPrompt + .composer, + draftPrompt: + snapshotSession.contextualPrompt + .composer.draftPrompt || + snapshotTurn.prompt, + }, }, - }, - }; - }); + }; + }, + ); return createInlineHistorySnapshot( this._editor, restoredSessions, @@ -4818,7 +5232,8 @@ class AIControllerImpl implements AIController { ) { return session; } - const targetTurn = session.turns.find((turn) => turn.id === turnId) ?? null; + const targetTurn = + session.turns.find((turn) => turn.id === turnId) ?? null; if (targetTurn?.status !== "review") { return session; } @@ -4859,48 +5274,64 @@ class AIControllerImpl implements AIController { session.id !== sessionId ? session : { - ...session, - ...overrides, - contextualPrompt: - overrides.contextualPrompt ?? session.contextualPrompt - ? { - ...(session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )), - ...(overrides.contextualPrompt ?? {}), - anchor: { - ...((session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )).anchor), - ...(overrides.contextualPrompt?.anchor ?? {}), - }, - composer: { - ...((session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )).composer), - ...(overrides.contextualPrompt?.composer ?? {}), - isSubmitting: - overrides.contextualPrompt?.composer?.isSubmitting ?? - (overrides.status === "streaming" - ? true - : overrides.status - ? false - : (session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )).composer.isSubmitting), - }, - } - : undefined, - updatedAt: Date.now(), - metrics: { - ...session.metrics, - ...(overrides.metrics ?? {}), + ...session, + ...overrides, + contextualPrompt: + (overrides.contextualPrompt ?? + session.contextualPrompt) + ? { + ...(session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + )), + ...(overrides.contextualPrompt ?? {}), + anchor: { + ...( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).anchor, + ...(overrides.contextualPrompt + ?.anchor ?? {}), + }, + composer: { + ...( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).composer, + ...(overrides.contextualPrompt + ?.composer ?? {}), + isSubmitting: + overrides.contextualPrompt + ?.composer?.isSubmitting ?? + (overrides.status === + "streaming" + ? true + : overrides.status + ? false + : ( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).composer + .isSubmitting), + }, + } + : undefined, + updatedAt: Date.now(), + metrics: { + ...session.metrics, + ...(overrides.metrics ?? {}), + }, }, - }, ); if (nextSessions === this._state.sessions) { return; @@ -4908,7 +5339,8 @@ class AIControllerImpl implements AIController { this._setState({ sessions: nextSessions, activeSessionId: - this._state.activeSessionId === sessionId || this._state.activeSessionId == null + this._state.activeSessionId === sessionId || + this._state.activeSessionId == null ? sessionId : this._state.activeSessionId, }); @@ -4918,7 +5350,9 @@ class AIControllerImpl implements AIController { sessionId: string, fastApply: FastApplyDebugState | undefined, ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session) { return; } @@ -4938,7 +5372,9 @@ class AIControllerImpl implements AIController { turnId: string, overrides: Partial, ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if (!session) { return; } @@ -4946,9 +5382,9 @@ class AIControllerImpl implements AIController { turn.id !== turnId ? turn : { - ...turn, - ...overrides, - }, + ...turn, + ...overrides, + }, ); if (areStructuredValuesEqual(session.turns, nextTurns)) { return; @@ -4972,8 +5408,12 @@ class AIControllerImpl implements AIController { } const nextSessions = this._state.sessions.map((session) => { const nextTurns = session.turns.map((turn) => { - const suggestionIds = turn.suggestionIds.filter((sessionSuggestionId) => - this._suggestions.some((suggestion) => suggestion.id === sessionSuggestionId), + const suggestionIds = turn.suggestionIds.filter( + (sessionSuggestionId) => + this._suggestions.some( + (suggestion) => + suggestion.id === sessionSuggestionId, + ), ); const activeGenerationMatchesTurn = this._state.activeGeneration?.sessionId === session.id && @@ -4981,15 +5421,18 @@ class AIControllerImpl implements AIController { const activeGenerationForTurn = activeGenerationMatchesTurn ? this._state.activeGeneration : null; - const reviewItemIds = - activeGenerationForTurn - ? (activeGenerationForTurn.reviewItems ?? []) + const reviewItemIds = activeGenerationForTurn + ? (activeGenerationForTurn.reviewItems ?? []) .map((item) => item.id) .filter((id) => turn.reviewItemIds.includes(id)) - : []; + : []; const structuredPreview = activeGenerationForTurn - ? (activeGenerationForTurn.structuredPreview ?? turn.structuredPreview ?? null) - : (turn.reviewItemIds.length > 0 ? (turn.structuredPreview ?? null) : null); + ? (activeGenerationForTurn.structuredPreview ?? + turn.structuredPreview ?? + null) + : turn.reviewItemIds.length > 0 + ? (turn.structuredPreview ?? null) + : null; return { ...turn, suggestionIds, @@ -4997,13 +5440,16 @@ class AIControllerImpl implements AIController { structuredPreview, }; }); - const pendingSuggestionIds = [...new Set(nextTurns.flatMap((turn) => turn.suggestionIds))]; - const pendingReviewItemIds = - [...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds))]; + const pendingSuggestionIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), + ]; + const pendingReviewItemIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), + ]; const nextStatus = pendingSuggestionIds.length === 0 && - pendingReviewItemIds.length === 0 && - session.status === "streaming" + pendingReviewItemIds.length === 0 && + session.status === "streaming" ? "complete" : session.status; return { @@ -5073,9 +5519,7 @@ class AIControllerImpl implements AIController { previousState: AIControllerState, nextState: AIControllerState, ): void { - if ( - !didInlineHistoryCheckpointChange(previousState, nextState) - ) { + if (!didInlineHistoryCheckpointChange(previousState, nextState)) { return; } if ( @@ -5085,7 +5529,10 @@ class AIControllerImpl implements AIController { return; } const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; - const nextHistory = this._inlineHistory.slice(0, this._inlineHistoryIndex + 1); + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); if (nextHistory.length === 0) { const baselineSnapshot = createInlineHistorySnapshot( this._editor, @@ -5095,7 +5542,8 @@ class AIControllerImpl implements AIController { ); nextHistory.push(baselineSnapshot); } - const previousSnapshot = nextHistory[nextHistory.length - 1] ?? currentSnapshot ?? null; + const previousSnapshot = + nextHistory[nextHistory.length - 1] ?? currentSnapshot ?? null; const snapshot = createInlineHistorySnapshot( this._editor, nextState.sessions, @@ -5108,7 +5556,10 @@ class AIControllerImpl implements AIController { : "document-coupled", }, ); - if (currentSnapshot && areInlineHistorySnapshotsEqual(currentSnapshot, snapshot)) { + if ( + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) + ) { return; } const currentUndoMetadata = @@ -5118,7 +5569,8 @@ class AIControllerImpl implements AIController { const shouldPersistUndoSnapshot = previousSnapshot != null && (snapshot.kind === "document-coupled" || - currentUndoMetadata?.after?.documentVersion === this._documentVersion); + currentUndoMetadata?.after?.documentVersion === + this._documentVersion); if (shouldPersistUndoSnapshot && previousSnapshot) { this._undoHistoryMetadata?.setCurrentEntryMetadata( AI_UNDO_HISTORY_METADATA_KEY, @@ -5137,7 +5589,9 @@ class AIControllerImpl implements AIController { sessionId: string, prompt: string, ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if ( !session || session.surface !== "inline-edit" || @@ -5152,17 +5606,17 @@ class AIControllerImpl implements AIController { item.id !== sessionId ? item : { - ...item, - contextualPrompt: { - ...item.contextualPrompt!, - composer: { - ...item.contextualPrompt!.composer, - draftPrompt: prompt, - isOpen: true, - isSubmitting: false, + ...item, + contextualPrompt: { + ...item.contextualPrompt!, + composer: { + ...item.contextualPrompt!.composer, + draftPrompt: prompt, + isOpen: true, + isSubmitting: false, + }, }, }, - }, ), }; const snapshot = createInlineHistorySnapshot( @@ -5173,10 +5627,16 @@ class AIControllerImpl implements AIController { { kind: "ui-local" }, ); const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; - if (currentSnapshot && areInlineHistorySnapshotsEqual(currentSnapshot, snapshot)) { + if ( + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) + ) { return; } - const nextHistory = this._inlineHistory.slice(0, this._inlineHistoryIndex + 1); + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); nextHistory.push(snapshot); this._inlineHistory = nextHistory; this._inlineHistoryIndex = nextHistory.length - 1; @@ -5190,19 +5650,22 @@ class AIControllerImpl implements AIController { if (!options?.shortcutOnly) { return this._inlineHistoryIndex + step; } - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex] ?? null; + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; const scopedSessionId = this._resolveShortcutInlineHistorySessionId( currentSnapshot, direction, ); - const waypoints = this._buildInlineShortcutHistoryWaypoints(scopedSessionId); + const waypoints = + this._buildInlineShortcutHistoryWaypoints(scopedSessionId); if (waypoints.length === 0) { return -1; } - const currentWaypointIndex = this._resolveCurrentInlineShortcutWaypointIndex( - waypoints, - scopedSessionId, - ); + const currentWaypointIndex = + this._resolveCurrentInlineShortcutWaypointIndex( + waypoints, + scopedSessionId, + ); if (currentWaypointIndex < 0) { return -1; } @@ -5219,7 +5682,11 @@ class AIControllerImpl implements AIController { return activeSession.id; } const selection = this._editor.selection; - if (currentSnapshot && selection?.type === "text" && !selection.isCollapsed) { + if ( + currentSnapshot && + selection?.type === "text" && + !selection.isCollapsed + ) { const matchingSession = [...currentSnapshot.sessions] .reverse() .find( @@ -5254,13 +5721,13 @@ class AIControllerImpl implements AIController { const searchSnapshot = this._inlineHistory[searchIndex]; const matchingSelectionSession = selection?.type === "text" && !selection.isCollapsed - ? [...(searchSnapshot?.sessions ?? [])] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - sessionSelectionMatches(session, selection), - ) ?? null + ? ([...(searchSnapshot?.sessions ?? [])] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + sessionSelectionMatches(session, selection), + ) ?? null) : null; if (matchingSelectionSession) { return matchingSelectionSession.id; @@ -5268,7 +5735,8 @@ class AIControllerImpl implements AIController { const searchInlineSession = [...(searchSnapshot?.sessions ?? [])] .reverse() - .find((session) => session.surface === "inline-edit") ?? null; + .find((session) => session.surface === "inline-edit") ?? + null; if (searchInlineSession) { return searchInlineSession.id; } @@ -5286,20 +5754,28 @@ class AIControllerImpl implements AIController { if (!snapshot || snapshot.kind === "ui-local") { continue; } - const state = resolveInlineShortcutHistoryState(snapshot, sessionId); + const state = resolveInlineShortcutHistoryState( + snapshot, + sessionId, + ); if (!state) { continue; } const previousWaypoint = waypoints[waypoints.length - 1] ?? null; if ( previousWaypoint && - areInlineShortcutHistoryStatesEqual(previousWaypoint.state, state) + areInlineShortcutHistoryStatesEqual( + previousWaypoint.state, + state, + ) ) { previousWaypoint.endIndex = index; if ( shouldReplaceInlineShortcutWaypointRepresentative( previousWaypoint.state, - this._inlineHistory[previousWaypoint.representativeIndex] ?? null, + this._inlineHistory[ + previousWaypoint.representativeIndex + ] ?? null, snapshot, ) ) { @@ -5321,7 +5797,8 @@ class AIControllerImpl implements AIController { waypoints: readonly AIInlineShortcutHistoryWaypoint[], sessionId: string | null, ): number { - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex] ?? null; + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; const currentState = currentSnapshot ? resolveInlineShortcutHistoryState(currentSnapshot, sessionId) : null; @@ -5330,20 +5807,29 @@ class AIControllerImpl implements AIController { (waypoint) => this._inlineHistoryIndex >= waypoint.startIndex && this._inlineHistoryIndex <= waypoint.endIndex && - areInlineShortcutHistoryStatesEqual(waypoint.state, currentState), + areInlineShortcutHistoryStatesEqual( + waypoint.state, + currentState, + ), ); if (currentIndex >= 0) { return currentIndex; } const matchingIndex = waypoints.findIndex((waypoint) => - areInlineShortcutHistoryStatesEqual(waypoint.state, currentState), + areInlineShortcutHistoryStatesEqual( + waypoint.state, + currentState, + ), ); if (matchingIndex >= 0) { return matchingIndex; } } for (let index = waypoints.length - 1; index >= 0; index -= 1) { - if (waypoints[index]!.representativeIndex <= this._inlineHistoryIndex) { + if ( + waypoints[index]!.representativeIndex <= + this._inlineHistoryIndex + ) { return index; } } @@ -5354,7 +5840,10 @@ class AIControllerImpl implements AIController { direction: AIInlineHistoryDirection, options?: { shortcutOnly?: boolean }, ): boolean { - const targetIndex = this._resolveInlineHistoryTargetIndex(direction, options); + const targetIndex = this._resolveInlineHistoryTargetIndex( + direction, + options, + ); const targetSnapshot = this._inlineHistory[targetIndex]; if (!targetSnapshot) { return false; @@ -5371,14 +5860,21 @@ class AIControllerImpl implements AIController { direction: AIInlineHistoryDirection, options?: { shortcutOnly?: boolean }, ): boolean { - const targetIndex = this._resolveInlineHistoryTargetIndex(direction, options); + const targetIndex = this._resolveInlineHistoryTargetIndex( + direction, + options, + ); const targetSnapshot = this._inlineHistory[targetIndex]; if (!targetSnapshot) { return false; } - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex] ?? null; + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; const shortcutSessionId = options?.shortcutOnly - ? this._resolveShortcutInlineHistorySessionId(currentSnapshot, direction) + ? this._resolveShortcutInlineHistorySessionId( + currentSnapshot, + direction, + ) : null; if (targetSnapshot.kind === "ui-local") { this._applyInlineHistorySnapshot(targetSnapshot, { @@ -5394,9 +5890,9 @@ class AIControllerImpl implements AIController { const targetState = resolveInlineShortcutHistoryState( targetSnapshot, shortcutSessionId ?? - targetSnapshot.sessionId ?? - targetSnapshot.activeSessionId ?? - null, + targetSnapshot.sessionId ?? + targetSnapshot.activeSessionId ?? + null, ); this._pendingInlineHistoryRestore = { direction, @@ -5417,9 +5913,9 @@ class AIControllerImpl implements AIController { } const resolvedTargetSnapshot = options?.shortcutOnly ? this._resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot, - shortcutSessionId, - ) + targetSnapshot, + shortcutSessionId, + ) : targetSnapshot; this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { historyTraversal: true, @@ -5474,13 +5970,19 @@ class AIControllerImpl implements AIController { ); if (targetIndex >= 0) { this._inlineHistoryIndex = targetIndex; - this._applyInlineHistorySnapshot(this._inlineHistory[targetIndex]!, { - historyTraversal: true, - }); + this._applyInlineHistorySnapshot( + this._inlineHistory[targetIndex]!, + { + historyTraversal: true, + }, + ); return; } this._applyInlineHistorySnapshot(snapshot, { historyTraversal: true }); - const nextHistory = this._inlineHistory.slice(0, this._inlineHistoryIndex + 1); + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); nextHistory.push(snapshot); this._inlineHistory = nextHistory; this._inlineHistoryIndex = nextHistory.length - 1; @@ -5490,21 +5992,30 @@ class AIControllerImpl implements AIController { session: AISession, direction: AIInlineHistoryDirection, ): AIInlineHistorySnapshot | null { - const latestTurnId = session.turns[session.turns.length - 1]?.id ?? null; + const latestTurnId = + session.turns[session.turns.length - 1]?.id ?? null; if (!latestTurnId) { return null; } - for (let index = this._inlineHistory.length - 1; index >= 0; index -= 1) { + for ( + let index = this._inlineHistory.length - 1; + index >= 0; + index -= 1 + ) { const snapshot = this._inlineHistory[index]; const snapshotSession = snapshot?.sessions.find( - (item) => item.id === session.id && item.surface === "inline-edit", + (item) => + item.id === session.id && + item.surface === "inline-edit", ) ?? null; if (!snapshotSession) { continue; } const snapshotTurn = - snapshotSession.turns.find((turn) => turn.id === latestTurnId) ?? null; + snapshotSession.turns.find( + (turn) => turn.id === latestTurnId, + ) ?? null; if (!snapshotTurn) { continue; } @@ -5588,7 +6099,9 @@ class AIControllerImpl implements AIController { } return createInlineHistorySnapshot( this._editor, - targetSnapshot.sessions.filter((session) => session.id !== scopedSessionId), + targetSnapshot.sessions.filter( + (session) => session.id !== scopedSessionId, + ), targetSnapshot.activeSessionId === scopedSessionId ? null : targetSnapshot.activeSessionId, @@ -5636,7 +6149,8 @@ class AIControllerImpl implements AIController { return -1; } let resolvedTargetIndex = -1; - const scopedSessionId = request.sessionId ?? request.targetState.sessionId; + const scopedSessionId = + request.sessionId ?? request.targetState.sessionId; for (let index = 0; index < this._inlineHistory.length; index += 1) { const snapshot = this._inlineHistory[index]; if (!snapshot || snapshot.kind === "ui-local") { @@ -5651,7 +6165,10 @@ class AIControllerImpl implements AIController { ); if ( !snapshotState || - !areInlineShortcutHistoryStatesEqual(snapshotState, request.targetState) + !areInlineShortcutHistoryStatesEqual( + snapshotState, + request.targetState, + ) ) { continue; } @@ -5674,25 +6191,25 @@ class AIControllerImpl implements AIController { this._pendingInlineHistoryRestore && this._pendingInlineHistoryRestore.direction === event.kind ) { - const targetIndex = this._resolvePendingInlineHistoryRestoreTargetIndex( - this._pendingInlineHistoryRestore, - ); + const targetIndex = + this._resolvePendingInlineHistoryRestoreTargetIndex( + this._pendingInlineHistoryRestore, + ); if (targetIndex >= 0) { this._inlineHistoryIndex = targetIndex; const targetSnapshot = this._inlineHistory[targetIndex]!; - const resolvedTargetSnapshot = - this._pendingInlineHistoryRestore.shortcutOnly - ? this._resolveShortcutInlineHistoryTraversalSnapshot( + const resolvedTargetSnapshot = this._pendingInlineHistoryRestore + .shortcutOnly + ? this._resolveShortcutInlineHistoryTraversalSnapshot( targetSnapshot, this._pendingInlineHistoryRestore.sessionId ?? null, ) - : this._resolveInlineHistoryTraversalSnapshot(targetSnapshot); - this._applyInlineHistorySnapshot( - resolvedTargetSnapshot, - { - historyTraversal: true, - }, - ); + : this._resolveInlineHistoryTraversalSnapshot( + targetSnapshot, + ); + this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { + historyTraversal: true, + }); } this._pendingInlineHistoryRestore = null; this._scheduleQueuedInlineHistoryShortcutFlush(); @@ -5727,7 +6244,9 @@ class AIControllerImpl implements AIController { isOpen: boolean, options?: { openReason?: "user" | "history" }, ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); if ( !session || session.surface !== "inline-edit" || @@ -5750,19 +6269,20 @@ class AIControllerImpl implements AIController { item.id !== sessionId ? item : { - ...item, - contextualPrompt: { - ...item.contextualPrompt!, - composer: { - ...item.contextualPrompt!.composer, - isOpen, - openReason: isOpen - ? (options?.openReason ?? "user") - : item.contextualPrompt!.composer.openReason, + ...item, + contextualPrompt: { + ...item.contextualPrompt!, + composer: { + ...item.contextualPrompt!.composer, + isOpen, + openReason: isOpen + ? (options?.openReason ?? "user") + : item.contextualPrompt!.composer + .openReason, + }, }, + updatedAt: Date.now(), }, - updatedAt: Date.now(), - }, ); this._setState({ sessions: nextSessions, @@ -5788,7 +6308,8 @@ export function aiExtension(config: AIExtensionConfig = {}): Extension { activateClient: async ({ editor }) => { activeEditor = editor; - const inlineCompletionRegistration = ensureInlineCompletionController(editor); + const inlineCompletionRegistration = + ensureInlineCompletionController(editor); inlineCompletion = inlineCompletionRegistration.controller; releaseInlineCompletion = inlineCompletionRegistration.release; controller = new AIControllerImpl(editor, config, { @@ -5814,28 +6335,39 @@ export function aiExtension(config: AIExtensionConfig = {}): Extension { }); reviewController = new AIReviewService({ getSuggestions: () => controller?.getSuggestions() ?? [], - acceptSuggestion: (id) => controller?.acceptSuggestion(id) ?? false, - rejectSuggestion: (id) => controller?.rejectSuggestion(id) ?? false, + acceptSuggestion: (id) => + controller?.acceptSuggestion(id) ?? false, + rejectSuggestion: (id) => + controller?.rejectSuggestion(id) ?? false, acceptAllSuggestions: () => controller?.acceptAllSuggestions(), rejectAllSuggestions: () => controller?.rejectAllSuggestions(), }); editor.internals.setSlot(AI_CONTROLLER_SLOT, controller); editor.internals.setSlot(AI_INLINE_HISTORY_SLOT, inlineHistory); - editor.internals.setSlot(AI_REVIEW_CONTROLLER_SLOT, reviewController); - unsubscribeTrackedOrigins = editor.undoManager.registerTrackedOrigins([ - AI_SESSION_SUGGESTION_ORIGIN, - SUGGESTION_RESOLUTION_ORIGIN, - ]); + editor.internals.setSlot( + AI_REVIEW_CONTROLLER_SLOT, + reviewController, + ); + unsubscribeTrackedOrigins = + editor.undoManager.registerTrackedOrigins([ + AI_SESSION_SUGGESTION_ORIGIN, + SUGGESTION_RESOLUTION_ORIGIN, + ]); unsubscribeBeforeApply = editor.onBeforeApply( (ops, options) => { if (!controller?.getState().suggestMode) return ops; if (shouldBypassSuggestMode(options.origin)) return ops; + const originType = options.origin + ? getOpOriginType(options.origin) + : undefined; return interceptApplyForSuggestMode( ops, editor, - options.origin === "ai" ? "assistant" : config.author ?? "user", - options.origin === "ai" ? "ai" : "user", + originType === "ai" + ? "assistant" + : (config.author ?? "user"), + originType === "ai" ? "ai" : "user", readModelId(config.model), ); }, @@ -5873,7 +6405,9 @@ export function aiExtension(config: AIExtensionConfig = {}): Extension { decorations: () => { const decorations = controller?.buildDecorations() ?? []; const inlineDecorations = - activeEditor?.internals.getSlot(AI_AUTOCOMPLETE_CONTROLLER_SLOT) == null + activeEditor?.internals.getSlot( + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + ) == null ? (inlineCompletion?.buildDecorations() ?? []) : []; return createDecorationSet([...decorations, ...inlineDecorations]); @@ -5900,15 +6434,21 @@ export function getAIInlineCompletionController( export function getAIInlineHistoryController( editor: Editor, ): AIInlineHistoryController | null { - return editor.internals.getSlot( - AI_INLINE_HISTORY_SLOT, - ) ?? null; + return ( + editor.internals.getSlot( + AI_INLINE_HISTORY_SLOT, + ) ?? null + ); } -export function getAIReviewController(editor: Editor): AIReviewController | null { - return editor.internals.getSlot( - AI_REVIEW_CONTROLLER_SLOT, - ) ?? null; +export function getAIReviewController( + editor: Editor, +): AIReviewController | null { + return ( + editor.internals.getSlot( + AI_REVIEW_CONTROLLER_SLOT, + ) ?? null + ); } function resolveOrderedReviewItems( @@ -5937,7 +6477,10 @@ function compareReviewItemRemovalOrder( left: StructuralReviewItem, right: StructuralReviewItem, ): number { - const maxPathLength = Math.max(left.bundlePath.length, right.bundlePath.length); + const maxPathLength = Math.max( + left.bundlePath.length, + right.bundlePath.length, + ); for (let index = 0; index < maxPathLength; index += 1) { const leftPart = left.bundlePath[index] ?? -1; const rightPart = right.bundlePath[index] ?? -1; @@ -5968,76 +6511,77 @@ function readModelId(model: ModelAdapter | undefined): string | undefined { return candidate.modelId ?? candidate.name; } -function supportsStructuredIntent( - model: ModelAdapter | undefined, -): boolean { +function supportsStructuredIntent(model: ModelAdapter | undefined): boolean { return model?.capabilities?.structuredIntent === true; } type AIStreamEventInput = | { - type: "generation-start"; - prompt: string; - target: GenerationState["target"]; - } + type: "generation-start"; + prompt: string; + target: GenerationState["target"]; + } | { - type: "status"; - status: AIControllerState["status"]; - } + type: "status"; + status: AIControllerState["status"]; + } | { - type: "text-delta"; - delta: string; - text: string; - } + type: "text-delta"; + delta: string; + text: string; + } | { - type: "operation"; - operation: AIRequestedOperation; - phase: "preview" | "final" | "conflict"; - text?: string; - reason?: string; - } + type: "operation"; + operation: AIRequestedOperation; + phase: "preview" | "final" | "conflict"; + text?: string; + reason?: string; + } | { - type: "app-partial"; - data: unknown; - final: boolean; - } + type: "app-partial"; + data: unknown; + final: boolean; + } | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - } + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + } | { - type: "tool-output"; - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - } + type: "tool-output"; + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + } | { - type: "tool-result"; - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - } + type: "tool-result"; + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + } | { - type: "structured-preview"; - preview: GenerationStructuredPreviewState; - patches: readonly { - op: "add" | "remove" | "replace"; - path: string; - value?: unknown; - }[]; - } + type: "structured-preview"; + preview: GenerationStructuredPreviewState; + patches: readonly { + op: "add" | "remove" | "replace"; + path: string; + value?: unknown; + }[]; + } | { - type: "generation-finish"; - status: GenerationState["status"]; - text: string; - }; + type: "generation-finish"; + status: GenerationState["status"]; + text: string; + }; function createAIStreamEvent( - generation: Pick, + generation: Pick< + GenerationState, + "id" | "zoneId" | "blockId" | "sessionId" + >, event: AIStreamEventInput, ): AIStreamEvent { return { @@ -6063,7 +6607,9 @@ function resolvePromptTarget( if (target === "document") { return "document"; } - return selection?.type === "text" && !selection.isCollapsed ? "selection" : "block"; + return selection?.type === "text" && !selection.isCollapsed + ? "selection" + : "block"; } function resolveSessionTarget( @@ -6088,11 +6634,11 @@ function resolveSessionTarget( }; } const blockId = - (target === "block" || target === "auto") - ? resolveActiveBlockId(selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null + target === "block" || target === "auto" + ? (resolveActiveBlockId(selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null) : null; return blockId ? { kind: "block", blockId } : { kind: "document" }; } @@ -6129,7 +6675,9 @@ function resolveContextualPromptAnchor( const range = target.selection.toRange(); return { kind: "text-range", - selectionSnapshot: resolveSessionSelectionSnapshot(target.selection), + selectionSnapshot: resolveSessionSelectionSnapshot( + target.selection, + ), focusBlockId: range.start.blockId, status: "valid", lastResolvedRect: null, @@ -6211,28 +6759,33 @@ function cloneInlineHistorySessions( target: cloneSessionTarget(editor, session.target), contextualPrompt: session.contextualPrompt ? { - ...session.contextualPrompt, - anchor: { - ...session.contextualPrompt.anchor, - selectionSnapshot: session.contextualPrompt.anchor.selectionSnapshot - ? { - ...session.contextualPrompt.anchor.selectionSnapshot, - anchor: { - ...session.contextualPrompt.anchor.selectionSnapshot.anchor, - }, - focus: { - ...session.contextualPrompt.anchor.selectionSnapshot.focus, - }, - blockRange: [ - ...session.contextualPrompt.anchor.selectionSnapshot.blockRange, - ], - } - : undefined, - }, - composer: { - ...session.contextualPrompt.composer, - }, - } + ...session.contextualPrompt, + anchor: { + ...session.contextualPrompt.anchor, + selectionSnapshot: session.contextualPrompt.anchor + .selectionSnapshot + ? { + ...session.contextualPrompt.anchor + .selectionSnapshot, + anchor: { + ...session.contextualPrompt.anchor + .selectionSnapshot.anchor, + }, + focus: { + ...session.contextualPrompt.anchor + .selectionSnapshot.focus, + }, + blockRange: [ + ...session.contextualPrompt.anchor + .selectionSnapshot.blockRange, + ], + } + : undefined, + }, + composer: { + ...session.contextualPrompt.composer, + }, + } : undefined, turns: session.turns.map((turn) => ({ ...turn, @@ -6241,11 +6794,11 @@ function cloneInlineHistorySessions( anchor: turn.anchor ? { ...turn.anchor } : undefined, selection: turn.selection ? { - ...turn.selection, - anchor: { ...turn.selection.anchor }, - focus: { ...turn.selection.focus }, - blockRange: [...turn.selection.blockRange], - } + ...turn.selection, + anchor: { ...turn.selection.anchor }, + focus: { ...turn.selection.focus }, + blockRange: [...turn.selection.blockRange], + } : undefined, })), promptHistory: session.promptHistory.map((prompt) => ({ ...prompt })), @@ -6284,7 +6837,8 @@ function recreateTextSelection( const isSingleBlock = blockRange.length === 1; if (isSingleBlock) { return ( - point.offset >= this.start.offset && point.offset <= this.end.offset + point.offset >= this.start.offset && + point.offset <= this.end.offset ); } if (point.blockId === this.start.blockId) { @@ -6300,7 +6854,11 @@ function recreateTextSelection( end: { blockId: string; offset: number }; contains: (point: { blockId: string; offset: number }) => boolean; }): boolean { - return this.contains(other.start) || this.contains(other.end) || other.contains(this.start); + return ( + this.contains(other.start) || + this.contains(other.end) || + other.contains(this.start) + ); }, equals(other: { start: { blockId: string; offset: number }; @@ -6380,7 +6938,8 @@ function resolveSelectionSnapshotRangeEnd( offset: Math.max(snapshot.anchor.offset, snapshot.focus.offset), }; } - const lastBlockId = blockRange[blockRange.length - 1] ?? snapshot.focus.blockId; + const lastBlockId = + blockRange[blockRange.length - 1] ?? snapshot.focus.blockId; return snapshot.anchor.blockId === lastBlockId ? { ...snapshot.anchor } : { ...snapshot.focus }; @@ -6430,10 +6989,10 @@ function resolveRequestedOperationForSession( ); const documentTransformPlan = clearDocument ? { - blockIds: documentBlockIds, - placement: "replace-blocks" as const, - transform: "remove" as const, - } + blockIds: documentBlockIds, + placement: "replace-blocks" as const, + transform: "remove" as const, + } : undefined; if (resolvedEditProposal) { @@ -6469,7 +7028,8 @@ function resolveRequestedOperationForSession( activeBlockId && (promptIntent === "rewrite" || (promptIntent === "local-edit" && - (editor.getBlock(activeBlockId)?.textContent().length ?? 0) > 0) || + (editor.getBlock(activeBlockId)?.textContent().length ?? 0) > + 0) || explicitTarget === "block") ) { if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { @@ -6503,7 +7063,9 @@ function resolveRequestedOperationForSession( } return createDocumentTransformOperation( editor, - session.target.kind === "document" ? documentActiveBlockId : activeBlockId, + session.target.kind === "document" + ? documentActiveBlockId + : activeBlockId, promptIntent, documentVersion, documentTransformPlan, @@ -6526,14 +7088,18 @@ function resolveLocalOperationContentFormat( if (operation.kind !== "rewrite-block") { return "text"; } - const blockId = operation.target.kind === "block" ? operation.target.blockId : null; + const blockId = + operation.target.kind === "block" ? operation.target.blockId : null; if (blockId && resolveFullBlockTextSelection(editor, blockId)) { return "text"; } return defaultBlockFormat; } -function canUseLocalBlockTextOperation(editor: Editor, blockId: string): boolean { +function canUseLocalBlockTextOperation( + editor: Editor, + blockId: string, +): boolean { const block = editor.getBlock(blockId); if (!block) { return false; @@ -6554,7 +7120,10 @@ function canReuseBottomChatSessionOperation( const nextResolvedTarget = resolveResolvedEditTargetFromRequestedOperation(nextOperation); if (previousResolvedTarget && nextResolvedTarget) { - return areResolvedEditTargetsEqual(previousResolvedTarget, nextResolvedTarget); + return areResolvedEditTargetsEqual( + previousResolvedTarget, + nextResolvedTarget, + ); } if (previousOperation.kind !== nextOperation.kind) { return false; @@ -6574,8 +7143,9 @@ function canReuseBottomChatSessionOperation( } return ( previousOperation.provenance?.selectionSignature === - nextOperation.provenance?.selectionSignature && - previousOperation.target.sourceText === nextOperation.target.sourceText + nextOperation.provenance?.selectionSignature && + previousOperation.target.sourceText === + nextOperation.target.sourceText ); } if (previousOperation.target.kind === "block") { @@ -6585,22 +7155,23 @@ function canReuseBottomChatSessionOperation( return ( previousOperation.target.blockId === nextOperation.target.blockId && previousOperation.provenance?.blockRevision === - nextOperation.provenance?.blockRevision + nextOperation.provenance?.blockRevision ); } if (nextOperation.target.kind !== "document") { return false; } return ( - previousOperation.target.activeBlockId === nextOperation.target.activeBlockId && + previousOperation.target.activeBlockId === + nextOperation.target.activeBlockId && areStructuredValuesEqual( previousOperation.target.blockIds ?? [], nextOperation.target.blockIds ?? [], ) && (previousOperation.target.placement ?? null) === - (nextOperation.target.placement ?? null) && + (nextOperation.target.placement ?? null) && (previousOperation.target.transform ?? null) === - (nextOperation.target.transform ?? null) + (nextOperation.target.transform ?? null) ); } @@ -6633,11 +7204,17 @@ function areResolvedEditTargetsEqual( ) { return false; } - if (previousTarget.kind === "scoped-range" && nextTarget.kind === "scoped-range") { + if ( + previousTarget.kind === "scoped-range" && + nextTarget.kind === "scoped-range" + ) { return ( previousTarget.scope === nextTarget.scope && previousTarget.contentFormat === nextTarget.contentFormat && - areStructuredValuesEqual(previousTarget.blockIds, nextTarget.blockIds) + areStructuredValuesEqual( + previousTarget.blockIds, + nextTarget.blockIds, + ) ); } return true; @@ -6670,7 +7247,11 @@ function isDocumentResetPrompt(prompt: string): boolean { function isDocumentFollowUpEditPrompt(prompt: string): boolean { const normalizedPrompt = prompt.trim().toLowerCase(); - if (/\b(continue|append|add|insert|another|more|next)\b/.test(normalizedPrompt)) { + if ( + /\b(continue|append|add|insert|another|more|next)\b/.test( + normalizedPrompt, + ) + ) { return false; } return ( @@ -6679,7 +7260,8 @@ function isDocumentFollowUpEditPrompt(prompt: string): boolean { ) && (/\b(title|heading|story|document|content|contents|text|tone|voice|ending|opening|intro|introduction|theme)\b/.test( normalizedPrompt, - ) || /\bmake (?:it|this)\b/.test(normalizedPrompt)) + ) || + /\bmake (?:it|this)\b/.test(normalizedPrompt)) ); } @@ -6738,7 +7320,8 @@ function createRewriteSelectionOperation( blockId: range.start.blockId, anchor: { ...selection.anchor }, focus: { ...selection.focus }, - sourceText: options?.sourceText ?? resolveSelectionText(editor, selection), + sourceText: + options?.sourceText ?? resolveSelectionText(editor, selection), }, provenance: { documentVersion, @@ -6790,7 +7373,9 @@ function createRewriteSelectionOperationFromResolvedTarget( }, provenance: { documentVersion, - blockRevision: editor.getBlockRevision(target.blockId ?? selection.anchor.blockId), + blockRevision: editor.getBlockRevision( + target.blockId ?? selection.anchor.blockId, + ), selectionSignature: createSelectionSignature(selection), syncedGeneration: editor.documentState.generation, }, @@ -6855,7 +7440,10 @@ function createDocumentTransformOperation( documentVersion: number, options?: { blockIds?: readonly string[]; - placement?: "append-after-block" | "replace-empty-block" | "replace-blocks"; + placement?: + | "append-after-block" + | "replace-empty-block" + | "replace-blocks"; transform?: "write" | "rewrite" | "remove"; }, ): AIRequestedOperation { @@ -6907,7 +7495,9 @@ function resolveReplacementDeleteBlockIds( replaceBlockIds?: readonly string[], ): string[] { const requestedIds = - replaceBlockIds && replaceBlockIds.length > 0 ? replaceBlockIds : [blockId]; + replaceBlockIds && replaceBlockIds.length > 0 + ? replaceBlockIds + : [blockId]; const deleteBlockIds = requestedIds.filter( (candidateBlockId, index, allBlockIds) => allBlockIds.indexOf(candidateBlockId) === index && @@ -7013,7 +7603,10 @@ function resolveResolvedEditProposal( ); } - const paragraphSelection = resolveDocumentParagraphSelection(editor, prompt); + const paragraphSelection = resolveDocumentParagraphSelection( + editor, + prompt, + ); if (paragraphSelection) { return createResolvedEditProposal( promptIntent, @@ -7077,7 +7670,8 @@ function resolveSelectionForRequestedOperation( focus: operation.target.focus, blockRange: resolveSelectionTargetBlockIds(editor, operation.target), isMultiBlock: - resolveSelectionTargetBlockIds(editor, operation.target).length > 1 || + resolveSelectionTargetBlockIds(editor, operation.target).length > + 1 || operation.target.anchor.blockId !== operation.target.focus.blockId, }); } @@ -7104,7 +7698,8 @@ function resolveDocumentBlockRangeSelection( ): TextSelection | null { const resolvedBlockIds = blockIds.filter( (blockId, index, allBlockIds) => - allBlockIds.indexOf(blockId) === index && editor.getBlock(blockId) != null, + allBlockIds.indexOf(blockId) === index && + editor.getBlock(blockId) != null, ); const firstBlockId = resolvedBlockIds[0]; const lastBlockId = resolvedBlockIds[resolvedBlockIds.length - 1]; @@ -7133,7 +7728,9 @@ function resolveDocumentTitleSelection( const headingBlockId = editor.documentState.blockOrder.find((blockId) => { const block = editor.getBlock(blockId); - return block?.type === "heading" || block?.type.startsWith("heading-"); + return ( + block?.type === "heading" || block?.type.startsWith("heading-") + ); }) ?? editor.firstBlock()?.id ?? null; @@ -7150,19 +7747,22 @@ function resolveDocumentParagraphSelection( if (paragraphIndex == null) { return null; } - const paragraphBlockIds = editor.documentState.blockOrder.filter((blockId) => { - const block = editor.getBlock(blockId); - if (!block) { - return false; - } - return ( - block.type === "paragraph" || - (block.textContent().trim().length > 0 && - block.type !== "heading" && - !block.type.startsWith("heading-")) - ); - }); - const targetParagraphBlockId = paragraphBlockIds[paragraphIndex - 1] ?? null; + const paragraphBlockIds = editor.documentState.blockOrder.filter( + (blockId) => { + const block = editor.getBlock(blockId); + if (!block) { + return false; + } + return ( + block.type === "paragraph" || + (block.textContent().trim().length > 0 && + block.type !== "heading" && + !block.type.startsWith("heading-")) + ); + }, + ); + const targetParagraphBlockId = + paragraphBlockIds[paragraphIndex - 1] ?? null; return targetParagraphBlockId ? resolveDocumentBlockRangeSelection(editor, [targetParagraphBlockId]) : null; @@ -7236,7 +7836,10 @@ function resolveRequestedOperationConflict( operation.target.kind === "selection" || operation.target.kind === "scoped-range" ) { - const selection = resolveSelectionForRequestedOperation(editor, operation); + const selection = resolveSelectionForRequestedOperation( + editor, + operation, + ); if (!selection) { return "The selected range no longer exists."; } @@ -7251,11 +7854,15 @@ function resolveRequestedOperationConflict( } if ( operation.provenance?.selectionSignature != null && - operation.provenance.selectionSignature !== currentSelectionSignature + operation.provenance.selectionSignature !== + currentSelectionSignature ) { return "The selected range changed before the rewrite completed."; } - if (resolveSelectionText(editor, selection) !== operation.target.sourceText) { + if ( + resolveSelectionText(editor, selection) !== + operation.target.sourceText + ) { return "The selected text changed before the rewrite completed."; } return null; @@ -7268,7 +7875,7 @@ function resolveRequestedOperationConflict( if ( operation.provenance?.blockRevision != null && editor.getBlockRevision(operation.target.blockId) !== - operation.provenance.blockRevision + operation.provenance.blockRevision ) { return "The target block changed before the operation completed."; } @@ -7276,14 +7883,18 @@ function resolveRequestedOperationConflict( } if ( operation.provenance?.syncedGeneration != null && - editor.documentState.generation !== operation.provenance.syncedGeneration + editor.documentState.generation !== + operation.provenance.syncedGeneration ) { return "The document changed before the operation completed."; } return null; } -function resolveContinueInsertionOffset(editor: Editor, blockId: string): number { +function resolveContinueInsertionOffset( + editor: Editor, + blockId: string, +): number { const selection = editor.selection; if ( selection?.type === "text" && @@ -7315,10 +7926,14 @@ function resolveSessionSelectionTarget( return null; } const activeTurnSelection = session.activeTurnId - ? session.turns.find((turn) => turn.id === session.activeTurnId)?.selection + ? session.turns.find((turn) => turn.id === session.activeTurnId) + ?.selection : session.turns[session.turns.length - 1]?.selection; if (activeTurnSelection) { - const restoredSelection = recreateTextSelection(editor, activeTurnSelection); + const restoredSelection = recreateTextSelection( + editor, + activeTurnSelection, + ); if (!restoredSelection.isCollapsed) { return restoredSelection; } @@ -7337,12 +7952,18 @@ function resolveSessionSelectionTarget( return selection; } if (anchorSelection) { - const restoredSelection = recreateTextSelection(editor, anchorSelection); + const restoredSelection = recreateTextSelection( + editor, + anchorSelection, + ); if (!restoredSelection.isCollapsed) { return restoredSelection; } } - if (session.target.kind === "selection" && !session.target.selection.isCollapsed) { + if ( + session.target.kind === "selection" && + !session.target.selection.isCollapsed + ) { return session.target.selection; } return null; @@ -7374,7 +7995,8 @@ function resolvePendingInlineSelectionTarget( const textSuggestions = readAllSuggestions(editor).filter( (suggestion): suggestion is PersistentTextSuggestion => suggestion.kind === "text" && - (suggestion.action === "insert" || suggestion.action === "delete") && + (suggestion.action === "insert" || + suggestion.action === "delete") && suggestionIds.includes(suggestion.id), ); if (textSuggestions.length === 0) { @@ -7452,7 +8074,9 @@ function resolveAcceptedInlineSelectionTarget( } function shouldCloseInlineSessionPrompt(session: AISession): boolean { - return session.surface === "inline-edit" && session.contextualPrompt != null; + return ( + session.surface === "inline-edit" && session.contextualPrompt != null + ); } function closeInlineSessionPrompt( @@ -7524,7 +8148,9 @@ function selectionMatchesSnapshot( selection.focus.offset === snapshot.focus.offset && selection.isMultiBlock === snapshot.isMultiBlock && selection.blockRange.length === snapshot.blockRange.length && - selection.blockRange.every((blockId, index) => blockId === snapshot.blockRange[index]) + selection.blockRange.every( + (blockId, index) => blockId === snapshot.blockRange[index], + ) ); } @@ -7534,8 +8160,9 @@ function resolveSessionSelectionSnapshots( const snapshots: AISessionSelectionSnapshot[] = []; const activeTurn = session.activeTurnId != null - ? session.turns.find((turn) => turn.id === session.activeTurnId) ?? null - : session.turns[session.turns.length - 1] ?? null; + ? (session.turns.find((turn) => turn.id === session.activeTurnId) ?? + null) + : (session.turns[session.turns.length - 1] ?? null); if (activeTurn?.selection) { snapshots.push(activeTurn.selection); } @@ -7543,7 +8170,9 @@ function resolveSessionSelectionSnapshots( snapshots.push(session.contextualPrompt.anchor.selectionSnapshot); } if (session.target.kind === "selection") { - snapshots.push(resolveSessionSelectionSnapshot(session.target.selection)); + snapshots.push( + resolveSessionSelectionSnapshot(session.target.selection), + ); } return snapshots; } @@ -7594,7 +8223,7 @@ function resolveBlockInsertionOffset(editor: Editor, blockId: string): number { const fallbackOffset = block && isVisuallyEmptyInlineText(block.textContent()) ? 0 - : block?.textContent().length ?? 0; + : (block?.textContent().length ?? 0); if (selection?.type !== "text") { return fallbackOffset; } @@ -7616,7 +8245,10 @@ function resolveBlockInsertionOffset(editor: Editor, blockId: string): number { return fallbackOffset; } -function appendUniqueString(values: readonly string[], value: string): string[] { +function appendUniqueString( + values: readonly string[], + value: string, +): string[] { return values.includes(value) ? [...values] : [...values, value]; } @@ -7656,7 +8288,7 @@ function areSuggestionsEqual( previousSuggestion.kind === "block" && nextSuggestion.kind === "block" && JSON.stringify(previousSuggestion.previousState) !== - JSON.stringify(nextSuggestion.previousState) + JSON.stringify(nextSuggestion.previousState) ) { return false; } @@ -7679,7 +8311,9 @@ function areAIControllerStatesEqual( return false; } - if (!areGenerationsEqual(previous.activeGeneration, next.activeGeneration)) { + if ( + !areGenerationsEqual(previous.activeGeneration, next.activeGeneration) + ) { return false; } @@ -7724,7 +8358,10 @@ function areGenerationsEqual( previous.mutationMode !== next.mutationMode || previous.planState !== next.planState || previous.targetKind !== next.targetKind || - !areStructuredValuesEqual(previous.structuredPreview, next.structuredPreview) || + !areStructuredValuesEqual( + previous.structuredPreview, + next.structuredPreview, + ) || !areStructuredValuesEqual(previous.reviewItems, next.reviewItems) || !areStructuredValuesEqual(previous.plan, next.plan) || !areStructuredValuesEqual(previous.debug, next.debug) @@ -7778,14 +8415,26 @@ function areSessionsEqual( previousSession.createdAt !== nextSession.createdAt || previousSession.updatedAt !== nextSession.updatedAt || previousSession.activeTurnId !== nextSession.activeTurnId || - !areStructuredValuesEqual(previousSession.target, nextSession.target) || - !areStructuredValuesEqual(previousSession.anchor, nextSession.anchor) || + !areStructuredValuesEqual( + previousSession.target, + nextSession.target, + ) || + !areStructuredValuesEqual( + previousSession.anchor, + nextSession.anchor, + ) || !areStructuredValuesEqual( previousSession.contextualPrompt, nextSession.contextualPrompt, ) || - !areStructuredValuesEqual(previousSession.turns, nextSession.turns) || - !areStructuredValuesEqual(previousSession.promptHistory, nextSession.promptHistory) || + !areStructuredValuesEqual( + previousSession.turns, + nextSession.turns, + ) || + !areStructuredValuesEqual( + previousSession.promptHistory, + nextSession.promptHistory, + ) || !areStringArraysEqual( previousSession.generationIds, nextSession.generationIds, @@ -7798,7 +8447,10 @@ function areSessionsEqual( previousSession.pendingReviewItemIds, nextSession.pendingReviewItemIds, ) || - !areStructuredValuesEqual(previousSession.metrics, nextSession.metrics) + !areStructuredValuesEqual( + previousSession.metrics, + nextSession.metrics, + ) ) { return false; } @@ -7828,9 +8480,7 @@ function didInlineHistoryCheckpointChange( ); } -function buildInlineHistoryCheckpoint( - state: AIControllerState, -): { +function buildInlineHistoryCheckpoint(state: AIControllerState): { activeSessionId: string | null; sessions: Array<{ id: string; @@ -7853,21 +8503,24 @@ function buildInlineHistoryCheckpoint( const settledTurns = session.turns.filter( (turn) => turn.status !== "streaming", ); - const latestSettledTurn = settledTurns[settledTurns.length - 1] ?? null; + const latestSettledTurn = + settledTurns[settledTurns.length - 1] ?? null; return { id: session.id, isOpen: session.contextualPrompt?.composer.isOpen ?? false, target: session.contextualPrompt?.anchor.selectionSnapshot ?? (session.target.kind === "selection" - ? resolveSessionSelectionSnapshot(session.target.selection) + ? resolveSessionSelectionSnapshot( + session.target.selection, + ) : null), latestSettledTurn: latestSettledTurn ? { - id: latestSettledTurn.id, - prompt: latestSettledTurn.prompt, - selection: latestSettledTurn.selection ?? null, - } + id: latestSettledTurn.id, + prompt: latestSettledTurn.prompt, + selection: latestSettledTurn.selection ?? null, + } : null, settledTurnCount: settledTurns.length, }; @@ -7886,13 +8539,16 @@ function countSettledInlineTurns( if (!session) { return 0; } - return session.turns.filter((turn) => turn.status !== "streaming").length; + return session.turns.filter((turn) => turn.status !== "streaming") + .length; } return snapshot.sessions .filter((session) => session.surface === "inline-edit") .reduce( (count, session) => - count + session.turns.filter((turn) => turn.status !== "streaming").length, + count + + session.turns.filter((turn) => turn.status !== "streaming") + .length, 0, ); } @@ -7905,7 +8561,9 @@ function hasStreamingInlineTurns( const session = snapshot.sessions.find( (item) => item.id === sessionId && item.surface === "inline-edit", ); - return session?.turns.some((turn) => turn.status === "streaming") ?? false; + return ( + session?.turns.some((turn) => turn.status === "streaming") ?? false + ); } return snapshot.sessions .filter((session) => session.surface === "inline-edit") @@ -7919,9 +8577,10 @@ function resolveInlineShortcutHistoryState( sessionId: string | null, ): AIInlineShortcutHistoryState | null { const session = sessionId - ? snapshot.sessions.find( - (item) => item.id === sessionId && item.surface === "inline-edit", - ) ?? null + ? (snapshot.sessions.find( + (item) => + item.id === sessionId && item.surface === "inline-edit", + ) ?? null) : null; if (!session) { return { @@ -7954,10 +8613,7 @@ function resolveInlineShortcutHistoryState( turnId: latestTurn.id, }; } - if ( - latestTurn.status === "accepted" || - latestTurn.status === "rejected" - ) { + if (latestTurn.status === "accepted" || latestTurn.status === "rejected") { return { sessionId, phase: "resolved", @@ -7991,27 +8647,33 @@ function shouldReplaceInlineShortcutWaypointRepresentative( return true; } const currentSession = state.sessionId - ? currentSnapshot.sessions.find( - (session) => - session.id === state.sessionId && session.surface === "inline-edit", - ) ?? null + ? (currentSnapshot.sessions.find( + (session) => + session.id === state.sessionId && + session.surface === "inline-edit", + ) ?? null) : null; const nextSession = state.sessionId - ? nextSnapshot.sessions.find( - (session) => - session.id === state.sessionId && session.surface === "inline-edit", - ) ?? null + ? (nextSnapshot.sessions.find( + (session) => + session.id === state.sessionId && + session.surface === "inline-edit", + ) ?? null) : null; if (state.phase === "review") { - const currentOpen = currentSession?.contextualPrompt?.composer.isOpen === true; - const nextOpen = nextSession?.contextualPrompt?.composer.isOpen === true; + const currentOpen = + currentSession?.contextualPrompt?.composer.isOpen === true; + const nextOpen = + nextSession?.contextualPrompt?.composer.isOpen === true; if (currentOpen !== nextOpen) { return nextOpen; } } if (state.phase === "resolved") { - const currentOpen = currentSession?.contextualPrompt?.composer.isOpen === true; - const nextOpen = nextSession?.contextualPrompt?.composer.isOpen === true; + const currentOpen = + currentSession?.contextualPrompt?.composer.isOpen === true; + const nextOpen = + nextSession?.contextualPrompt?.composer.isOpen === true; if (currentOpen !== nextOpen) { return !nextOpen; } @@ -8064,10 +8726,7 @@ function areStringArraysEqual( return true; } -function areStructuredValuesEqual( - previous: unknown, - next: unknown, -): boolean { +function areStructuredValuesEqual(previous: unknown, next: unknown): boolean { if (previous === next) { return true; } @@ -8167,7 +8826,10 @@ function sliceInlineDeltasFromOffset( deltas: readonly { insert: string; attributes?: Record }[], startOffset: number, ): Array<{ insert: string; attributes?: Record }> { - const sliced: Array<{ insert: string; attributes?: Record }> = []; + const sliced: Array<{ + insert: string; + attributes?: Record; + }> = []; let offset = 0; for (const delta of deltas) { const length = delta.insert.length; @@ -8189,7 +8851,10 @@ function sliceInlineDeltasFromOffset( return sliced; } -function resolveSelectionText(editor: Editor, selection: TextSelection): string { +function resolveSelectionText( + editor: Editor, + selection: TextSelection, +): string { const range = selection.toRange(); const blockIds = range.blockRange; const parts = blockIds.map((blockId, index) => { @@ -8200,7 +8865,9 @@ function resolveSelectionText(editor: Editor, selection: TextSelection): string let resolved = ""; const startOffset = index === 0 ? range.start.offset : 0; const endOffset = - index === blockIds.length - 1 ? range.end.offset : Number.POSITIVE_INFINITY; + index === blockIds.length - 1 + ? range.end.offset + : Number.POSITIVE_INFINITY; for (const delta of block.textDeltas()) { const length = delta.insert.length; diff --git a/packages/extensions/ai/src/suggestions/suggestMode.ts b/packages/extensions/ai/src/suggestions/suggestMode.ts index c20d0a8..e8ab384 100644 --- a/packages/extensions/ai/src/suggestions/suggestMode.ts +++ b/packages/extensions/ai/src/suggestions/suggestMode.ts @@ -1,4 +1,5 @@ -import type { DocumentOp, Editor } from "@pen/types"; +import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; +import { getOpOriginType } from "@pen/types"; import { createSuggestionMark } from "./persistent"; import type { BlockSuggestionMeta } from "../types"; @@ -15,8 +16,8 @@ const BYPASS_ORIGINS = new Set([ SUGGESTION_RESOLUTION_ORIGIN, ]); -export function shouldBypassSuggestMode(origin?: string): boolean { - return origin != null && BYPASS_ORIGINS.has(origin); +export function shouldBypassSuggestMode(origin?: OpOrigin): boolean { + return origin != null && BYPASS_ORIGINS.has(getOpOriginType(origin)); } export function interceptApplyForSuggestMode( @@ -152,7 +153,10 @@ export function interceptApplyForSuggestMode( model, { position: layoutParent - ? { parent: layoutParent.id, index: block?.index ?? 0 } + ? { + parent: layoutParent.id, + index: block?.index ?? 0, + } : block?.prev ? { after: block.prev.id } : "first", diff --git a/packages/extensions/export-json/README.md b/packages/extensions/export-json/README.md index 8f6302b..6976c83 100644 --- a/packages/extensions/export-json/README.md +++ b/packages/extensions/export-json/README.md @@ -11,7 +11,27 @@ pnpm add @pen/export-json ## What It Provides - `jsonExporter` for machine-readable document export +- `textExporter` and `exportPlainText()` for plain text export - `jsonImporter` for importing Pen JSON documents - shared JSON document types for integration code Use this package for persistence, interchange, and deterministic round-tripping of supported Pen document content. + +## Plain Text + +```ts +import { + exportEditorToText, + exportPenDocumentToText, + exportPlainText, +} from "@pen/export-json"; + +const textFromEditor = exportEditorToText(editor); +const plainText = exportPlainText(editor); +const textFromJson = exportPenDocumentToText(documentJson, { + excludeBlockTypes: ["quote"], + separator: " ", +}); +``` + +Hosts can filter block types, render app-specific inline nodes, and extract database block text while keeping product delivery policy outside Pen. diff --git a/packages/extensions/export-json/src/__tests__/textExporter.test.ts b/packages/extensions/export-json/src/__tests__/textExporter.test.ts new file mode 100644 index 0000000..87a0200 --- /dev/null +++ b/packages/extensions/export-json/src/__tests__/textExporter.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { exportPenDocumentToText } from "../textExporter"; +import type { PenDocumentJSON } from "../types"; + +describe("exportPenDocumentToText", () => { + it("exports nested block text in document order", () => { + const document: PenDocumentJSON = { + version: 1, + blocks: [ + { + id: "one", + type: "paragraph", + props: {}, + content: { text: "One" }, + children: [ + { + id: "child", + type: "paragraph", + props: {}, + content: { text: "Child" }, + }, + ], + }, + { + id: "two", + type: "paragraph", + props: {}, + content: { text: "Two" }, + }, + ], + }; + + expect(exportPenDocumentToText(document)).toBe("One\nChild\nTwo"); + }); + + it("supports host-owned block exclusion and inline node rendering", () => { + const document: PenDocumentJSON = { + version: 1, + blocks: [ + { + id: "body", + type: "paragraph", + props: {}, + content: { + text: "Hello ", + segments: [ + { type: "text", text: "Hello " }, + { + type: "node", + nodeType: "mention", + props: { label: "Ada" }, + }, + ], + }, + }, + { + id: "quote", + type: "emailQuote", + props: {}, + content: { text: "Quoted" }, + }, + ], + }; + + expect( + exportPenDocumentToText(document, { + excludeBlockTypes: ["emailQuote"], + renderInlineNode(segment) { + const label = segment.props?.label; + return segment.nodeType === "mention" && + typeof label === "string" + ? `@${label}` + : ""; + }, + }), + ).toBe("Hello @Ada"); + }); + + it("exports database block text in stable column order", () => { + const document: PenDocumentJSON = { + version: 1, + blocks: [ + { + id: "db", + type: "database", + props: {}, + database: { + title: "Tasks", + columns: [ + { id: "name", type: "text", title: "Name" }, + { id: "owner", type: "text", title: "Owner" }, + ], + rows: [ + { + id: "one", + values: { name: "Plan", owner: "Ada" }, + }, + { + id: "two", + values: { name: "Ship", owner: "Grace" }, + }, + ], + }, + }, + ], + }; + + expect(exportPenDocumentToText(document)).toBe( + "Tasks\nPlan\tAda\nShip\tGrace", + ); + }); +}); diff --git a/packages/extensions/export-json/src/index.ts b/packages/extensions/export-json/src/index.ts index 1dbc423..b799fb6 100644 --- a/packages/extensions/export-json/src/index.ts +++ b/packages/extensions/export-json/src/index.ts @@ -1,17 +1,24 @@ export { jsonExporter, exportEditorToJson } from "./exporter"; +export { + exportEditorToText, + exportPenDocumentToText, + exportPlainText, + textExporter, +} from "./textExporter"; export { jsonImporter, parseJsonDocument } from "./importer"; export { - PEN_DOCUMENT_JSON_VERSION, - isSupportedPenDocumentVersion, + PEN_DOCUMENT_JSON_VERSION, + isSupportedPenDocumentVersion, } from "./schema"; export type { - PenBlockJSON, - PenDatabaseJSON, - PenDocumentJSON, - PenInlineContentJSON, - PenInlineNodeSegmentJSON, - PenInlineSegmentJSON, - PenInlineTextSegmentJSON, - PenJsonExportExtraOptions, - PenMarkJSON, + PenBlockJSON, + PenDatabaseJSON, + PenDocumentJSON, + PenInlineContentJSON, + PenInlineNodeSegmentJSON, + PenInlineSegmentJSON, + PenInlineTextSegmentJSON, + PenJsonExportExtraOptions, + PenMarkJSON, } from "./types"; +export type { PenTextExportExtraOptions } from "./textExporter"; diff --git a/packages/extensions/export-json/src/textExporter.ts b/packages/extensions/export-json/src/textExporter.ts new file mode 100644 index 0000000..bc77ca7 --- /dev/null +++ b/packages/extensions/export-json/src/textExporter.ts @@ -0,0 +1,124 @@ +import type { Editor, Exporter, ExportOptions } from "@pen/types"; +import { exportEditorToJson } from "./exporter"; +import type { + PenBlockJSON, + PenDocumentJSON, + PenInlineNodeSegmentJSON, + PenInlineSegmentJSON, +} from "./types"; + +const ZERO_WIDTH_SPACE = "\u200B"; +const DEFAULT_SEPARATOR = "\n"; + +export type PenTextExportExtraOptions = Record & { + excludeBlockTypes?: string[]; + includeBlockTypes?: string[]; + separator?: string; + renderInlineNode?: (segment: PenInlineNodeSegmentJSON) => string; +}; + +export const textExporter: Exporter = { + name: "text", + mimeType: "text/plain", + fileExtension: ".txt", + + export( + editor: Editor, + options?: ExportOptions, + ): string { + return exportEditorToText(editor, options); + }, +}; + +export function exportEditorToText( + editor: Editor, + options?: ExportOptions, +): string { + return exportPenDocumentToText(exportEditorToJson(editor), options?.extra); +} + +export function exportPlainText( + editor: Editor, + options?: ExportOptions, +): string { + return exportEditorToText(editor, options); +} + +export function exportPenDocumentToText( + document: PenDocumentJSON, + options: PenTextExportExtraOptions = {}, +): string { + const separator = options.separator ?? DEFAULT_SEPARATOR; + return document.blocks + .flatMap((block) => renderBlockText(block, options)) + .join(separator); +} + +function renderBlockText( + block: PenBlockJSON, + options: PenTextExportExtraOptions, +): string[] { + if (options.excludeBlockTypes?.includes(block.type)) { + return []; + } + if ( + options.includeBlockTypes && + !options.includeBlockTypes.includes(block.type) + ) { + return []; + } + + const ownText = renderInlineContentText(block, options); + const databaseTexts = renderDatabaseText(block); + const childTexts = + block.children?.flatMap((child) => renderBlockText(child, options)) ?? + []; + + return [ownText, ...databaseTexts, ...childTexts].filter( + (text) => text.length > 0, + ); +} + +function renderInlineContentText( + block: PenBlockJSON, + options: PenTextExportExtraOptions, +): string { + if (block.content?.segments?.length) { + return block.content.segments + .map((segment) => renderInlineSegmentText(segment, options)) + .join("") + .replaceAll(ZERO_WIDTH_SPACE, ""); + } + + return (block.content?.text ?? "").replaceAll(ZERO_WIDTH_SPACE, ""); +} + +function renderInlineSegmentText( + segment: PenInlineSegmentJSON, + options: PenTextExportExtraOptions, +): string { + if (segment.type === "text") { + return segment.text.replaceAll(ZERO_WIDTH_SPACE, ""); + } + + return options.renderInlineNode?.(segment) ?? ""; +} + +function renderDatabaseText(block: PenBlockJSON): string[] { + if (!block.database) { + return []; + } + + const columnIds = block.database.columns.map((column) => column.id); + const title = block.database.title?.trim(); + const rows = block.database.rows + .map((row) => + columnIds + .map((columnId) => row.values[columnId]) + .filter((value): value is string => Boolean(value?.trim())) + .join("\t"), + ) + .filter((rowText) => rowText.length > 0); + + return [title, ...rows].filter((text): text is string => Boolean(text)); +} diff --git a/packages/tooling/test/README.md b/packages/tooling/test/README.md index dbf6a5f..866dd29 100644 --- a/packages/tooling/test/README.md +++ b/packages/tooling/test/README.md @@ -13,6 +13,8 @@ pnpm add -D @pen/test - `createTestEditor()` for a Yjs-backed editor harness with the default schema - `assertDocEquals()` for document-shape assertions - `createTestCollaboration()` for two-editor sync tests +- `createDeterministicYDocFixture()` for stable Yjs updates and snapshots +- `runCRDTStateVectorContract()`, `runHeadlessEditorContract()`, and `runExportContract()` for opt-in package/app contracts - `simulateTyping()` and `simulateKeypress()` helpers for editor interactions ## Minimal Setup @@ -44,8 +46,26 @@ collab.sync(); assertDocEquals(collab.editorA, collab.editorB); ``` +## Deterministic Fixtures + +```ts +import { + createDeterministicYDocFixture, + runCRDTStateVectorContract, +} from "@pen/test"; + +const fixture = createDeterministicYDocFixture({ + blocks: [{ id: "p1", type: "paragraph", content: "Stable text" }], +}); + +expect(fixture.updateBase64).toMatchSnapshot(); +runCRDTStateVectorContract({ createFixture: () => fixture }); +``` + ## Integration Notes - The test harness defaults to Pen's shipped schema and a Yjs-backed document. - Override `schema`, `doc`, or other editor options when a test needs a custom runtime setup. +- Fixture helpers use generic Pen document roots and avoid product-specific fixture data. +- Contract helpers throw ordinary errors and do not require a specific test runner. - These utilities are intended for package and app tests, not production editor bootstrapping. diff --git a/packages/tooling/test/package.json b/packages/tooling/test/package.json index 2196b1e..43cd944 100644 --- a/packages/tooling/test/package.json +++ b/packages/tooling/test/package.json @@ -46,6 +46,7 @@ "dependencies": { "@pen/core": "workspace:*", "@pen/crdt-yjs": "workspace:*", + "@pen/export-json": "workspace:*", "@pen/schema-default": "workspace:*", "@pen/types": "workspace:*", "yjs": "^13.6.29" diff --git a/packages/tooling/test/src/__tests__/fixtures.test.ts b/packages/tooling/test/src/__tests__/fixtures.test.ts new file mode 100644 index 0000000..caf9250 --- /dev/null +++ b/packages/tooling/test/src/__tests__/fixtures.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { + assertDocumentRoots, + createDeterministicYDocFixture, + encodeFixtureUpdate, + normalizeDocumentForSnapshot, + runCRDTStateVectorContract, + runExportContract, + runHeadlessEditorContract, +} from "../index"; + +describe("deterministic fixture helpers", () => { + it("generates stable updates and normalized snapshots", () => { + const first = createDeterministicYDocFixture(); + const second = createDeterministicYDocFixture(); + + expect(first.updateBase64).toBe(second.updateBase64); + expect(first.stateVectorBase64).toBe(second.stateVectorBase64); + expect(first.snapshot).toEqual(second.snapshot); + expect(encodeFixtureUpdate(first.ydoc)).toBe(first.updateBase64); + }); + + it("normalizes map keys for snapshots", () => { + const ydoc = new Y.Doc(); + const metadata = ydoc.getMap("metadata"); + metadata.set("z", 1); + metadata.set("a", 2); + + expect( + Object.keys( + normalizeDocumentForSnapshot(ydoc, [ + { name: "metadata", type: "map" }, + ]).roots.metadata as Record, + ), + ).toEqual(["a", "z"]); + }); + + it("throws useful diagnostics for invalid fixture roots", () => { + const ydoc = new Y.Doc(); + ydoc.getMap("metadata"); + + expect(() => + assertDocumentRoots(ydoc, [{ name: "metadata", type: "array" }]), + ).toThrow('root "metadata" must be array'); + }); +}); + +describe("contract helpers", () => { + it("runs the CRDT state-vector contract", () => { + expect(runCRDTStateVectorContract()).toMatchObject({ + emptySatisfied: false, + syncedSatisfied: true, + }); + }); + + it("runs the headless editor contract", () => { + expect(runHeadlessEditorContract().blockCount).toBeGreaterThan(0); + }); + + it("runs the export contract", () => { + expect(runExportContract()).toMatchObject({ + text: "Deterministic fixture\nStable body text", + }); + }); +}); diff --git a/packages/tooling/test/src/contracts.ts b/packages/tooling/test/src/contracts.ts new file mode 100644 index 0000000..0495a0f --- /dev/null +++ b/packages/tooling/test/src/contracts.ts @@ -0,0 +1,140 @@ +import * as Y from "yjs"; +import { createHeadlessEditor } from "@pen/core"; +import { yjsAdapter } from "@pen/crdt-yjs"; +import { + compareYjsStateVectors, + encodeYjsStateVector, + wrapYjsDocument, +} from "@pen/crdt-yjs"; +import { exportEditorToJson, exportEditorToText } from "@pen/export-json"; +import type { Editor, SchemaRegistry } from "@pen/types"; +import type { + DeterministicYDocFixture, + DeterministicYDocFixtureOptions, +} from "./types"; +import { createDeterministicYDocFixture, PenFixtureError } from "./fixtures"; + +export interface CRDTStateVectorContractResult { + fixture: DeterministicYDocFixture; + emptySatisfied: boolean; + syncedSatisfied: boolean; +} + +export interface CRDTStateVectorContractOptions extends DeterministicYDocFixtureOptions { + createFixture?: () => DeterministicYDocFixture; +} + +export interface HeadlessEditorContractResult { + fixture: DeterministicYDocFixture; + editor: Editor; + blockCount: number; +} + +export interface HeadlessEditorContractOptions extends DeterministicYDocFixtureOptions { + createFixture?: () => DeterministicYDocFixture; + schema?: SchemaRegistry; +} + +export interface ExportContractResult extends HeadlessEditorContractResult { + json: ReturnType; + text: string; +} + +export interface ExportContractOptions extends HeadlessEditorContractOptions { + allowEmptyText?: boolean; +} + +export function runCRDTStateVectorContract( + options: CRDTStateVectorContractOptions = {}, +): CRDTStateVectorContractResult { + const fixture = createContractFixture(options); + const empty = new Y.Doc({ gc: false }); + const synced = new Y.Doc({ gc: false }); + Y.applyUpdate(synced, fixture.update); + + const requiredStateVector = encodeYjsStateVector(fixture.ydoc); + const emptyComparison = compareYjsStateVectors( + encodeYjsStateVector(empty), + requiredStateVector, + ); + const syncedComparison = compareYjsStateVectors( + encodeYjsStateVector(synced), + requiredStateVector, + ); + + if (emptyComparison.satisfied) { + throw new PenFixtureError( + "CRDT state-vector contract failed: empty document satisfied a populated fixture.", + ); + } + if (!syncedComparison.satisfied) { + throw new PenFixtureError( + "CRDT state-vector contract failed: synced document did not satisfy the fixture state vector.", + ); + } + + return { + fixture, + emptySatisfied: emptyComparison.satisfied, + syncedSatisfied: syncedComparison.satisfied, + }; +} + +export function runHeadlessEditorContract( + options: HeadlessEditorContractOptions = {}, +): HeadlessEditorContractResult { + const fixture = createContractFixture(options); + const adapter = yjsAdapter(); + const editor = createHeadlessEditor({ + crdt: adapter, + document: wrapYjsDocument(adapter, fixture.ydoc), + schema: options.schema, + }); + const blockCount = [...editor.documentState.allBlocks()].length; + + if (blockCount === 0) { + throw new PenFixtureError( + "Headless editor contract failed: fixture document has no blocks.", + ); + } + + return { fixture, editor, blockCount }; +} + +export function runExportContract( + options: ExportContractOptions = {}, +): ExportContractResult { + const headless = runHeadlessEditorContract(options); + const json = exportEditorToJson(headless.editor); + const text = exportEditorToText(headless.editor); + + if (json.blocks.length === 0) { + throw new PenFixtureError( + "Export contract failed: JSON export has no blocks.", + ); + } + if (!options.allowEmptyText && text.trim().length === 0) { + throw new PenFixtureError( + "Export contract failed: text export is empty.", + ); + } + + return { ...headless, json, text }; +} + +function createContractFixture( + options: + | CRDTStateVectorContractOptions + | HeadlessEditorContractOptions + | ExportContractOptions, +): DeterministicYDocFixture { + return ( + options.createFixture?.() ?? + createDeterministicYDocFixture({ + blocks: options.blocks, + clientId: options.clientId, + roots: options.roots, + mutate: options.mutate, + }) + ); +} diff --git a/packages/tooling/test/src/fixtures.ts b/packages/tooling/test/src/fixtures.ts new file mode 100644 index 0000000..3ce273d --- /dev/null +++ b/packages/tooling/test/src/fixtures.ts @@ -0,0 +1,216 @@ +import * as Y from "yjs"; +import { + encodeYjsStateVector, + encodeYjsStateVectorBase64, + yjsAdapter, + wrapYjsDocument, +} from "@pen/crdt-yjs"; +import type { CRDTDocument, PenDocument } from "@pen/types"; +import { populateYDoc } from "./createTestDocument"; +import { resetTestIdCounter } from "./helpers"; +import type { + DeterministicYDocFixture, + DeterministicYDocFixtureOptions, + NormalizedYDocSnapshot, + NormalizedYjsValue, + YjsRootExpectation, +} from "./types"; + +const DEFAULT_CLIENT_ID = 1; +const DEFAULT_FIXTURE_BLOCKS = [ + { + id: "fixture-title", + type: "heading", + props: { level: 2 }, + content: "Deterministic fixture", + }, + { + id: "fixture-body", + type: "paragraph", + content: "Stable body text", + }, +]; + +export const DEFAULT_PEN_ROOTS = [ + { name: "blockOrder", type: "array" }, + { name: "blocks", type: "map" }, + { name: "apps", type: "map" }, + { name: "metadata", type: "map" }, +] satisfies YjsRootExpectation[]; + +export class PenFixtureError extends Error { + constructor(message: string) { + super(message); + this.name = "PenFixtureError"; + } +} + +export function createDeterministicYDocFixture( + options: DeterministicYDocFixtureOptions = {}, +): DeterministicYDocFixture { + resetTestIdCounter(); + const ydoc = new Y.Doc({ gc: false }); + setDeterministicClientId(ydoc, options.clientId ?? DEFAULT_CLIENT_ID); + const blocks = options.blocks ?? DEFAULT_FIXTURE_BLOCKS; + populateYDoc(ydoc, blocks); + options.mutate?.(ydoc); + + const roots = options.roots ?? DEFAULT_PEN_ROOTS; + assertDocumentRoots(ydoc, roots); + + const adapter = yjsAdapter(); + const crdtDoc = wrapYjsDocument(adapter, ydoc); + + return { + ydoc, + doc: crdtDoc.penDocument, + crdtDoc, + update: Y.encodeStateAsUpdate(ydoc), + updateBase64: encodeFixtureUpdate(ydoc), + stateVector: encodeYjsStateVector(ydoc), + stateVectorBase64: encodeYjsStateVectorBase64(ydoc), + snapshot: normalizeDocumentForSnapshot(ydoc, roots), + }; +} + +export function encodeFixtureUpdate(ydoc: Y.Doc): string { + return encodeUint8ArrayToBase64(Y.encodeStateAsUpdate(ydoc)); +} + +export function normalizeDocumentForSnapshot( + ydoc: Y.Doc, + roots: readonly YjsRootExpectation[] = DEFAULT_PEN_ROOTS, +): NormalizedYDocSnapshot { + const normalizedRoots: Record = {}; + + for (const root of roots) { + const value = readRoot(ydoc, root); + if (value === undefined) { + continue; + } + normalizedRoots[root.name] = normalizeYjsValue(value); + } + + return { roots: normalizedRoots }; +} + +export function assertDocumentRoots( + ydoc: Y.Doc, + roots: readonly YjsRootExpectation[] = DEFAULT_PEN_ROOTS, +): void { + const failures = roots.flatMap((root) => { + const value = readRoot(ydoc, root); + if (value === undefined) { + return root.optional ? [] : [`missing root "${root.name}"`]; + } + if (root.type && !isExpectedRootType(value, root.type)) { + return [`root "${root.name}" must be ${root.type}`]; + } + return []; + }); + + if (failures.length > 0) { + throw new PenFixtureError( + `Invalid Yjs fixture roots: ${failures.join(", ")}`, + ); + } +} + +function setDeterministicClientId(ydoc: Y.Doc, clientId: number): void { + (ydoc as unknown as { clientID: number }).clientID = clientId; +} + +function readRoot( + ydoc: Y.Doc, + root: YjsRootExpectation, +): Y.AbstractType | undefined { + return ( + ydoc as unknown as { share: Map> } + ).share.get(root.name); +} + +function isExpectedRootType( + value: Y.AbstractType, + type: YjsRootExpectation["type"], +): boolean { + if (type === "array") { + return value instanceof Y.Array; + } + if (type === "text") { + return value instanceof Y.Text; + } + return value instanceof Y.Map; +} + +function normalizeYjsValue(value: unknown): NormalizedYjsValue { + if (value instanceof Y.Map) { + return Object.fromEntries( + Array.from(value.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, normalizeYjsValue(child)]), + ); + } + + if (value instanceof Y.Array) { + return value.toArray().map((child) => normalizeYjsValue(child)); + } + + if (value instanceof Y.Text) { + return value.toString(); + } + + if (value === undefined || value === null) { + return null; + } + + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((child) => normalizeYjsValue(child)); + } + + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, normalizeYjsValue(child)]), + ); + } + + return String(value); +} + +function encodeUint8ArrayToBase64(value: Uint8Array): string { + const buffer = (globalThis as Base64Globals).Buffer; + if (buffer) { + return buffer.from(value).toString("base64"); + } + + const btoa = (globalThis as Base64Globals).btoa; + if (!btoa) { + throw new PenFixtureError( + "No base64 encoder is available in this runtime.", + ); + } + + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +type Base64Globals = typeof globalThis & { + Buffer?: { + from(value: Uint8Array): { toString(encoding: "base64"): string }; + }; + btoa?: (value: string) => string; +}; + +export type { CRDTDocument, PenDocument }; diff --git a/packages/tooling/test/src/index.ts b/packages/tooling/test/src/index.ts index 9984cd0..8470fe1 100644 --- a/packages/tooling/test/src/index.ts +++ b/packages/tooling/test/src/index.ts @@ -1,8 +1,14 @@ export type { - TestBlock, - TestEditorOptions, - TestEditor, - TestCollaboration, + TestBlock, + TestEditorOptions, + TestEditor, + TestCollaboration, + DeterministicYDocFixture, + DeterministicYDocFixtureOptions, + NormalizedYDocSnapshot, + NormalizedYjsValue, + YjsRootExpectation, + YjsRootType, } from "./types"; export { createTestDocument, populateYDoc } from "./createTestDocument"; export { createTestEditor } from "./createTestEditor"; @@ -10,3 +16,24 @@ export { assertDocEquals } from "./assertDocEquals"; export { createTestCollaboration } from "./createTestCollaboration"; export { simulateKeypress, simulateTyping } from "./simulation"; export { resetTestIdCounter, toYMap } from "./helpers"; +export { + DEFAULT_PEN_ROOTS, + PenFixtureError, + assertDocumentRoots, + createDeterministicYDocFixture, + encodeFixtureUpdate, + normalizeDocumentForSnapshot, +} from "./fixtures"; +export { + runCRDTStateVectorContract, + runExportContract, + runHeadlessEditorContract, +} from "./contracts"; +export type { + CRDTStateVectorContractOptions, + CRDTStateVectorContractResult, + ExportContractOptions, + ExportContractResult, + HeadlessEditorContractOptions, + HeadlessEditorContractResult, +} from "./contracts"; diff --git a/packages/tooling/test/src/types.ts b/packages/tooling/test/src/types.ts index 29b0eb8..e749ce8 100644 --- a/packages/tooling/test/src/types.ts +++ b/packages/tooling/test/src/types.ts @@ -1,41 +1,79 @@ import type { - CreateEditorOptions, - CRDTDocument, - Editor, - PenDocument, - SchemaRegistry, - BlockHandle, + CreateEditorOptions, + CRDTDocument, + Editor, + PenDocument, + SchemaRegistry, + BlockHandle, } from "@pen/types"; import type * as Y from "yjs"; export interface TestBlock { - id?: string; - type: string; - props?: Record; - content?: string; - children?: TestBlock[]; + id?: string; + type: string; + props?: Record; + content?: string; + children?: TestBlock[]; } export interface TestEditorOptions extends Partial { - blocks?: TestBlock[]; - doc?: Y.Doc; + blocks?: TestBlock[]; + doc?: Y.Doc; } export interface TestEditor extends Editor { - readonly document: PenDocument; - readonly ydoc: Y.Doc; - readonly crdtDoc: CRDTDocument; + readonly document: PenDocument; + readonly ydoc: Y.Doc; + readonly crdtDoc: CRDTDocument; - getBlock(blockId: string): BlockHandle; - simulateKeypress(key: string): void; - simulateTyping(text: string): void; - normalizeAll(): void; - markDirty(blockId: string): void; - normalizeDirty(): void; + getBlock(blockId: string): BlockHandle; + simulateKeypress(key: string): void; + simulateTyping(text: string): void; + normalizeAll(): void; + markDirty(blockId: string): void; + normalizeDirty(): void; } export interface TestCollaboration { - editorA: TestEditor; - editorB: TestEditor; - sync(): void; + editorA: TestEditor; + editorB: TestEditor; + sync(): void; +} + +export type NormalizedYjsValue = + | null + | boolean + | number + | string + | NormalizedYjsValue[] + | { [key: string]: NormalizedYjsValue }; + +export type YjsRootType = "array" | "map" | "text"; + +export interface YjsRootExpectation { + name: string; + type?: YjsRootType; + optional?: boolean; +} + +export interface NormalizedYDocSnapshot { + roots: Record; +} + +export interface DeterministicYDocFixtureOptions { + blocks?: TestBlock[]; + clientId?: number; + roots?: readonly YjsRootExpectation[]; + mutate?: (ydoc: Y.Doc) => void; +} + +export interface DeterministicYDocFixture { + ydoc: Y.Doc; + doc: PenDocument; + crdtDoc: CRDTDocument; + update: Uint8Array; + updateBase64: string; + stateVector: Uint8Array; + stateVectorBase64: string; + snapshot: NormalizedYDocSnapshot; } diff --git a/packages/types/src/types/crdt.ts b/packages/types/src/types/crdt.ts index 8c6890c..cdc60ce 100644 --- a/packages/types/src/types/crdt.ts +++ b/packages/types/src/types/crdt.ts @@ -7,254 +7,242 @@ export type DocumentProfile = "structured" | "flow"; // ── Abstract CRDT Collections ─────────────────────────────── export interface CRDTArray { - readonly length: number; - get(index: number): T; - toArray(): T[]; - [Symbol.iterator](): Iterator; + readonly length: number; + get(index: number): T; + toArray(): T[]; + [Symbol.iterator](): Iterator; } export interface CRDTMap { - get(key: string): T | undefined; - has(key: string): boolean; - entries(): IterableIterator<[string, T]>; - keys(): IterableIterator; - readonly size: number; + get(key: string): T | undefined; + has(key: string): boolean; + entries(): IterableIterator<[string, T]>; + keys(): IterableIterator; + readonly size: number; } // ── CRDT Adapter ──────────────────────────────────────────── export interface CRDTAdapter { - createDocument(): CRDTDocument; - loadDocument(binary: Uint8Array): CRDTDocument; - - encodeState(doc: CRDTDocument): Uint8Array; - encodeUpdate(doc: CRDTDocument, since?: Uint8Array): Uint8Array; - applyUpdate(doc: CRDTDocument, update: Uint8Array): void; - - transact(doc: CRDTDocument, fn: () => void, origin?: string): void; - - createUndoManager( - doc: CRDTDocument, - options?: UndoManagerOptions, - ): CRDTUndoManager; - - createAwareness?(doc: CRDTDocument): Awareness; - - observe( - doc: CRDTDocument, - callback: (event: CRDTEvent) => void, - ): Unsubscribe; - - createSnapshot(doc: CRDTDocument): Uint8Array; - restoreSnapshot(doc: CRDTDocument, snapshot: Uint8Array): CRDTDocument; - - mergeUpdates?(updates: Uint8Array[]): Uint8Array; - - fork?(doc: CRDTDocument): CRDTDocument; - merge?(target: CRDTDocument, source: CRDTDocument): void; - - getClientId(doc: CRDTDocument): number; - - getDocumentProfile?(doc: CRDTDocument): DocumentProfile | null; - setDocumentProfile?(doc: CRDTDocument, profile: DocumentProfile): void; - - raw(doc: CRDTDocument): T; - - // Factory methods - createMap(): unknown; - createArray(): unknown; - createText(): unknown; - initBlockMap( - doc: CRDTDocument, - blockId: string, - blockType: string, - contentType: - | "inline" - | "nested" - | "table" - | "database" - | "subdocument" - | "none", - ): unknown; - - // Attribution (per-character authorship) - getAttributionRanges?( - doc: CRDTDocument, - blockId: string, - ): AttributionRange[]; + createDocument(): CRDTDocument; + loadDocument(binary: Uint8Array): CRDTDocument; + + encodeState(doc: CRDTDocument): Uint8Array; + encodeUpdate(doc: CRDTDocument, since?: Uint8Array): Uint8Array; + applyUpdate(doc: CRDTDocument, update: Uint8Array): void; + + transact(doc: CRDTDocument, fn: () => void, origin?: unknown): void; + + createUndoManager( + doc: CRDTDocument, + options?: UndoManagerOptions, + ): CRDTUndoManager; + + createAwareness?(doc: CRDTDocument): Awareness; + + observe( + doc: CRDTDocument, + callback: (event: CRDTEvent) => void, + ): Unsubscribe; + + createSnapshot(doc: CRDTDocument): Uint8Array; + restoreSnapshot(doc: CRDTDocument, snapshot: Uint8Array): CRDTDocument; + + mergeUpdates?(updates: Uint8Array[]): Uint8Array; + + fork?(doc: CRDTDocument): CRDTDocument; + merge?(target: CRDTDocument, source: CRDTDocument): void; + + getClientId(doc: CRDTDocument): number; + + getDocumentProfile?(doc: CRDTDocument): DocumentProfile | null; + setDocumentProfile?(doc: CRDTDocument, profile: DocumentProfile): void; + + raw(doc: CRDTDocument): T; + + // Factory methods + createMap(): unknown; + createArray(): unknown; + createText(): unknown; + initBlockMap( + doc: CRDTDocument, + blockId: string, + blockType: string, + contentType: + | "inline" + | "nested" + | "table" + | "database" + | "subdocument" + | "none", + ): unknown; + + // Attribution (per-character authorship) + getAttributionRanges?( + doc: CRDTDocument, + blockId: string, + ): AttributionRange[]; } export interface AttributionRange { - offset: number; - length: number; - clientId: number; + offset: number; + length: number; + clientId: number; } export type DocumentScopeKind = "root" | "subdocument"; export interface DocumentScopeInfo { - id: string; - guid: string; - kind: DocumentScopeKind; - parentId: string | null; - ownerBlockId: string | null; + id: string; + guid: string; + kind: DocumentScopeKind; + parentId: string | null; + ownerBlockId: string | null; } export interface DocumentScope extends DocumentScopeInfo { - readonly doc: CRDTDocument; + readonly doc: CRDTDocument; } export interface CreateSubdocumentOptions { - scopeId?: string; - guid?: string; - autoLoad?: boolean; + scopeId?: string; + guid?: string; + autoLoad?: boolean; } export interface DocumentScopeLookupOptions { - scopeId?: string; + scopeId?: string; } export interface ReplaceScopeDocumentOptions { - destroyReplacedDoc?: boolean; + destroyReplacedDoc?: boolean; } export interface DocumentScopeReplacementEvent { - previousScope: DocumentScopeInfo; - scope: DocumentScope; + previousScope: DocumentScopeInfo; + scope: DocumentScope; } export interface DocumentSessionAttachOptions { - onScopeReplaced?: (event: DocumentScopeReplacementEvent) => void; + onScopeReplaced?: (event: DocumentScopeReplacementEvent) => void; } export interface DocumentSession { - readonly adapter: CRDTAdapter; - readonly rootScope: DocumentScope; - - getScope(scopeId: string): DocumentScope | null; - getScopeByGuid(guid: string): DocumentScope | null; - getScopeForBlock( - blockId: string, - options?: DocumentScopeLookupOptions, - ): DocumentScope | null; - listScopes(): readonly DocumentScope[]; - - getAwareness(scopeId?: string): Awareness | null; - - observe( - scopeId: string, - callback: (event: CRDTEvent) => void, - ): Unsubscribe; - observeAll(callback: (event: CRDTEvent) => void): Unsubscribe; - - createSubdocument( - blockId: string, - options?: CreateSubdocumentOptions, - ): DocumentScope | null; - loadSubdocument(scopeId: string): void; - replaceScopeDocument( - scopeId: string, - doc: CRDTDocument, - options?: ReplaceScopeDocumentOptions, - ): void; - attachEditor(options?: DocumentSessionAttachOptions): Unsubscribe; - - destroy(): void; + readonly adapter: CRDTAdapter; + readonly rootScope: DocumentScope; + + getScope(scopeId: string): DocumentScope | null; + getScopeByGuid(guid: string): DocumentScope | null; + getScopeForBlock( + blockId: string, + options?: DocumentScopeLookupOptions, + ): DocumentScope | null; + listScopes(): readonly DocumentScope[]; + + getAwareness(scopeId?: string): Awareness | null; + + observe(scopeId: string, callback: (event: CRDTEvent) => void): Unsubscribe; + observeAll(callback: (event: CRDTEvent) => void): Unsubscribe; + + createSubdocument( + blockId: string, + options?: CreateSubdocumentOptions, + ): DocumentScope | null; + loadSubdocument(scopeId: string): void; + replaceScopeDocument( + scopeId: string, + doc: CRDTDocument, + options?: ReplaceScopeDocumentOptions, + ): void; + attachEditor(options?: DocumentSessionAttachOptions): Unsubscribe; + + destroy(): void; } // ── CRDT Document ─────────────────────────────────────────── export interface CRDTDocument { - readonly adapter: CRDTAdapter; + readonly adapter: CRDTAdapter; } export interface PenDocument { - readonly blockOrder: CRDTArray; - readonly blocks: CRDTMap; - readonly apps: CRDTMap; - readonly metadata: CRDTMap; - readonly adapter: CRDTAdapter; + readonly blockOrder: CRDTArray; + readonly blocks: CRDTMap; + readonly apps: CRDTMap; + readonly metadata: CRDTMap; + readonly adapter: CRDTAdapter; } // ── Undo Manager ──────────────────────────────────────────── export interface UndoManagerOptions { - trackedOrigins?: OpOrigin[]; - captureTimeout?: number; + trackedOrigins?: OpOrigin[]; + captureTimeout?: number; } export interface CRDTUndoManager { - undo(): boolean; - redo(): boolean; - canUndo(): boolean; - canRedo(): boolean; - stopCapturing(): void; - setCaptureTimeout?(ms: number): void; - addTrackedOrigin(origin: OpOrigin): void; - removeTrackedOrigin(origin: OpOrigin): void; - onStackItemAdded?( - callback: ( - stackItem: CRDTUndoStackItem, - kind: "undo" | "redo", - ) => void, - ): Unsubscribe; - onStackItemUpdated?( - callback: ( - stackItem: CRDTUndoStackItem, - kind: "undo" | "redo", - ) => void, - ): Unsubscribe; - onStackItemPopped?( - callback: ( - stackItem: CRDTUndoStackItem, - kind: "undo" | "redo", - ) => void, - ): Unsubscribe; + undo(): boolean; + redo(): boolean; + canUndo(): boolean; + canRedo(): boolean; + stopCapturing(): void; + setCaptureTimeout?(ms: number): void; + addTrackedOrigin(origin: OpOrigin): void; + removeTrackedOrigin(origin: OpOrigin): void; + onStackItemAdded?( + callback: (stackItem: CRDTUndoStackItem, kind: "undo" | "redo") => void, + ): Unsubscribe; + onStackItemUpdated?( + callback: (stackItem: CRDTUndoStackItem, kind: "undo" | "redo") => void, + ): Unsubscribe; + onStackItemPopped?( + callback: (stackItem: CRDTUndoStackItem, kind: "undo" | "redo") => void, + ): Unsubscribe; } export interface CRDTUndoStackItem { - getMeta(key: string): T | undefined; - setMeta(key: string, value: unknown): void; + getMeta(key: string): T | undefined; + setMeta(key: string, value: unknown): void; } // ── Awareness ─────────────────────────────────────────────── export interface AwarenessChangeEvent { - added: number[]; - updated: number[]; - removed: number[]; + added: number[]; + updated: number[]; + removed: number[]; } export interface Awareness { - getLocalState(): Record | null; - setLocalState(state: Record | null): void; - getStates(): Map>; - on( - event: "change", - callback: (changes: AwarenessChangeEvent) => void, - ): void; - off( - event: "change", - callback: (changes: AwarenessChangeEvent) => void, - ): void; - destroy(): void; + getLocalState(): Record | null; + setLocalState(state: Record | null): void; + getStates(): Map>; + on( + event: "change", + callback: (changes: AwarenessChangeEvent) => void, + ): void; + off( + event: "change", + callback: (changes: AwarenessChangeEvent) => void, + ): void; + destroy(): void; } // ── Generation Zone ───────────────────────────────────────── export interface GenerationZone { - id: string; - blockId: string; - range: DocumentRange; - status: "idle" | "streaming" | "complete" | "error"; + id: string; + blockId: string; + range: DocumentRange; + status: "idle" | "streaming" | "complete" | "error"; } // ── CRDT Event ────────────────────────────────────────────── export interface CRDTEvent { - origin: OpOrigin; - readonly affectedBlocks: readonly string[]; - ops: readonly DocumentOp[]; - timestamp: number; - scope?: DocumentScopeInfo; + origin: OpOrigin; + readonly affectedBlocks: readonly string[]; + ops: readonly DocumentOp[]; + timestamp: number; + scope?: DocumentScopeInfo; } diff --git a/packages/types/src/types/index.ts b/packages/types/src/types/index.ts index be08f36..d4e5bd0 100644 --- a/packages/types/src/types/index.ts +++ b/packages/types/src/types/index.ts @@ -21,13 +21,7 @@ export type { } from "./collaboration"; // ── Block ─────────────────────────────────────────────────── -export type { - Block, - App, - Range, - AppPlacement, - AnchorPosition, -} from "./block"; +export type { Block, App, Range, AppPlacement, AnchorPosition } from "./block"; // ── Selection ─────────────────────────────────────────────── export type { @@ -56,7 +50,10 @@ export type { // ── Operations ────────────────────────────────────────────── export type { DocumentOp, + OpOriginType, OpOrigin, + StructuredOpOrigin, + MutationGroupMetadata, ApplyOptions, Position, InsertBlockOp, @@ -104,6 +101,13 @@ export type { DeleteAppOp, SetSelectionOp, } from "./ops"; +export { + MUTATION_GROUP_METADATA_KEY, + createMutationGroupMetadata, + getApplyOptionsGroupId, + getOpOriginGroupId, + getOpOriginType, +} from "./ops"; // ── Stream ────────────────────────────────────────────────── export type { @@ -182,11 +186,7 @@ export type { export { DEFAULT_DATABASE_COLUMN_WIDTH } from "./database"; // ── Field Editor ──────────────────────────────────────────── -export type { - FieldEditor, - InputBackend, - StreamingTarget, -} from "./fieldEditor"; +export type { FieldEditor, InputBackend, StreamingTarget } from "./fieldEditor"; export type { FieldEditorBehavior, FieldEditorInputMode, diff --git a/packages/types/src/types/ops.ts b/packages/types/src/types/ops.ts index 0d74c8a..e06f773 100644 --- a/packages/types/src/types/ops.ts +++ b/packages/types/src/types/ops.ts @@ -1,14 +1,10 @@ import type { AppPlacement } from "./block"; import type { SelectionState } from "./selection"; import type { LayoutProps } from "./layout"; -import type { - ColumnType, - DatabaseViewState, - SelectOption, -} from "./database"; +import type { ColumnType, DatabaseViewState, SelectOption } from "./database"; import type { TableColumnSchema } from "./handles"; -export type OpOrigin = +export type OpOriginType = | "user" | "ai" | "ai-session" @@ -21,12 +17,67 @@ export type OpOrigin = | "import" | "system"; +export interface StructuredOpOrigin { + type: OpOriginType | (string & {}); + groupId?: string; + requestId?: string; + actorId?: string; + source?: string; +} + +export type OpOrigin = OpOriginType | StructuredOpOrigin; + +export interface MutationGroupMetadata { + groupId: string; + originType: string; + requestId?: string; + actorId?: string; + source?: string; +} + export interface ApplyOptions { origin?: OpOrigin; undoGroup?: boolean; + groupId?: string; undoGroupId?: string; } +export const MUTATION_GROUP_METADATA_KEY = "mutation-group"; + +export function getOpOriginType(origin: OpOrigin): string { + return typeof origin === "string" ? origin : origin.type; +} + +export function getOpOriginGroupId(origin: OpOrigin): string | undefined { + return typeof origin === "string" ? undefined : origin.groupId; +} + +export function getApplyOptionsGroupId( + origin: OpOrigin, + options?: Pick, +): string | undefined { + return ( + options?.undoGroupId ?? options?.groupId ?? getOpOriginGroupId(origin) + ); +} + +export function createMutationGroupMetadata( + origin: OpOrigin, + groupId: string, +): MutationGroupMetadata { + if (typeof origin === "string") { + return { groupId, originType: origin }; + } + + return { + groupId, + originType: origin.type, + requestId: origin.requestId, + actorId: origin.actorId, + source: origin.source, + }; +} + export type Position = | "first" | "last" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 631f808..42f5df4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -879,6 +879,9 @@ importers: '@pen/crdt-yjs': specifier: workspace:* version: link:../../crdt/yjs + '@pen/export-json': + specifier: workspace:* + version: link:../../extensions/export-json '@pen/schema-default': specifier: workspace:* version: link:../../schema/default diff --git a/spec/README.md b/spec/README.md index 05ae7f0..23bb08c 100644 --- a/spec/README.md +++ b/spec/README.md @@ -24,6 +24,7 @@ Then read package specs by layer: - `charter/` contains cross-cutting architectural invariants. - `packages/` mirrors the workspace package and app layout. - Package specs stay close to real package boundaries instead of grouping work by milestone or wave. +- `roadmap/` contains explicit forward-looking plans that have not yet been folded into package specs. ## Core Conventions @@ -40,3 +41,7 @@ Then read package specs by layer: - Historical wave docs and planning notes were removed. - Specs now describe the workspace as shipped today. - The highest-value packages now have deeper runtime notes, boundaries, and architecture diagrams rather than just metadata summaries. + +## Roadmap Specs + +- `roadmap/headless-collaboration-ai-waves.md`: generic Pen primitives for CRDT state barriers, structured mutation groups, headless server editors, export hooks, field adapters, and deterministic fixtures. diff --git a/spec/packages/core.md b/spec/packages/core.md index 0d664e3..bb9d46b 100644 --- a/spec/packages/core.md +++ b/spec/packages/core.md @@ -11,7 +11,7 @@ Every higher-level package depends on the contracts and runtime behavior establi ## Key Exports / Entrypoints - Export map: `.` -- Runtime entrypoints such as `createEditor()` and `createDocumentSession()` +- Runtime entrypoints such as `createEditor()`, `createHeadlessEditor()`, and `createDocumentSession()` - Schema runtime exports such as `SchemaRegistryImpl`, `mergeSchemas`, and `SchemaEngineImpl` - Read-model and editor helpers such as `DocumentStateImpl`, `SelectionManagerImpl`, `DocumentRangeImpl`, and `ExtensionManagerImpl` - Decoration and inline-completion helpers such as `createDecorationSet()`, `mergeDecorationSets()`, `ensureInlineCompletionController()`, and `getInlineCompletionController()` @@ -53,14 +53,22 @@ Important rules: - `DocumentOp[]` is the mutation currency. - Durable document writes go through `editor.apply(...)`. +- Structured operation origins can carry `groupId`, `requestId`, `actorId`, and `source` metadata so hosts can attribute and group mutations without inventing a parallel apply path. - Extensions can prepare work, observe editor events, and register slots, but they do not bypass the core mutation boundary. - Renderer packages read `DocumentState`, `BlockHandle`, selection, and decorations from the editor; they do not become alternate document authorities. +## Headless Workflows + +`createHeadlessEditor()` is the preferred factory for server-side or workflow-only editor use. It keeps Pen headless and applies the same document pipeline to existing CRDT documents without mounting a renderer. Hosts should use it for AI workers, export workers, migrations, and contract tests that need editor semantics without UI behavior. + +Headless editors default to the core apply pipeline only. Hosts can opt into default extensions explicitly when a non-rendered workflow needs undo, shortcuts, or delta stream behavior. + ## Integration Notes - Path in workspace: `packages/core` - Spec path mirrors workspace path: `packages/core.md` - Typical adoption starts with `createEditor()` plus `@pen/schema-default` and `@pen/preset-default` +- Server/workflow adoption starts with `createHeadlessEditor()` plus a wrapped CRDT document. - Schema composition happens here through the registry/merge APIs, not in renderer packages - Serialization packages and tool packages should treat the editor as the authority boundary, even when they export convenience helpers diff --git a/spec/packages/crdt/yjs.md b/spec/packages/crdt/yjs.md index e51aa96..06b971e 100644 --- a/spec/packages/crdt/yjs.md +++ b/spec/packages/crdt/yjs.md @@ -11,6 +11,11 @@ Bridge Pen contracts to a specific CRDT implementation. ## Key Exports / Entrypoints - Export map: `.` +- CRDT adapter and document helpers such as `yjsAdapter()`, `wrapYjsDocument()`, `initBlockMap()`, and `getYjsDoc()` +- Collaboration helpers such as `createYjsProviderSession()`, `createYjsAwareness()`, and `getYjsAwareness()` +- State-vector helpers such as `encodeYjsStateVectorBase64()`, `compareYjsStateVectors()`, and `isYjsStateVectorBase64Satisfied()` +- Generic field adapters such as `createYTextFieldAdapter()` and `createYArrayFieldAdapter()` +- Extension-root helpers such as `ensureExtensionRoot()` and `readExtensionRoot()` - Workspace scripts: `build`, `clean`, `test`, `typecheck` ## Dependencies And Boundaries @@ -23,11 +28,19 @@ Bridge Pen contracts to a specific CRDT implementation. CRDT adapter packages in Pen should stay package-first and explicit about ownership. Use this package when a host app adopts the matching CRDT backend. +State-vector helpers are the generic synchronization primitive for host-owned workflow barriers. A host can capture a Yjs state vector before enqueueing work and later ask whether a synced document satisfies that barrier without duplicating Yjs clock comparison logic. + +Field adapters cover host-owned non-body fields that live next to Pen document roots, such as titles, labels, tags, or app-specific structured arrays. They are storage helpers only: hosts provide normalization and stable IDs, while Pen provides deterministic Yjs text/array operations. + +Extension-root helpers reserve namespaced Yjs maps under the document `apps` root. They provide version checks and deterministic field initialization for app-owned collaboration data without teaching Pen product-specific schema. + ## Integration Notes - Path in workspace: `packages/crdt/yjs` - Spec path mirrors workspace path: `packages/crdt/yjs.md` - This package is part of the current package surface and should stay aligned with the headless runtime architecture. +- Keep app semantics out of the adapters. For example, a recipient list should be implemented as a host-specific array adapter configuration, not as an email-aware Pen primitive. +- Prefer extension roots over ad hoc top-level shared types for new app-owned CRDT data. ## Current Maturity / Intended Usage @@ -35,4 +48,6 @@ Workspace package at version `0.0.0`; intended usage is current-state but still ## Non-goals -Do not let the adapter redefine the Pen document model or renderer behavior. +- Do not let the adapter redefine the Pen document model or renderer behavior. +- Do not make Durable Streams, WebSocket providers, or app-owned sync state part of this package. +- Do not encode product-specific validation in field adapters. diff --git a/spec/packages/extensions/export-json.md b/spec/packages/extensions/export-json.md index 7f395f8..48f66b1 100644 --- a/spec/packages/extensions/export-json.md +++ b/spec/packages/extensions/export-json.md @@ -11,7 +11,7 @@ This package is the canonical structured serialization boundary for Pen. It does ## Key Exports / Entrypoints - Export map: `.` -- Export APIs such as `jsonExporter` and `exportEditorToJson()` +- Export APIs such as `jsonExporter`, `exportEditorToJson()`, `textExporter`, `exportEditorToText()`, `exportPlainText()`, and `exportPenDocumentToText()` - Import APIs such as `jsonImporter` and `parseJsonDocument()` - Schema/version helpers such as `PEN_DOCUMENT_JSON_VERSION` and `isSupportedPenDocumentVersion()` - Public JSON model types such as `PenDocumentJSON`, `PenBlockJSON`, `PenInlineContentJSON`, `PenMarkJSON`, and export option types @@ -47,14 +47,21 @@ flowchart TD Important rules: - Export reads from the editor's current document state, including root blocks and structured children. +- Text export traverses the same JSON block tree and supports host-provided block filtering and inline node rendering. - Import parses JSON, validates version and shape, normalizes pending blocks against the current schema and document profile, and only then applies editor operations. - The JSON envelope is a transport and persistence shape, not a second runtime authority. +## Plain Text Export + +`exportEditorToText()`, `exportPlainText()`, and `exportPenDocumentToText()` provide deterministic plain-text extraction for headless workflows, search indexes, previews, tests, and host-owned delivery/export pipelines. Hosts can filter block types, customize separators, render app-specific inline nodes, and extract database block text without making Pen aware of product concepts. + ## Integration Notes - Path in workspace: `packages/extensions/export-json` - Spec path mirrors workspace path: `packages/extensions/export-json.md` - `exportEditorToJson()` is the lowest-friction way to persist a Pen document in a structured form +- `exportPenDocumentToText()` is the lowest-friction way to derive text from already-exported JSON without reopening an editor. +- `exportPlainText()` is the convenience alias for hosts that want the canonical plain-text exporter name. - `jsonImporter.import()` is the higher-level path when the goal is to apply imported content into a live editor - This package is a foundational dependency for other structured interchange formats in the repo @@ -67,3 +74,4 @@ Workspace package at version `0.0.0`; intended usage is current-state but still - Do not duplicate core editor authority. - Do not treat parsed JSON as trusted runtime state before validation and normalization. - Do not move renderer concerns or UI flows into the serialization layer. +- Do not make host delivery policy, HTML sanitization, or email/provider quirks part of this package. diff --git a/spec/packages/tooling/test.md b/spec/packages/tooling/test.md index 29cb801..9663936 100644 --- a/spec/packages/tooling/test.md +++ b/spec/packages/tooling/test.md @@ -15,19 +15,23 @@ Support development, testing, benchmarking, or local integration workflows aroun ## Dependencies And Boundaries -- Runtime dependencies: `@pen/core`, `@pen/crdt-yjs`, `@pen/schema-default`, `@pen/types`, `yjs` +- Runtime dependencies: `@pen/core`, `@pen/crdt-yjs`, `@pen/export-json`, `@pen/schema-default`, `@pen/types`, `yjs` - Peer dependencies: No peer dependencies declared. - Boundary: Tooling packages serve the workspace and advanced integrators more than standard runtime embedding. ## Data Flow / Runtime Model -Tooling package packages in Pen should stay package-first and explicit about ownership. Use these packages in development flows, tests, or benchmarks. +Tooling packages in Pen should stay package-first and explicit about ownership. Use these packages in development flows, tests, or benchmarks. + +`@pen/test` provides deterministic Yjs fixtures and opt-in contract helpers for host apps and Pen packages. Fixture helpers generate stable updates, state vectors, and normalized snapshots without relying on product data. Contract helpers exercise CRDT state-vector satisfaction, headless editor creation, and export behavior while leaving the choice of test runner to the host. ## Integration Notes - Path in workspace: `packages/tooling/test` - Spec path mirrors workspace path: `packages/tooling/test.md` - This package is part of the current package surface and should stay aligned with the headless runtime architecture. +- Use `createDeterministicYDocFixture()` when a test needs a stable Yjs update or normalized root snapshot. +- Use `runCRDTStateVectorContract()`, `runHeadlessEditorContract()`, and `runExportContract()` as opt-in smoke contracts for host integrations. ## Current Maturity / Intended Usage @@ -35,4 +39,6 @@ Workspace package at version `0.0.0`; intended usage is current-state but still ## Non-goals -Do not present tooling packages as the editor runtime itself. +- Do not present tooling packages as the editor runtime itself. +- Do not encode host-product fixture data in Pen test helpers. +- Do not require host apps to use Pen's test runner. diff --git a/spec/packages/types.md b/spec/packages/types.md index 5921f31..1272af9 100644 --- a/spec/packages/types.md +++ b/spec/packages/types.md @@ -14,6 +14,7 @@ This is the contract package for the monorepo. It is the place where packages ag - Root export of package-wide contracts via `./types/index` - Lightweight runtime helpers such as `defineBlock()`, `defineExtension()`, `prop()`, `resolveSchema()`, `generateId()`, and database/block-capability helpers - Shared slot keys such as `FIELD_EDITOR_SLOT_KEY`, `SEARCH_CONTROLLER_SLOT`, `MULTIPLAYER_CONTROLLER_SLOT`, `HISTORY_CONTROLLER_SLOT`, and AI/undo-related slots +- Operation origin contracts such as `OpOriginType`, `StructuredOpOrigin`, `MutationGroupMetadata`, and helpers for resolving origin/group metadata - Shared AI operation contracts such as selection targets, scoped-range targets, requested-operation provenance, and low-level range helpers - Workspace scripts: `build`, `clean`, `test`, `typecheck` @@ -46,8 +47,19 @@ Important rules: - Slot keys and interfaces defined here are cross-package contracts and should remain stable unless a real architectural change requires otherwise. - Lightweight helpers are acceptable when they support contract authoring or schema declarations, but heavier behavior belongs elsewhere. - Other packages should depend on this package to agree on shapes, not to inherit hidden runtime behavior. +- Structured mutation metadata belongs here because `@pen/core`, undo/history packages, AI extensions, and host workflows all need to agree on attribution and grouping semantics. - If multiple packages need to agree on AI mutation target semantics, that target contract belongs here rather than being duplicated in package-local types. +## Structured Mutation Origins + +`OpOrigin` accepts both legacy string origins and structured origins. Structured origins allow a host or extension to attach: + +- `type`: the stable origin type, such as `user`, `ai`, or a host-defined string. +- `groupId`: a logical mutation group shared across one or more `editor.apply(...)` calls. +- `requestId`, `actorId`, and `source`: optional attribution fields for workflow and diagnostics. + +`getOpOriginType()`, `getApplyOptionsGroupId()`, and `createMutationGroupMetadata()` keep this behavior consistent across runtime packages. Hosts should use structured origins for AI/workflow changes instead of encoding request metadata in ad hoc strings. + ## Shared AI Target Contract `@pen/types` is the canonical home for shared AI operation target shapes. diff --git a/spec/roadmap/headless-collaboration-ai-waves.md b/spec/roadmap/headless-collaboration-ai-waves.md new file mode 100644 index 0000000..b8238b9 --- /dev/null +++ b/spec/roadmap/headless-collaboration-ai-waves.md @@ -0,0 +1,579 @@ +# Headless Collaboration And AI Primitives Roadmap + +## Status + +Roadmap proposal for Pen library improvements that support local-first host applications, synchronized AI workflows, server-side rendering/export, and cross-client collaboration. + +This document is intentionally roadmap-oriented. The rest of `spec/` remains current-state and package-centric. These waves should become package specs or package updates as they are implemented. + +## Product Boundary + +Pen must remain a headless, open source editor library. + +Pen should provide generic primitives for: + +- CRDT documents, +- collaboration state, +- structured mutation origins, +- grouped undo/update semantics, +- headless server editor construction, +- app-owned extension roots, +- export hooks, +- field adapters, +- deterministic fixtures. + +Pen must not provide product-specific semantics for: + +- email, +- recipients, +- subject lines, +- send/provider workflows, +- Input-specific sync tables, +- app auth, +- app model routing, +- system prompts, +- external provider secrets. + +Host apps such as Input own those product concerns. + +## Why This Matters + +The Input Pen email architecture needs to: + +- wait for a Yjs document to reach a requested state before AI/send workers run, +- apply AI edits as a single grouped mutation, +- let server workers create headless editors from YDocs, +- keep app metadata roots such as `mail` organized without raw Yjs access everywhere, +- export HTML/text through a consistent server-side pipeline, +- bind non-body CRDT fields such as subject and recipients, +- share deterministic fixtures between web and API. + +Input can build these locally, but the same primitives are valuable for any app building collaborative documents, AI-assisted editing, CMS workflows, comments, notes, docs, or issue descriptions. The right move is to improve Pen generically rather than adding `toMail` or email-specific APIs. + +## Cross-Wave Invariants + +- `editor.apply(...)` remains the canonical document mutation path. +- Pen never owns host auth, persistence policy, transport secrets, or product workflow state. +- Renderer packages do not become document sources of truth. +- CRDT/Yjs helpers stay in `@pen/crdt-yjs` unless they become implementation-agnostic contracts. +- Export packages emit fragments/artifacts; host apps own final wrappers, sanitization policy, and delivery. +- AI helpers remain model/provider agnostic. +- All new APIs must work in headless/server environments. + +## Wave Order + +1. CRDT state vectors and synchronization barriers. +2. Structured mutation origins and update groups. +3. Headless editor factory and extension roots. +4. Export pipeline hooks and plain-text artifact support. +5. CRDT field adapters. +6. Deterministic fixtures, contract tests, and docs. + +## Wave 1: CRDT State Vectors And Synchronization Barriers + +### Wave 1 Goal + +Make Yjs state-vector comparison and serialization a supported Pen capability so host apps do not hand-roll clock comparison. + +### Wave 1 Package + +Primary package: + +```text +packages/crdt/yjs +``` + +Possible shared contracts: + +```text +packages/types +``` + +### Wave 1 Public API + +Add helpers like: + +```ts +encodeYjsStateVector(doc): Uint8Array +encodeYjsStateVectorBase64(doc): string +decodeYjsStateVectorBase64(value): Uint8Array +isYjsStateVectorSatisfied(current, required): boolean +compareYjsStateVectors(current, required): YjsStateVectorComparison +``` + +Suggested result type: + +```ts +type YjsStateVectorComparison = { + satisfied: boolean; + missingClients: Array<{ + clientId: number; + currentClock: number; + requiredClock: number; + }>; +}; +``` + +Rules: + +- Decode state vectors with Yjs APIs, not ad hoc parsing. +- Missing current client clocks count as `0`. +- Extra current client IDs do not make comparison fail. +- Malformed vectors fail closed and return diagnostics. +- Base64 helpers should be explicit; do not hide binary/text conversion in unrelated APIs. + +### Wave 1 Non-Goals + +- Do not add Durable Streams-specific offsets to Pen. +- Do not add host workflow rows or request concepts. +- Do not add networking or waiting/polling to core state-vector helpers. + +### Wave 1 Tests + +- identical vectors satisfy, +- current vector with higher clocks satisfies, +- missing client fails, +- lower clock fails, +- extra current clients are ignored, +- malformed base64 fails with diagnostic, +- helpers work for empty documents, +- helpers work after applying merged updates. + +### Wave 1 Input Impact + +Input can replace app-local `isEmailDraftStateBarrierSatisfied(...)` internals with Pen-provided Yjs comparison while keeping the app-level helper name and workflow semantics. + +## Wave 2: Structured Mutation Origins And Update Groups + +### Wave 2 Goal + +Make grouped mutations and origin metadata first-class enough for AI edits, undo grouping, attribution, diagnostics, and cross-client "go back" workflows. + +### Wave 2 Packages + +Primary packages: + +```text +packages/types +packages/core +packages/crdt/yjs +packages/extensions/undo +packages/extensions/history +``` + +### Wave 2 Public API + +Support structured origins in addition to existing string origins: + +```ts +type MutationOrigin = + | "user" + | "ai" + | "collaborator" + | "input-rule" + | { + type: string; + groupId?: string; + requestId?: string; + actorId?: string; + source?: string; + }; +``` + +Add grouped apply helpers: + +```ts +editor.applyGrouped(ops, { + origin: { type: "ai", groupId, requestId }, +}); +``` + +or keep `editor.apply(...)` as the only API but standardize grouped options: + +```ts +editor.apply(ops, { + origin: { type: "ai", groupId, requestId }, + groupId, +}); +``` + +Undo/history should preserve group metadata: + +```ts +type MutationGroupMetadata = { + groupId: string; + originType: string; + requestId?: string; + actorId?: string; +}; +``` + +### Wave 2 Rules + +- Existing string origins remain supported. +- Yjs transactions should receive stable origin objects or tags that undo tracking can understand. +- Undo stack items should expose group metadata. +- History/suggestion/AI flows should not need to infer grouped mutations by timestamp. +- Group IDs are host-provided or generated by Pen helpers; they are not product-specific. + +### Wave 2 Non-Goals + +- Do not add Input prompt/request rows. +- Do not define model providers. +- Do not define "email AI" behavior. + +### Wave 2 Tests + +- string origins remain backward compatible, +- structured origins are tracked by undo manager, +- grouped AI mutation becomes one undo item, +- stack item metadata includes group ID, +- redo preserves group metadata, +- collaboration updates preserve enough metadata for diagnostics where feasible, +- history extension can filter/group by origin type. + +### Wave 2 Input Impact + +Input can record `applied_update_group_id` and rely on Pen to make the corresponding AI mutation one logical undoable unit. + +## Wave 3: Headless Editor Factory And Extension Roots + +### Wave 3 Goal + +Give server workers and host apps a safe, boring path to create headless editors from CRDT documents and app-owned metadata roots. + +### Wave 3 Packages + +Primary packages: + +```text +packages/core +packages/crdt/yjs +packages/types +``` + +### Wave 3 Public API + +Headless editor factory: + +```ts +createHeadlessEditor({ + document, + schema, + preset, + documentProfile, + extensions, + onDiagnostic, +}); +``` + +This can be a documented alias or wrapper around existing editor creation if the capability already exists internally. The important point is a stable server-safe entrypoint. + +Extension root helpers: + +```ts +ensureExtensionRoot(doc, { + namespace: "input.mail", + version: 1, + shape, +}); + +readExtensionRoot(doc, "input.mail"); +``` + +`shape` should be a lightweight validation/initialization contract. It should not require Pen to know host product semantics. + +### Wave 3 Rules + +- Extension roots are namespaced. +- Pen validates presence/version/shape at a generic level. +- Host apps own fields inside their roots. +- Helpers should avoid raw `Y.Map` access leaking through product code. +- Headless editor construction must not require DOM or renderer packages. + +### Wave 3 Non-Goals + +- Do not add a built-in `mail` root. +- Do not add recipient/subject concepts. +- Do not add server transport policy. + +### Wave 3 Tests + +- headless editor can be constructed from a wrapped YDoc, +- missing Pen roots are initialized or diagnosed according to options, +- extension root initialization is idempotent, +- version mismatch produces diagnostic, +- root helpers do not mutate unrelated roots, +- headless editor can export after construction. + +### Wave 3 Input Impact + +Input's API workers can load a YDoc, ensure Pen roots and the `input.mail` extension root, then create a headless editor for AI/export without custom bootstrapping in every worker. + +## Wave 4: Export Pipeline Hooks And Plain-Text Artifacts + +### Wave 4 Goal + +Make export more composable for host-defined targets such as web previews, markdown, plain text, and product-specific delivery formats while keeping Pen responsible only for document fragments/artifacts. + +### Wave 4 Packages + +Primary packages: + +```text +packages/extensions/export-html +packages/extensions/export-markdown +packages/core +packages/types +``` + +Possible new package: + +```text +packages/extensions/export-text +``` + +### Wave 4 Public API + +Extend export options generically: + +```ts +type ExportOptions = { + includeSuggestions?: boolean; + target?: string; + hooks?: ExportHooks; + extra?: Extra; +}; +``` + +Hooks: + +```ts +type ExportHooks = { + block?: (context) => string | undefined; + inline?: (context) => string | undefined; + asset?: (context) => ExportAsset | undefined; + afterBlock?: (context) => string | undefined; +}; +``` + +Suggestion policy: + +```ts +type SuggestionExportMode = + | "raw" + | "resolved" + | "accepted-only" + | "rejected-only"; +``` + +Plain-text artifact: + +```ts +exportPlainText(editor, options): string +``` + +### Wave 4 Rules + +- HTML exporter still returns fragments, not full delivery documents. +- Host apps own wrappers, CSS inlining, sanitization, provider quirks, and final delivery. +- Export hooks must be deterministic and side-effect free. +- Traversal must include nested/layout children. +- Defaults must preserve current output. + +### Wave 4 Non-Goals + +- Do not implement `toMail`. +- Do not add host delivery compatibility policy to Pen. +- Do not sanitize final host output in Pen unless a generic sanitizer package is explicitly introduced. + +### Wave 4 Tests + +- current HTML snapshots remain stable by default, +- `target` is passed to block/inline hooks, +- host block override works without modifying schema, +- suggestion export modes behave consistently, +- plain text traversal includes nested children, +- database/table export still works, +- unknown target falls back safely. + +### Wave 4 Input Impact + +Input can use Pen for stable fragment/text export while keeping mail wrappers, quote handling, footer insertion, and sanitization inside Input. + +## Wave 5: CRDT Field Adapters + +### Wave 5 Goal + +Provide generic adapters for non-body CRDT fields such as titles, labels, tags, recipients-like arrays, and app-owned structured fields. + +### Wave 5 Packages + +Primary package: + +```text +packages/crdt/yjs +``` + +Possible shared contracts: + +```text +packages/types +``` + +### Wave 5 Public API + +Text field adapter: + +```ts +createYTextFieldAdapter({ + doc, + root, + key, + normalize?, +}); +``` + +Array/map field adapter: + +```ts +createYArrayFieldAdapter({ + doc, + root, + key, + itemSchema, + getId, + normalizeItem?, +}); +``` + +Returned capabilities: + +```ts +read() +replace(value) +insert(item, index?) +update(id, patch) +remove(id) +observe(callback) +``` + +### Wave 5 Rules + +- Adapters are generic CRDT helpers, not form components. +- They should work in browser and server. +- They should support stable item IDs. +- They should keep normalization optional and host-provided. +- They should not know about recipients, subject, email addresses, or contacts. + +### Wave 5 Non-Goals + +- Do not add UI bindings to core adapters. +- Do not add app validation rules. +- Do not add schema-default fields. + +### Wave 5 Tests + +- Y.Text field reads/writes/observes, +- array adapter inserts/removes by stable ID, +- concurrent item updates do not replace the whole array, +- normalization is applied consistently, +- server-side use works without DOM, +- malformed item data emits diagnostics or fails safely. + +### Wave 5 Input Impact + +Input can bind subject and recipient arrays through generic field adapters instead of custom Yjs wrappers. + +## Wave 6: Deterministic Fixtures, Contract Tests, And Docs + +### Wave 6 Goal + +Make headless CRDT/editor/export behavior easy to test across host apps and Pen packages. + +### Wave 6 Packages + +Primary packages: + +```text +packages/tooling/test +packages/crdt/yjs +packages/core +``` + +### Wave 6 Public API + +Fixture helpers: + +```ts +createDeterministicYDocFixture(...) +encodeFixtureUpdate(...) +normalizeDocumentForSnapshot(...) +assertDocumentRoots(...) +``` + +Contract test helpers: + +```ts +runCRDTStateVectorContract(...) +runHeadlessEditorContract(...) +runExportContract(...) +``` + +### Wave 6 Rules + +- Fixtures must avoid real personal data. +- Helpers must be deterministic. +- Helpers should be usable by host apps without private Pen internals. +- Contract tests should be opt-in and package-friendly. + +### Wave 6 Non-Goals + +- Do not create Input-specific fixtures in Pen. +- Do not require host apps to use Pen's test runner. +- Do not encode product-specific expected outputs. + +### Wave 6 Tests + +- deterministic fixture generation is stable, +- normalized snapshots are stable across clients, +- contract helpers can run in Node, +- malformed fixture helpers produce useful diagnostics. + +### Wave 6 Input Impact + +Input's `spec/fixtures/email-drafts/` can use Pen fixture tooling to create stable YDoc updates and verify projection/export/state-barrier behavior across web and API. + +## Documentation Updates + +As waves ship, update: + +- `spec/packages/crdt/yjs.md`, +- `spec/packages/core.md`, +- `spec/packages/extensions/export-html.md`, +- `spec/packages/extensions/ai.md`, +- package READMEs, +- playground examples where helpful. + +Examples should stay generic: + +- collaborative title/body document, +- AI rewrite with grouped undo, +- headless server export, +- extension root for app metadata, +- field adapter for tags or labels. + +Do not use a mail workflow as the primary Pen example unless it is clearly framed as a host-app pattern outside core Pen semantics. + +## Rollout Guidance + +Recommended order for Input alignment: + +1. Ship Wave 1 before Input implements state barriers. +2. Ship Wave 2 before Input finalizes AI go-back semantics. +3. Ship Wave 3 before Input builds API AI/send workers. +4. Ship Wave 4 before Input locks server export. +5. Ship Wave 5 before Input builds custom subject/recipient Yjs adapters, if timing allows. +6. Ship Wave 6 when Input begins shared fixture work. + +Input can proceed with local wrappers if a Pen wave is not ready, but those wrappers should mirror the proposed Pen API so they can collapse back into the library later. From ded1c97b636be86359594fe98aaa9ef2904a6fb2 Mon Sep 17 00:00:00 2001 From: krijn Date: Sat, 9 May 2026 11:30:19 +0200 Subject: [PATCH 05/20] Implement session diagnostics feature and update dependencies - Added session diagnostics functionality to the DebugPanel, allowing real-time monitoring of session state and metrics. - Introduced new constants for session diagnostics endpoint in playgroundAI.ts. - Updated pnpm-lock.yaml to include `@pen/export-json` for workspace integration. - Refactored imports and code structure in various files for improved readability and maintainability. --- playground/package.json | 5 +- playground/server/index.ts | 542 ++++++++++++++++------- playground/src/components/DebugPanel.tsx | 133 +++++- playground/src/constants/playgroundAI.ts | 2 + pnpm-lock.yaml | 3 + 5 files changed, 521 insertions(+), 164 deletions(-) diff --git a/playground/package.json b/playground/package.json index 185ed81..080740e 100644 --- a/playground/package.json +++ b/playground/package.json @@ -14,14 +14,15 @@ "@ai-sdk/anthropic": "^3.0.58", "@pen/ai": "workspace:*", "@pen/ai-autocomplete": "workspace:*", - "@pen/ai-suggestions": "workspace:*", "@pen/ai-skills": "workspace:*", + "@pen/ai-suggestions": "workspace:*", "@pen/ai-tools": "workspace:*", "@pen/assets-memory": "workspace:^", "@pen/core": "workspace:*", "@pen/crdt-yjs": "workspace:^", "@pen/database": "workspace:*", "@pen/export-html": "workspace:*", + "@pen/export-json": "workspace:^", "@pen/export-markdown": "workspace:*", "@pen/import-html": "workspace:*", "@pen/import-markdown": "workspace:*", @@ -29,8 +30,8 @@ "@pen/multiplayer": "workspace:^", "@pen/preset-default": "workspace:*", "@pen/react": "workspace:*", - "@pen/search": "workspace:*", "@pen/schema-default": "workspace:*", + "@pen/search": "workspace:*", "@pen/shortcuts": "workspace:*", "@pen/types": "workspace:*", "@y/websocket-server": "^0.1.5", diff --git a/playground/server/index.ts b/playground/server/index.ts index a4690e0..fca2f74 100644 --- a/playground/server/index.ts +++ b/playground/server/index.ts @@ -1,13 +1,35 @@ import { anthropic } from "@ai-sdk/anthropic"; -import { docs as collaborationDocs, setupWSConnection } from "@y/websocket-server/utils"; -import { generateText, jsonSchema, Output, stepCountIs, streamText, tool } from "ai"; +import { + docs as collaborationDocs, + setupWSConnection, +} from "@y/websocket-server/utils"; +import { + generateText, + jsonSchema, + Output, + stepCountIs, + streamText, + tool, +} from "ai"; import { config as loadEnv } from "dotenv"; import { randomUUID } from "node:crypto"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; import type { Duplex } from "node:stream"; import { fileURLToPath } from "node:url"; import { WebSocketServer, type WebSocket } from "ws"; -import { createEditor } from "@pen/core"; +import * as Y from "yjs"; +import { createHeadlessEditor } from "@pen/core"; +import { + encodeYjsStateVectorBase64, + ensureExtensionRoot, + getYjsDoc, + readExtensionRoot, +} from "@pen/crdt-yjs"; +import { exportPlainText } from "@pen/export-json"; import { buildPlaygroundRequestPlan as buildSharedPlaygroundRequestPlan, buildExplicitLocalOperationPrompt, @@ -35,11 +57,7 @@ import { import { listDefaultAISkills, renderSkillFiles } from "@pen/ai-skills"; import { defaultPreset } from "@pen/preset-default"; import { createDefaultSchema } from "@pen/schema-default"; -import type { - Editor, - ModelRequestedOperation, - ToolRuntime, -} from "@pen/types"; +import type { Editor, ModelRequestedOperation, ToolRuntime } from "@pen/types"; import { isScopedSelectionTarget, renderSelectionTargetText, @@ -106,6 +124,9 @@ const PLAYGROUND_LOCAL_CONTINUE_SYSTEM_PROMPT = "You are a precise local editor operation. Return only the continuation text that should be inserted at the requested cursor position inside the required payload wrapper. Do not repeat the existing content, and do not include analysis, narration, tool chatter, labels, or quotes outside the wrapper."; const PLAYGROUND_SKILLS_ROUTE = "/api/skills"; const PLAYGROUND_TOOL_ROUTE_PREFIX = "/api/tools/"; +const PLAYGROUND_SESSION_DIAGNOSTICS_ROUTE = "/api/ai/session/diagnostics"; +const PLAYGROUND_EXTENSION_ROOT_NAMESPACE = "pen.playground"; +const PLAYGROUND_EXTENSION_ROOT_VERSION = 1; const PLAYGROUND_DIRECT_TOOL_NAMES = new Set([ "get_context", "read_document", @@ -132,6 +153,22 @@ interface SessionCreateResponse { sessionId: string; } +interface SessionDiagnosticsResponse { + sessionId: string; + headless: true; + blockCount: number; + generation: number; + plainText: string; + stateVector: string; + extensionRoot: { + namespace: string; + version: number; + requestCount: number; + lastRequestMode: string | null; + lastSyncedRevision: number | null; + }; +} + interface SessionSyncBody { sessionId?: unknown; editorState?: unknown; @@ -207,28 +244,29 @@ interface PlaygroundRequestPlan { selectedTextLength: number | null; } -const buildTypedSharedPlaygroundRequestPlan = buildSharedPlaygroundRequestPlan as ( - editor: Editor, - prompt: string, - config: { - documentModel: string; - selectionModel: string; - documentSystemPrompt: string; - structuredPlannerSystemPrompt: string; - selectionFastPathSystemPrompt: string; - autocompleteSystemPrompt: string; - selectionSourceCharLimit: number; - selectionStopSentinel: string; - selectionOutputTokenCap: number; - autocompleteOutputTokenCap: number; - selectionDefaultOutputTokens: number; - selectionExpandOutputTokens: number; - selectionSummarizeOutputTokens: number; - selectionTranslateOutputTokens: number; - }, - requestedMode?: PlaygroundRequestMode | null, - requestedOperation?: ModelRequestedOperation | null, -) => PlaygroundRequestPlan; +const buildTypedSharedPlaygroundRequestPlan = + buildSharedPlaygroundRequestPlan as ( + editor: Editor, + prompt: string, + config: { + documentModel: string; + selectionModel: string; + documentSystemPrompt: string; + structuredPlannerSystemPrompt: string; + selectionFastPathSystemPrompt: string; + autocompleteSystemPrompt: string; + selectionSourceCharLimit: number; + selectionStopSentinel: string; + selectionOutputTokenCap: number; + autocompleteOutputTokenCap: number; + selectionDefaultOutputTokens: number; + selectionExpandOutputTokens: number; + selectionSummarizeOutputTokens: number; + selectionTranslateOutputTokens: number; + }, + requestedMode?: PlaygroundRequestMode | null, + requestedOperation?: ModelRequestedOperation | null, + ) => PlaygroundRequestPlan; const sessions = new Map(); const serverOrigin = `http://${PLAYGROUND_SERVER_HOST}:${PLAYGROUND_SERVER_PORT}`; @@ -272,6 +310,14 @@ const server = createServer(async (req, res) => { return; } + if ( + url.pathname === PLAYGROUND_SESSION_DIAGNOSTICS_ROUTE && + req.method === "GET" + ) { + handleSessionDiagnosticsRequest(req, res, url); + return; + } + if (url.pathname === "/api/tools" && req.method === "GET") { handleListToolsRequest(req, res); return; @@ -282,7 +328,10 @@ const server = createServer(async (req, res) => { return; } - if (url.pathname.startsWith(PLAYGROUND_TOOL_ROUTE_PREFIX) && req.method === "POST") { + if ( + url.pathname.startsWith(PLAYGROUND_TOOL_ROUTE_PREFIX) && + req.method === "POST" + ) { await handleDirectToolRequest(req, res, url); return; } @@ -304,9 +353,7 @@ server.on("upgrade", (request, socket, head) => { }); server.listen(PLAYGROUND_SERVER_PORT, PLAYGROUND_SERVER_HOST, () => { - console.log( - `Pen playground AI backend listening on ${serverOrigin}`, - ); + console.log(`Pen playground AI backend listening on ${serverOrigin}`); }); collaborationWebSocketServer.on( @@ -330,7 +377,33 @@ function handleCreateSession(res: ServerResponse): void { logPlaygroundEvent("session:create", { sessionId: session.id, }); - sendJson(res, 200, { sessionId: session.id } satisfies SessionCreateResponse); + sendJson(res, 200, { + sessionId: session.id, + } satisfies SessionCreateResponse); +} + +function handleSessionDiagnosticsRequest( + req: IncomingMessage, + res: ServerResponse, + url: URL, +): void { + const sessionId = + url.searchParams.get("sessionId")?.trim() ?? + readHeader(req, SESSION_HEADER); + if (!sessionId) { + sendJson(res, 400, { + error: "Expected a valid playground session ID.", + }); + return; + } + + const session = sessions.get(sessionId) ?? null; + if (!session) { + sendJson(res, 404, { error: "Playground session not found." }); + return; + } + + sendJson(res, 200, { ...createSessionDiagnostics(session) }); } async function handleSessionSync( @@ -343,14 +416,14 @@ async function handleSessionSync( const editorState = parseSerializedEditorState(body.editorState); const revision = typeof body.revision === "number" && - Number.isInteger(body.revision) && - body.revision >= 0 + Number.isInteger(body.revision) && + body.revision >= 0 ? body.revision : null; const generation = typeof body.generation === "number" && - Number.isInteger(body.generation) && - body.generation >= 0 + Number.isInteger(body.generation) && + body.generation >= 0 ? body.generation : null; @@ -358,7 +431,9 @@ async function handleSessionSync( logPlaygroundEvent("session:sync-rejected", { reason: "missing-session-id", }); - sendJson(res, 400, { error: "Expected a valid playground session ID." }); + sendJson(res, 400, { + error: "Expected a valid playground session ID.", + }); return; } @@ -367,7 +442,9 @@ async function handleSessionSync( sessionId, reason: "missing-editor-state", }); - sendJson(res, 400, { error: "Expected a serialized editor state payload." }); + sendJson(res, 400, { + error: "Expected a serialized editor state payload.", + }); return; } if (revision == null || generation == null) { @@ -409,6 +486,7 @@ async function handleSessionSync( session.lastSyncedAt = Date.now(); session.syncedRevision = revision; session.syncedGeneration = syncedGeneration; + recordPlaygroundSessionSync(session); touchSession(session); previousEditor.destroy(); logPlaygroundEvent("session:sync-complete", { @@ -433,15 +511,13 @@ async function handleAIRequest( reason: "missing-api-key", }); sendJson(res, 500, { - error: - "Missing ANTHROPIC_API_KEY. Add it to playground/.env.local before starting the backend.", + error: "Missing ANTHROPIC_API_KEY. Add it to playground/.env.local before starting the backend.", }); return; } const body = (await readJsonBody(req)) ?? {}; - const prompt = - typeof body.prompt === "string" ? body.prompt.trim() : ""; + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; const sessionId = typeof body.sessionId === "string" ? body.sessionId : null; const isAISuggestionsRequest = @@ -451,14 +527,14 @@ async function handleAIRequest( const requestedOperation = parseRequestedOperation(body.operation); const expectedSyncRevision = typeof body.expectedSyncRevision === "number" && - Number.isInteger(body.expectedSyncRevision) && - body.expectedSyncRevision >= 0 + Number.isInteger(body.expectedSyncRevision) && + body.expectedSyncRevision >= 0 ? body.expectedSyncRevision : null; const expectedSyncedGeneration = typeof body.expectedSyncedGeneration === "number" && - Number.isInteger(body.expectedSyncedGeneration) && - body.expectedSyncedGeneration >= 0 + Number.isInteger(body.expectedSyncedGeneration) && + body.expectedSyncedGeneration >= 0 ? body.expectedSyncedGeneration : null; @@ -481,7 +557,9 @@ async function handleAIRequest( logPlaygroundEvent("ai:request-rejected", { reason: "missing-session-id", }); - sendJson(res, 400, { error: "Expected a valid playground session ID." }); + sendJson(res, 400, { + error: "Expected a valid playground session ID.", + }); return; } @@ -534,9 +612,9 @@ async function handleAIRequest( const resolvedOperation = requestedOperation != null ? remapRequestedOperationBlockIds( - requestedOperation, - session.clientToServerBlockIds, - ) + requestedOperation, + session.clientToServerBlockIds, + ) : null; const requestPlan = buildPlaygroundRequestPlan( session.editor, @@ -553,6 +631,7 @@ async function handleAIRequest( startedAt: performance.now(), ...createPlaygroundRequestMetricsSeed(requestPlan), }; + recordPlaygroundRequestMetadata(session, requestId, requestPlan.mode); const abortActiveRequest = () => { if (abortController.signal.aborted || res.writableEnded) { return; @@ -571,7 +650,9 @@ async function handleAIRequest( try { if (isAISuggestionsRequest && suggestionScope) { const result = await generateText({ - model: createPlaygroundLanguageModel(PLAYGROUND_SELECTION_MODEL), + model: createPlaygroundLanguageModel( + PLAYGROUND_SELECTION_MODEL, + ), system: AI_SUGGESTIONS_SYSTEM_PROMPT, prompt: JSON.stringify( { @@ -599,7 +680,10 @@ async function handleAIRequest( sendJson(res, 200, { suggestions: parseSuggestionResponse(result.text), usage: { - promptTokens: resolveUsageTokenValue(result.usage, "inputTokens"), + promptTokens: resolveUsageTokenValue( + result.usage, + "inputTokens", + ), completionTokens: resolveUsageTokenValue( result.usage, "outputTokens", @@ -645,15 +729,12 @@ async function handleAIRequest( (resolvedOperation.kind === "rewrite-selection" || resolvedOperation.kind === "rewrite-block" || resolvedOperation.kind === "continue-block" || - ( - resolvedOperation.kind === "document-transform" && + (resolvedOperation.kind === "document-transform" && resolvedOperation.target.kind === "document" && - ( - resolvedOperation.target.transform === "rewrite" || + (resolvedOperation.target.transform === "rewrite" || resolvedOperation.target.transform === "remove" || - resolvedOperation.target.placement === "replace-blocks" - ) - )); + resolvedOperation.target.placement === + "replace-blocks"))); if (isLocalOperation) { await streamLocalOperationResponse({ @@ -661,11 +742,12 @@ async function handleAIRequest( editor: session.editor, prompt, operation: resolvedOperation, - requestedMode: body.requestMode === "bottom-chat" || + requestedMode: + body.requestMode === "bottom-chat" || body.requestMode === "inline-edit" || body.requestMode === "structured-planner" - ? body.requestMode - : requestedMode, + ? body.requestMode + : requestedMode, requestPlan, abortSignal: abortController.signal, metrics, @@ -678,10 +760,14 @@ async function handleAIRequest( const result = streamText({ model: createPlaygroundLanguageModel(requestPlan.modelId), system: requestPlan.systemPrompt, - prompt: buildStructuredIntentModelPrompt(structuredIntentRequest), + prompt: buildStructuredIntentModelPrompt( + structuredIntentRequest, + ), output: Output.object({ schema: jsonSchema( - getStructuredIntentOutputSchema(structuredIntentRequest.targetKind), + getStructuredIntentOutputSchema( + structuredIntentRequest.targetKind, + ), ), }), ...(requestPlan.maxOutputTokens != null @@ -694,7 +780,8 @@ async function handleAIRequest( }); for await (const partial of result.partialOutputStream) { if (metrics.firstTextDeltaServerMs == null) { - metrics.firstTextDeltaServerMs = performance.now() - metrics.startedAt; + metrics.firstTextDeltaServerMs = + performance.now() - metrics.startedAt; logPlaygroundEvent("ai:first-structured-partial", { requestId, sessionId, @@ -718,9 +805,12 @@ async function handleAIRequest( prompt: requestPlan.prompt, ...(requestPlan.useTools ? { - tools: buildPlaygroundTools(session.editor, metrics), - stopWhen: stepCountIs(PLAYGROUND_MAX_TOOL_STEPS), - } + tools: buildPlaygroundTools( + session.editor, + metrics, + ), + stopWhen: stepCountIs(PLAYGROUND_MAX_TOOL_STEPS), + } : {}), ...(requestPlan.maxOutputTokens != null ? { maxOutputTokens: requestPlan.maxOutputTokens } @@ -734,7 +824,8 @@ async function handleAIRequest( abortSignal: abortController.signal, }); - const shouldStreamRawText = requestPlan.mode === "inline-autocomplete"; + const shouldStreamRawText = + requestPlan.mode === "inline-autocomplete"; const documentPayloadCollector = shouldStreamRawText ? null : createLocalOperationPayloadCollector(); @@ -742,7 +833,8 @@ async function handleAIRequest( for await (const part of result.fullStream) { if (part.type === "tool-call") { if (metrics.firstToolStartMs == null) { - metrics.firstToolStartMs = performance.now() - metrics.startedAt; + metrics.firstToolStartMs = + performance.now() - metrics.startedAt; logPlaygroundEvent("ai:first-tool-call", { requestId, sessionId, @@ -751,13 +843,17 @@ async function handleAIRequest( }); } metrics.toolCallCount += 1; - writeJsonLine(res, { type: "phase", phase: "tool-calling" }); + writeJsonLine(res, { + type: "phase", + phase: "tool-calling", + }); continue; } if (part.type === "text-delta") { if (metrics.firstTextDeltaServerMs == null) { - metrics.firstTextDeltaServerMs = performance.now() - metrics.startedAt; + metrics.firstTextDeltaServerMs = + performance.now() - metrics.startedAt; logPlaygroundEvent("ai:first-text-delta", { requestId, sessionId, @@ -773,7 +869,10 @@ async function handleAIRequest( continue; } const preview = documentPayloadCollector?.push(part.text); - if (preview?.changed && preview.text.length > lastSentLength) { + if ( + preview?.changed && + preview.text.length > lastSentLength + ) { const increment = preview.text.slice(lastSentLength); lastSentLength = preview.text.length; writeJsonLine(res, { type: "phase", phase: "writing" }); @@ -850,7 +949,10 @@ async function handleAIRequest( }); res.end(); } finally { - session.activeRequestCount = Math.max(0, session.activeRequestCount - 1); + session.activeRequestCount = Math.max( + 0, + session.activeRequestCount - 1, + ); touchSession(session); logPlaygroundEvent("ai:request-finish", { requestId, @@ -891,7 +993,9 @@ function handleListSkillsRequest( const skills = listDefaultAISkills(listAITools(resolved.toolRuntime), { autocompleteProviders: - getAutocompleteController(resolved.editor)?.listProviderDescriptors() ?? [], + getAutocompleteController( + resolved.editor, + )?.listProviderDescriptors() ?? [], }); sendJson(res, 200, { skills: skills.map((skill) => ({ @@ -946,7 +1050,7 @@ async function handleDirectToolRequest( } function createPlaygroundEditor(): Editor { - return createEditor({ + const editor = createHeadlessEditor({ preset: defaultPreset({ deltaStream: false, undo: false, @@ -954,6 +1058,8 @@ function createPlaygroundEditor(): Editor { schema: createDefaultSchema(), documentProfile: "structured", }); + ensurePlaygroundExtensionRoot(editor); + return editor; } function createPlaygroundSession(): PlaygroundSession { @@ -972,6 +1078,93 @@ function createPlaygroundSession(): PlaygroundSession { return session; } +function ensurePlaygroundExtensionRoot(editor: Editor) { + return ensureExtensionRoot({ + doc: getYjsDoc(editor), + namespace: PLAYGROUND_EXTENSION_ROOT_NAMESPACE, + version: PLAYGROUND_EXTENSION_ROOT_VERSION, + shape: { + requestIds: "array", + diagnostics: "map", + notes: "text", + }, + }); +} + +function recordPlaygroundRequestMetadata( + session: PlaygroundSession, + requestId: string, + requestMode: PlaygroundRequestMode, +): void { + const root = ensurePlaygroundExtensionRoot(session.editor); + const requestIds = root.map.get("requestIds"); + const diagnostics = root.map.get("diagnostics"); + if (requestIds instanceof Y.Array) { + requestIds.push([requestId]); + } + if (diagnostics instanceof Y.Map) { + diagnostics.set("lastRequestMode", requestMode); + diagnostics.set("lastRequestId", requestId); + diagnostics.set("lastRequestAt", new Date().toISOString()); + } +} + +function recordPlaygroundSessionSync(session: PlaygroundSession): void { + const root = ensurePlaygroundExtensionRoot(session.editor); + const diagnostics = root.map.get("diagnostics"); + if (diagnostics instanceof Y.Map) { + diagnostics.set("lastSyncedRevision", session.syncedRevision); + diagnostics.set("lastSyncedGeneration", session.syncedGeneration); + diagnostics.set( + "lastSyncedAt", + new Date(session.lastSyncedAt ?? Date.now()).toISOString(), + ); + } +} + +function createSessionDiagnostics( + session: PlaygroundSession, +): SessionDiagnosticsResponse { + const yDoc = getYjsDoc(session.editor); + const extensionRoot = readExtensionRoot({ + doc: yDoc, + namespace: PLAYGROUND_EXTENSION_ROOT_NAMESPACE, + }); + const rootMap = extensionRoot?.map; + const requestIds = rootMap?.get("requestIds"); + const diagnostics = rootMap?.get("diagnostics"); + const requestCount = requestIds instanceof Y.Array ? requestIds.length : 0; + const lastRequestMode = + diagnostics instanceof Y.Map + ? diagnostics.get("lastRequestMode") + : null; + const lastSyncedRevision = + diagnostics instanceof Y.Map + ? diagnostics.get("lastSyncedRevision") + : null; + + return { + sessionId: session.id, + headless: true, + blockCount: session.editor.documentState.blockOrder.length, + generation: session.editor.documentState.generation, + plainText: exportPlainText(session.editor), + stateVector: encodeYjsStateVectorBase64(yDoc), + extensionRoot: { + namespace: + extensionRoot?.namespace ?? PLAYGROUND_EXTENSION_ROOT_NAMESPACE, + version: extensionRoot?.version ?? 0, + requestCount, + lastRequestMode: + typeof lastRequestMode === "string" ? lastRequestMode : null, + lastSyncedRevision: + typeof lastSyncedRevision === "number" + ? lastSyncedRevision + : null, + }, + }; +} + function touchSession(session: PlaygroundSession): void { session.lastTouchedAt = Date.now(); } @@ -980,7 +1173,7 @@ function resolvePlaygroundToolRuntime( req: IncomingMessage, ): { editor: Editor; toolRuntime: ToolRuntime } | null { const sessionId = readHeader(req, SESSION_HEADER); - const session = sessionId ? sessions.get(sessionId) ?? null : null; + const session = sessionId ? (sessions.get(sessionId) ?? null) : null; const editor = session?.editor ?? null; if (!editor) { return null; @@ -1020,33 +1213,48 @@ function buildPlaygroundRequestPlan( requestedMode: PlaygroundRequestMode | null, requestedOperation: ModelRequestedOperation | null, ): PlaygroundRequestPlan { - return buildTypedSharedPlaygroundRequestPlan(editor, prompt, { - documentModel: PLAYGROUND_DOCUMENT_MODEL, - selectionModel: PLAYGROUND_SELECTION_MODEL, - documentSystemPrompt: PLAYGROUND_DOCUMENT_SYSTEM_PROMPT, - structuredPlannerSystemPrompt: PLAYGROUND_STRUCTURED_PLANNER_SYSTEM_PROMPT, - selectionFastPathSystemPrompt: PLAYGROUND_SELECTION_FAST_PATH_SYSTEM_PROMPT, - autocompleteSystemPrompt: AUTOCOMPLETE_SYSTEM_PROMPT, - selectionSourceCharLimit: PLAYGROUND_SELECTION_SOURCE_CHAR_LIMIT, - selectionStopSentinel: PLAYGROUND_SELECTION_STOP_SENTINEL, - selectionOutputTokenCap: PLAYGROUND_SELECTION_OUTPUT_TOKEN_CAP, - autocompleteOutputTokenCap: PLAYGROUND_AUTOCOMPLETE_OUTPUT_TOKEN_CAP, - selectionDefaultOutputTokens: PLAYGROUND_SELECTION_DEFAULT_OUTPUT_TOKENS, - selectionExpandOutputTokens: PLAYGROUND_SELECTION_EXPAND_OUTPUT_TOKENS, - selectionSummarizeOutputTokens: PLAYGROUND_SELECTION_SUMMARIZE_OUTPUT_TOKENS, - selectionTranslateOutputTokens: PLAYGROUND_SELECTION_TRANSLATE_OUTPUT_TOKENS, - }, requestedMode, requestedOperation); + return buildTypedSharedPlaygroundRequestPlan( + editor, + prompt, + { + documentModel: PLAYGROUND_DOCUMENT_MODEL, + selectionModel: PLAYGROUND_SELECTION_MODEL, + documentSystemPrompt: PLAYGROUND_DOCUMENT_SYSTEM_PROMPT, + structuredPlannerSystemPrompt: + PLAYGROUND_STRUCTURED_PLANNER_SYSTEM_PROMPT, + selectionFastPathSystemPrompt: + PLAYGROUND_SELECTION_FAST_PATH_SYSTEM_PROMPT, + autocompleteSystemPrompt: AUTOCOMPLETE_SYSTEM_PROMPT, + selectionSourceCharLimit: PLAYGROUND_SELECTION_SOURCE_CHAR_LIMIT, + selectionStopSentinel: PLAYGROUND_SELECTION_STOP_SENTINEL, + selectionOutputTokenCap: PLAYGROUND_SELECTION_OUTPUT_TOKEN_CAP, + autocompleteOutputTokenCap: + PLAYGROUND_AUTOCOMPLETE_OUTPUT_TOKEN_CAP, + selectionDefaultOutputTokens: + PLAYGROUND_SELECTION_DEFAULT_OUTPUT_TOKENS, + selectionExpandOutputTokens: + PLAYGROUND_SELECTION_EXPAND_OUTPUT_TOKENS, + selectionSummarizeOutputTokens: + PLAYGROUND_SELECTION_SUMMARIZE_OUTPUT_TOKENS, + selectionTranslateOutputTokens: + PLAYGROUND_SELECTION_TRANSLATE_OUTPUT_TOKENS, + }, + requestedMode, + requestedOperation, + ); } -function parsePlaygroundRequestMode(value: unknown): PlaygroundRequestMode | null { +function parsePlaygroundRequestMode( + value: unknown, +): PlaygroundRequestMode | null { const requestedMode = value === "document-agent" || - value === "structured-generation" || - value === "selection-fast" || - value === "inline-autocomplete" || - value === "bottom-chat" || - value === "inline-edit" || - value === "structured-planner" + value === "structured-generation" || + value === "selection-fast" || + value === "inline-autocomplete" || + value === "bottom-chat" || + value === "inline-edit" || + value === "structured-planner" ? (value as PlaygroundRequestedMode) : null; if (!requestedMode) { @@ -1081,7 +1289,9 @@ function resolveOperationRequestMode( return requestedMode; } -function parseRequestedOperation(value: unknown): ModelRequestedOperation | null { +function parseRequestedOperation( + value: unknown, +): ModelRequestedOperation | null { if (!value || typeof value !== "object") { return null; } @@ -1118,7 +1328,9 @@ function parseRequestedOperation(value: unknown): ModelRequestedOperation | null typeof candidate.target.sourceText === "string" && (candidate.target.kind !== "scoped-range" || (Array.isArray(candidate.target.blockIds) && - candidate.target.blockIds.every((blockId) => typeof blockId === "string") && + candidate.target.blockIds.every( + (blockId) => typeof blockId === "string", + ) && (candidate.target.contentFormat === "text" || candidate.target.contentFormat === "markdown") && (candidate.target.scope === "block" || @@ -1135,10 +1347,11 @@ function parseRequestedOperation(value: unknown): ModelRequestedOperation | null : null; } if (candidate.target.kind === "document") { - return ( - (candidate.target.blockIds === undefined || - (Array.isArray(candidate.target.blockIds) && - candidate.target.blockIds.every((blockId) => typeof blockId === "string"))) && + return (candidate.target.blockIds === undefined || + (Array.isArray(candidate.target.blockIds) && + candidate.target.blockIds.every( + (blockId) => typeof blockId === "string", + ))) && (candidate.target.placement === undefined || candidate.target.placement === "append-after-block" || candidate.target.placement === "replace-empty-block" || @@ -1147,7 +1360,6 @@ function parseRequestedOperation(value: unknown): ModelRequestedOperation | null candidate.target.transform === "write" || candidate.target.transform === "rewrite" || candidate.target.transform === "remove") - ) ? candidate : null; } @@ -1165,13 +1377,14 @@ function parseAISuggestionRequestScope( return typeof candidate.targetText === "string" && typeof candidate.contextBefore === "string" && typeof candidate.contextAfter === "string" && - (candidate.blockType === null || typeof candidate.blockType === "string") + (candidate.blockType === null || + typeof candidate.blockType === "string") ? { - blockType: (candidate.blockType as string | null) ?? null, - targetText: candidate.targetText, - contextBefore: candidate.contextBefore, - contextAfter: candidate.contextAfter, - } + blockType: (candidate.blockType as string | null) ?? null, + targetText: candidate.targetText, + contextBefore: candidate.contextBefore, + contextAfter: candidate.contextAfter, + } : null; } @@ -1212,9 +1425,13 @@ async function streamLocalOperationResponse(input: { sessionId, } = input; const usesClientInlineSelectionPreview = requestedMode === "inline-edit"; - const conflictReason = resolveRequestedOperationConflict(editor, operation, { - allowSelectionTextMismatch: usesClientInlineSelectionPreview, - }); + const conflictReason = resolveRequestedOperationConflict( + editor, + operation, + { + allowSelectionTextMismatch: usesClientInlineSelectionPreview, + }, + ); if (conflictReason) { writeJsonLine(res, { type: "conflict", @@ -1239,7 +1456,9 @@ async function streamLocalOperationResponse(input: { ...(requestPlan.temperature != null ? { temperature: requestPlan.temperature } : {}), - ...(requestPlan.stopSequences ? { stopSequences: requestPlan.stopSequences } : {}), + ...(requestPlan.stopSequences + ? { stopSequences: requestPlan.stopSequences } + : {}), abortSignal, }); @@ -1247,7 +1466,8 @@ async function streamLocalOperationResponse(input: { for await (const part of result.fullStream) { if (part.type === "text-delta") { if (metrics.firstTextDeltaServerMs == null) { - metrics.firstTextDeltaServerMs = performance.now() - metrics.startedAt; + metrics.firstTextDeltaServerMs = + performance.now() - metrics.startedAt; logPlaygroundEvent("ai:first-text-delta", { requestId, sessionId, @@ -1288,11 +1508,9 @@ function resolveLocalOperationFrameType( ): "replace-preview" | "replace-final" | "insert-preview" | "insert-final" { if ( operation.kind === "continue-block" || - ( - operation.kind === "document-transform" && + (operation.kind === "document-transform" && operation.target.kind === "document" && - operation.target.placement === "append-after-block" - ) + operation.target.placement === "append-after-block") ) { return phase === "preview" ? "insert-preview" : "insert-final"; } @@ -1304,7 +1522,9 @@ function resolveDocumentTransformTargetBlockIds( target: Extract, ): string[] { const requestedBlockIds = - target.blockIds?.filter((blockId) => editor.getBlock(blockId) != null) ?? []; + target.blockIds?.filter( + (blockId) => editor.getBlock(blockId) != null, + ) ?? []; if (requestedBlockIds.length > 0) { return requestedBlockIds; } @@ -1321,7 +1541,9 @@ function remapRequestedOperationBlockIds( clientToServerBlockIds: ReadonlyMap, ): ModelRequestedOperation { const remapBlockId = (blockId: string | null | undefined): string | null => - blockId == null ? null : (clientToServerBlockIds.get(blockId) ?? blockId); + blockId == null + ? null + : (clientToServerBlockIds.get(blockId) ?? blockId); if ( operation.target.kind === "selection" || operation.target.kind === "scoped-range" @@ -1333,22 +1555,26 @@ function remapRequestedOperationBlockIds( blockId: remapBlockId(operation.target.blockId), ...(operation.target.kind === "scoped-range" ? { - blockIds: operation.target.blockIds.map( - (blockId) => clientToServerBlockIds.get(blockId) ?? blockId, - ), - } + blockIds: operation.target.blockIds.map( + (blockId) => + clientToServerBlockIds.get(blockId) ?? + blockId, + ), + } : {}), anchor: { ...operation.target.anchor, blockId: - clientToServerBlockIds.get(operation.target.anchor.blockId) ?? - operation.target.anchor.blockId, + clientToServerBlockIds.get( + operation.target.anchor.blockId, + ) ?? operation.target.anchor.blockId, }, focus: { ...operation.target.focus, blockId: - clientToServerBlockIds.get(operation.target.focus.blockId) ?? - operation.target.focus.blockId, + clientToServerBlockIds.get( + operation.target.focus.blockId, + ) ?? operation.target.focus.blockId, }, }, }; @@ -1396,7 +1622,8 @@ function resolveRequestedOperationConflict( isScopedSelectionTarget(target) && operation.provenance?.syncedGeneration != null && operation.provenance.syncedGeneration >= 0 && - editor.documentState.generation !== operation.provenance.syncedGeneration + editor.documentState.generation !== + operation.provenance.syncedGeneration ) { return "The document changed before the operation started."; } @@ -1419,7 +1646,7 @@ function resolveRequestedOperationConflict( if ( operation.provenance?.blockRevision != null && editor.getBlockRevision(operation.target.blockId) !== - operation.provenance.blockRevision + operation.provenance.blockRevision ) { return "The target block changed before the operation started."; } @@ -1428,7 +1655,8 @@ function resolveRequestedOperationConflict( operation.target.kind === "document" && operation.provenance?.syncedGeneration != null && operation.provenance.syncedGeneration >= 0 && - editor.documentState.generation !== operation.provenance.syncedGeneration + editor.documentState.generation !== + operation.provenance.syncedGeneration ) { return "The document changed before the operation started."; } @@ -1452,15 +1680,20 @@ function buildPlaygroundTools( /* Server-side tool execution streams metrics, not editor deltas */ }); - return toolRuntime.listTools().reduce>>( - (accumulator, definition) => { + return toolRuntime + .listTools() + .reduce< + Record> + >((accumulator, definition) => { if (!PLAYGROUND_DIRECT_TOOL_NAMES.has(definition.name)) { return accumulator; } accumulator[definition.name] = { description: definition.description, - inputSchema: jsonSchema(definition.inputSchema as Record), + inputSchema: jsonSchema( + definition.inputSchema as Record, + ), execute: async (input: unknown) => { const startedAt = performance.now(); const result = await executeAITool( @@ -1471,15 +1704,14 @@ function buildPlaygroundTools( ); metrics.toolExecutionMs += performance.now() - startedAt; if (metrics.firstToolResultMs == null) { - metrics.firstToolResultMs = performance.now() - metrics.startedAt; + metrics.firstToolResultMs = + performance.now() - metrics.startedAt; } return result; }, } as unknown as ReturnType; return accumulator; - }, - {}, - ); + }, {}); } function hydrateEditor( @@ -1492,7 +1724,12 @@ function hydrateEditor( if (firstSerializedBlock && firstEditorBlock) { idMap.set(firstSerializedBlock.id, firstEditorBlock.id); - applyBlockSnapshot(editor, firstSerializedBlock, firstEditorBlock.id, idMap); + applyBlockSnapshot( + editor, + firstSerializedBlock, + firstEditorBlock.id, + idMap, + ); } for (const block of state.blocks.slice(1)) { @@ -1625,7 +1862,7 @@ function normalizeBlockProps( ): Record { const normalizedParentId = typeof props.parentId === "string" - ? idMap.get(props.parentId) ?? props.parentId + ? (idMap.get(props.parentId) ?? props.parentId) : props.parentId; return { @@ -1649,9 +1886,7 @@ async function readJsonBody( const chunks: Uint8Array[] = []; for await (const chunk of req) { - chunks.push( - typeof chunk === "string" ? Buffer.from(chunk) : chunk, - ); + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } if (chunks.length === 0) { @@ -1795,7 +2030,10 @@ function shutdownPlaygroundServer(signal: NodeJS.Signals): void { collaborationWebSocketServer.close(); clearTimeout(exitTimer); if (error) { - console.error("Failed to close playground AI backend cleanly:", error); + console.error( + "Failed to close playground AI backend cleanly:", + error, + ); process.exit(1); return; } diff --git a/playground/src/components/DebugPanel.tsx b/playground/src/components/DebugPanel.tsx index a3a0757..388ef52 100644 --- a/playground/src/components/DebugPanel.tsx +++ b/playground/src/components/DebugPanel.tsx @@ -1,7 +1,9 @@ import "./DebugPanel.css"; import type { Editor } from "@pen/types"; import { useAIDebugLog, useAISuggestionsMetrics } from "@pen/react"; +import { useEffect, useState } from "react"; import { PLAYGROUND_AI_SESSION_ID_PREVIEW_LENGTH } from "../constants/playgroundAI"; +import { PLAYGROUND_AI_SESSION_DIAGNOSTICS_ENDPOINT } from "../constants/playgroundAI"; import { usePlaygroundAIState } from "../hooks/usePlaygroundAISession"; type DebugPanelProps = { @@ -14,6 +16,22 @@ type DebugPanelProps = { variant?: "sidebar" | "dock"; }; +interface PlaygroundSessionDiagnostics { + sessionId: string; + headless: boolean; + blockCount: number; + generation: number; + plainText: string; + stateVector: string; + extensionRoot: { + namespace: string; + version: number; + requestCount: number; + lastRequestMode: string | null; + lastSyncedRevision: number | null; + }; +} + export function DebugPanel({ editor, sessionId, @@ -26,23 +44,74 @@ export function DebugPanel({ const debugLog = useAIDebugLog(editor, { sessionId }); const aiSuggestionsMetrics = useAISuggestionsMetrics(editor); const playgroundAIState = usePlaygroundAIState(); + const [sessionDiagnostics, setSessionDiagnostics] = + useState(null); const sessionLabel = playgroundAIState.sessionId ? playgroundAIState.sessionId.slice( - 0, - PLAYGROUND_AI_SESSION_ID_PREVIEW_LENGTH, - ) + 0, + PLAYGROUND_AI_SESSION_ID_PREVIEW_LENGTH, + ) : "None"; const syncLabel = formatMetricMs(playgroundAIState.lastSyncMs); const backendPhaseLabel = formatPhaseLabel(playgroundAIState.phase); const lastRequest = playgroundAIState.lastRequest; - const requestModeLabel = formatRequestMode(lastRequest?.requestMode ?? null); - const requestModelLabel = formatRequestModel(lastRequest?.requestModel ?? null); - const contextFormatLabel = formatContextFormat(lastRequest?.contextFormat ?? null); + const requestModeLabel = formatRequestMode( + lastRequest?.requestMode ?? null, + ); + const requestModelLabel = formatRequestModel( + lastRequest?.requestModel ?? null, + ); + const contextFormatLabel = formatContextFormat( + lastRequest?.contextFormat ?? null, + ); const contextTokensLabel = formatContextTokens( lastRequest?.contextEstimatedTokensJson ?? null, lastRequest?.contextFormat ?? null, ); + const lastRequestId = lastRequest?.requestId ?? null; + useEffect(() => { + const currentSessionId = playgroundAIState.sessionId; + if (!currentSessionId) { + setSessionDiagnostics(null); + return; + } + + const abortController = new AbortController(); + void fetch( + `${PLAYGROUND_AI_SESSION_DIAGNOSTICS_ENDPOINT}?sessionId=${encodeURIComponent(currentSessionId)}`, + { signal: abortController.signal }, + ) + .then(async (response) => { + if (!response.ok) { + throw new Error( + `Diagnostics request failed: ${response.status}`, + ); + } + return (await response.json()) as PlaygroundSessionDiagnostics; + }) + .then((diagnostics) => { + setSessionDiagnostics(diagnostics); + }) + .catch((error: unknown) => { + if ( + error instanceof DOMException && + error.name === "AbortError" + ) { + return; + } + setSessionDiagnostics(null); + }); + + return () => { + abortController.abort(); + }; + }, [ + playgroundAIState.lastSyncAt, + playgroundAIState.sessionId, + lastRequestId, + ]); + const aggregateFastApply = debugLog.aggregateFastApply; const performanceMetricItems = [ { @@ -65,6 +134,34 @@ export function DebugPanel({ label: "Session", value: sessionLabel, }, + { + label: "Headless backend", + value: sessionDiagnostics?.headless ? "Yes" : "Pending", + }, + { + label: "Yjs state vector", + value: sessionDiagnostics + ? truncateDebugValue(sessionDiagnostics.stateVector) + : "Pending", + }, + { + label: "Plain text chars", + value: sessionDiagnostics + ? `${sessionDiagnostics.plainText.length}` + : "Pending", + }, + { + label: "Extension root", + value: sessionDiagnostics + ? `${sessionDiagnostics.extensionRoot.namespace}@${sessionDiagnostics.extensionRoot.version}` + : "Pending", + }, + { + label: "Extension requests", + value: sessionDiagnostics + ? `${sessionDiagnostics.extensionRoot.requestCount}` + : "Pending", + }, { label: "Last sync", value: syncLabel, @@ -91,15 +188,21 @@ export function DebugPanel({ }, { label: "Fast apply native", - value: formatFastApplyMetricCount(aggregateFastApply.nativeFastApplyCount), + value: formatFastApplyMetricCount( + aggregateFastApply.nativeFastApplyCount, + ), }, { label: "Fast apply scoped", - value: formatFastApplyMetricCount(aggregateFastApply.scopedReplacementCount), + value: formatFastApplyMetricCount( + aggregateFastApply.scopedReplacementCount, + ), }, { label: "Fast apply plain", - value: formatFastApplyMetricCount(aggregateFastApply.plainMarkdownCount), + value: formatFastApplyMetricCount( + aggregateFastApply.plainMarkdownCount, + ), }, { label: "Fast apply failed", @@ -183,7 +286,9 @@ export function DebugPanel({

Debug

-
{performanceMetricRows}
+
+ {performanceMetricRows} +
@@ -260,6 +365,14 @@ function formatRequestModel(value: string | null): string { return value; } +function truncateDebugValue(value: string, limit = 12): string { + if (value.length <= limit) { + return value; + } + + return `${value.slice(0, limit)}...`; +} + function formatContextFormat(value: string | null): string { if (value === "json") { return "JSON"; diff --git a/playground/src/constants/playgroundAI.ts b/playground/src/constants/playgroundAI.ts index d9bd3d1..209bfd6 100644 --- a/playground/src/constants/playgroundAI.ts +++ b/playground/src/constants/playgroundAI.ts @@ -1,6 +1,8 @@ export const PLAYGROUND_AI_ENDPOINT = "/api/ai"; export const PLAYGROUND_AI_SESSION_ENDPOINT = "/api/ai/session"; export const PLAYGROUND_AI_SESSION_SYNC_ENDPOINT = "/api/ai/session/sync"; +export const PLAYGROUND_AI_SESSION_DIAGNOSTICS_ENDPOINT = + "/api/ai/session/diagnostics"; export const PLAYGROUND_AI_SYNC_DEBOUNCE_MS = 200; export const PLAYGROUND_AI_DIRECT_STREAM_BATCH_INTERVAL_MS = 120; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42f5df4..68a9d2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -987,6 +987,9 @@ importers: '@pen/export-html': specifier: workspace:* version: link:../packages/extensions/export-html + '@pen/export-json': + specifier: workspace:^ + version: link:../packages/extensions/export-json '@pen/export-markdown': specifier: workspace:* version: link:../packages/extensions/export-markdown From 6d394e3d80f33ddd51ae1ee621b063031db5556b Mon Sep 17 00:00:00 2001 From: krijn Date: Sat, 9 May 2026 11:38:21 +0200 Subject: [PATCH 06/20] Remove headless collaboration AI waves roadmap document - Deleted the `headless-collaboration-ai-waves.md` file, which outlined the roadmap for Pen library improvements related to local-first applications, AI workflows, and collaboration features. - This removal reflects a shift in focus or restructuring of documentation within the project. --- .../headless-collaboration-ai-waves.md | 579 ------------------ 1 file changed, 579 deletions(-) delete mode 100644 spec/roadmap/headless-collaboration-ai-waves.md diff --git a/spec/roadmap/headless-collaboration-ai-waves.md b/spec/roadmap/headless-collaboration-ai-waves.md deleted file mode 100644 index b8238b9..0000000 --- a/spec/roadmap/headless-collaboration-ai-waves.md +++ /dev/null @@ -1,579 +0,0 @@ -# Headless Collaboration And AI Primitives Roadmap - -## Status - -Roadmap proposal for Pen library improvements that support local-first host applications, synchronized AI workflows, server-side rendering/export, and cross-client collaboration. - -This document is intentionally roadmap-oriented. The rest of `spec/` remains current-state and package-centric. These waves should become package specs or package updates as they are implemented. - -## Product Boundary - -Pen must remain a headless, open source editor library. - -Pen should provide generic primitives for: - -- CRDT documents, -- collaboration state, -- structured mutation origins, -- grouped undo/update semantics, -- headless server editor construction, -- app-owned extension roots, -- export hooks, -- field adapters, -- deterministic fixtures. - -Pen must not provide product-specific semantics for: - -- email, -- recipients, -- subject lines, -- send/provider workflows, -- Input-specific sync tables, -- app auth, -- app model routing, -- system prompts, -- external provider secrets. - -Host apps such as Input own those product concerns. - -## Why This Matters - -The Input Pen email architecture needs to: - -- wait for a Yjs document to reach a requested state before AI/send workers run, -- apply AI edits as a single grouped mutation, -- let server workers create headless editors from YDocs, -- keep app metadata roots such as `mail` organized without raw Yjs access everywhere, -- export HTML/text through a consistent server-side pipeline, -- bind non-body CRDT fields such as subject and recipients, -- share deterministic fixtures between web and API. - -Input can build these locally, but the same primitives are valuable for any app building collaborative documents, AI-assisted editing, CMS workflows, comments, notes, docs, or issue descriptions. The right move is to improve Pen generically rather than adding `toMail` or email-specific APIs. - -## Cross-Wave Invariants - -- `editor.apply(...)` remains the canonical document mutation path. -- Pen never owns host auth, persistence policy, transport secrets, or product workflow state. -- Renderer packages do not become document sources of truth. -- CRDT/Yjs helpers stay in `@pen/crdt-yjs` unless they become implementation-agnostic contracts. -- Export packages emit fragments/artifacts; host apps own final wrappers, sanitization policy, and delivery. -- AI helpers remain model/provider agnostic. -- All new APIs must work in headless/server environments. - -## Wave Order - -1. CRDT state vectors and synchronization barriers. -2. Structured mutation origins and update groups. -3. Headless editor factory and extension roots. -4. Export pipeline hooks and plain-text artifact support. -5. CRDT field adapters. -6. Deterministic fixtures, contract tests, and docs. - -## Wave 1: CRDT State Vectors And Synchronization Barriers - -### Wave 1 Goal - -Make Yjs state-vector comparison and serialization a supported Pen capability so host apps do not hand-roll clock comparison. - -### Wave 1 Package - -Primary package: - -```text -packages/crdt/yjs -``` - -Possible shared contracts: - -```text -packages/types -``` - -### Wave 1 Public API - -Add helpers like: - -```ts -encodeYjsStateVector(doc): Uint8Array -encodeYjsStateVectorBase64(doc): string -decodeYjsStateVectorBase64(value): Uint8Array -isYjsStateVectorSatisfied(current, required): boolean -compareYjsStateVectors(current, required): YjsStateVectorComparison -``` - -Suggested result type: - -```ts -type YjsStateVectorComparison = { - satisfied: boolean; - missingClients: Array<{ - clientId: number; - currentClock: number; - requiredClock: number; - }>; -}; -``` - -Rules: - -- Decode state vectors with Yjs APIs, not ad hoc parsing. -- Missing current client clocks count as `0`. -- Extra current client IDs do not make comparison fail. -- Malformed vectors fail closed and return diagnostics. -- Base64 helpers should be explicit; do not hide binary/text conversion in unrelated APIs. - -### Wave 1 Non-Goals - -- Do not add Durable Streams-specific offsets to Pen. -- Do not add host workflow rows or request concepts. -- Do not add networking or waiting/polling to core state-vector helpers. - -### Wave 1 Tests - -- identical vectors satisfy, -- current vector with higher clocks satisfies, -- missing client fails, -- lower clock fails, -- extra current clients are ignored, -- malformed base64 fails with diagnostic, -- helpers work for empty documents, -- helpers work after applying merged updates. - -### Wave 1 Input Impact - -Input can replace app-local `isEmailDraftStateBarrierSatisfied(...)` internals with Pen-provided Yjs comparison while keeping the app-level helper name and workflow semantics. - -## Wave 2: Structured Mutation Origins And Update Groups - -### Wave 2 Goal - -Make grouped mutations and origin metadata first-class enough for AI edits, undo grouping, attribution, diagnostics, and cross-client "go back" workflows. - -### Wave 2 Packages - -Primary packages: - -```text -packages/types -packages/core -packages/crdt/yjs -packages/extensions/undo -packages/extensions/history -``` - -### Wave 2 Public API - -Support structured origins in addition to existing string origins: - -```ts -type MutationOrigin = - | "user" - | "ai" - | "collaborator" - | "input-rule" - | { - type: string; - groupId?: string; - requestId?: string; - actorId?: string; - source?: string; - }; -``` - -Add grouped apply helpers: - -```ts -editor.applyGrouped(ops, { - origin: { type: "ai", groupId, requestId }, -}); -``` - -or keep `editor.apply(...)` as the only API but standardize grouped options: - -```ts -editor.apply(ops, { - origin: { type: "ai", groupId, requestId }, - groupId, -}); -``` - -Undo/history should preserve group metadata: - -```ts -type MutationGroupMetadata = { - groupId: string; - originType: string; - requestId?: string; - actorId?: string; -}; -``` - -### Wave 2 Rules - -- Existing string origins remain supported. -- Yjs transactions should receive stable origin objects or tags that undo tracking can understand. -- Undo stack items should expose group metadata. -- History/suggestion/AI flows should not need to infer grouped mutations by timestamp. -- Group IDs are host-provided or generated by Pen helpers; they are not product-specific. - -### Wave 2 Non-Goals - -- Do not add Input prompt/request rows. -- Do not define model providers. -- Do not define "email AI" behavior. - -### Wave 2 Tests - -- string origins remain backward compatible, -- structured origins are tracked by undo manager, -- grouped AI mutation becomes one undo item, -- stack item metadata includes group ID, -- redo preserves group metadata, -- collaboration updates preserve enough metadata for diagnostics where feasible, -- history extension can filter/group by origin type. - -### Wave 2 Input Impact - -Input can record `applied_update_group_id` and rely on Pen to make the corresponding AI mutation one logical undoable unit. - -## Wave 3: Headless Editor Factory And Extension Roots - -### Wave 3 Goal - -Give server workers and host apps a safe, boring path to create headless editors from CRDT documents and app-owned metadata roots. - -### Wave 3 Packages - -Primary packages: - -```text -packages/core -packages/crdt/yjs -packages/types -``` - -### Wave 3 Public API - -Headless editor factory: - -```ts -createHeadlessEditor({ - document, - schema, - preset, - documentProfile, - extensions, - onDiagnostic, -}); -``` - -This can be a documented alias or wrapper around existing editor creation if the capability already exists internally. The important point is a stable server-safe entrypoint. - -Extension root helpers: - -```ts -ensureExtensionRoot(doc, { - namespace: "input.mail", - version: 1, - shape, -}); - -readExtensionRoot(doc, "input.mail"); -``` - -`shape` should be a lightweight validation/initialization contract. It should not require Pen to know host product semantics. - -### Wave 3 Rules - -- Extension roots are namespaced. -- Pen validates presence/version/shape at a generic level. -- Host apps own fields inside their roots. -- Helpers should avoid raw `Y.Map` access leaking through product code. -- Headless editor construction must not require DOM or renderer packages. - -### Wave 3 Non-Goals - -- Do not add a built-in `mail` root. -- Do not add recipient/subject concepts. -- Do not add server transport policy. - -### Wave 3 Tests - -- headless editor can be constructed from a wrapped YDoc, -- missing Pen roots are initialized or diagnosed according to options, -- extension root initialization is idempotent, -- version mismatch produces diagnostic, -- root helpers do not mutate unrelated roots, -- headless editor can export after construction. - -### Wave 3 Input Impact - -Input's API workers can load a YDoc, ensure Pen roots and the `input.mail` extension root, then create a headless editor for AI/export without custom bootstrapping in every worker. - -## Wave 4: Export Pipeline Hooks And Plain-Text Artifacts - -### Wave 4 Goal - -Make export more composable for host-defined targets such as web previews, markdown, plain text, and product-specific delivery formats while keeping Pen responsible only for document fragments/artifacts. - -### Wave 4 Packages - -Primary packages: - -```text -packages/extensions/export-html -packages/extensions/export-markdown -packages/core -packages/types -``` - -Possible new package: - -```text -packages/extensions/export-text -``` - -### Wave 4 Public API - -Extend export options generically: - -```ts -type ExportOptions = { - includeSuggestions?: boolean; - target?: string; - hooks?: ExportHooks; - extra?: Extra; -}; -``` - -Hooks: - -```ts -type ExportHooks = { - block?: (context) => string | undefined; - inline?: (context) => string | undefined; - asset?: (context) => ExportAsset | undefined; - afterBlock?: (context) => string | undefined; -}; -``` - -Suggestion policy: - -```ts -type SuggestionExportMode = - | "raw" - | "resolved" - | "accepted-only" - | "rejected-only"; -``` - -Plain-text artifact: - -```ts -exportPlainText(editor, options): string -``` - -### Wave 4 Rules - -- HTML exporter still returns fragments, not full delivery documents. -- Host apps own wrappers, CSS inlining, sanitization, provider quirks, and final delivery. -- Export hooks must be deterministic and side-effect free. -- Traversal must include nested/layout children. -- Defaults must preserve current output. - -### Wave 4 Non-Goals - -- Do not implement `toMail`. -- Do not add host delivery compatibility policy to Pen. -- Do not sanitize final host output in Pen unless a generic sanitizer package is explicitly introduced. - -### Wave 4 Tests - -- current HTML snapshots remain stable by default, -- `target` is passed to block/inline hooks, -- host block override works without modifying schema, -- suggestion export modes behave consistently, -- plain text traversal includes nested children, -- database/table export still works, -- unknown target falls back safely. - -### Wave 4 Input Impact - -Input can use Pen for stable fragment/text export while keeping mail wrappers, quote handling, footer insertion, and sanitization inside Input. - -## Wave 5: CRDT Field Adapters - -### Wave 5 Goal - -Provide generic adapters for non-body CRDT fields such as titles, labels, tags, recipients-like arrays, and app-owned structured fields. - -### Wave 5 Packages - -Primary package: - -```text -packages/crdt/yjs -``` - -Possible shared contracts: - -```text -packages/types -``` - -### Wave 5 Public API - -Text field adapter: - -```ts -createYTextFieldAdapter({ - doc, - root, - key, - normalize?, -}); -``` - -Array/map field adapter: - -```ts -createYArrayFieldAdapter({ - doc, - root, - key, - itemSchema, - getId, - normalizeItem?, -}); -``` - -Returned capabilities: - -```ts -read() -replace(value) -insert(item, index?) -update(id, patch) -remove(id) -observe(callback) -``` - -### Wave 5 Rules - -- Adapters are generic CRDT helpers, not form components. -- They should work in browser and server. -- They should support stable item IDs. -- They should keep normalization optional and host-provided. -- They should not know about recipients, subject, email addresses, or contacts. - -### Wave 5 Non-Goals - -- Do not add UI bindings to core adapters. -- Do not add app validation rules. -- Do not add schema-default fields. - -### Wave 5 Tests - -- Y.Text field reads/writes/observes, -- array adapter inserts/removes by stable ID, -- concurrent item updates do not replace the whole array, -- normalization is applied consistently, -- server-side use works without DOM, -- malformed item data emits diagnostics or fails safely. - -### Wave 5 Input Impact - -Input can bind subject and recipient arrays through generic field adapters instead of custom Yjs wrappers. - -## Wave 6: Deterministic Fixtures, Contract Tests, And Docs - -### Wave 6 Goal - -Make headless CRDT/editor/export behavior easy to test across host apps and Pen packages. - -### Wave 6 Packages - -Primary packages: - -```text -packages/tooling/test -packages/crdt/yjs -packages/core -``` - -### Wave 6 Public API - -Fixture helpers: - -```ts -createDeterministicYDocFixture(...) -encodeFixtureUpdate(...) -normalizeDocumentForSnapshot(...) -assertDocumentRoots(...) -``` - -Contract test helpers: - -```ts -runCRDTStateVectorContract(...) -runHeadlessEditorContract(...) -runExportContract(...) -``` - -### Wave 6 Rules - -- Fixtures must avoid real personal data. -- Helpers must be deterministic. -- Helpers should be usable by host apps without private Pen internals. -- Contract tests should be opt-in and package-friendly. - -### Wave 6 Non-Goals - -- Do not create Input-specific fixtures in Pen. -- Do not require host apps to use Pen's test runner. -- Do not encode product-specific expected outputs. - -### Wave 6 Tests - -- deterministic fixture generation is stable, -- normalized snapshots are stable across clients, -- contract helpers can run in Node, -- malformed fixture helpers produce useful diagnostics. - -### Wave 6 Input Impact - -Input's `spec/fixtures/email-drafts/` can use Pen fixture tooling to create stable YDoc updates and verify projection/export/state-barrier behavior across web and API. - -## Documentation Updates - -As waves ship, update: - -- `spec/packages/crdt/yjs.md`, -- `spec/packages/core.md`, -- `spec/packages/extensions/export-html.md`, -- `spec/packages/extensions/ai.md`, -- package READMEs, -- playground examples where helpful. - -Examples should stay generic: - -- collaborative title/body document, -- AI rewrite with grouped undo, -- headless server export, -- extension root for app metadata, -- field adapter for tags or labels. - -Do not use a mail workflow as the primary Pen example unless it is clearly framed as a host-app pattern outside core Pen semantics. - -## Rollout Guidance - -Recommended order for Input alignment: - -1. Ship Wave 1 before Input implements state barriers. -2. Ship Wave 2 before Input finalizes AI go-back semantics. -3. Ship Wave 3 before Input builds API AI/send workers. -4. Ship Wave 4 before Input locks server export. -5. Ship Wave 5 before Input builds custom subject/recipient Yjs adapters, if timing allows. -6. Ship Wave 6 when Input begins shared fixture work. - -Input can proceed with local wrappers if a Pen wave is not ready, but those wrappers should mirror the proposed Pen API so they can collapse back into the library later. From 47a087d324517c70da0d08f51a0a3e6208e80906 Mon Sep 17 00:00:00 2001 From: krijn Date: Mon, 11 May 2026 15:15:22 +0200 Subject: [PATCH 07/20] Improve editor input backend synchronization --- .../field-editor/contenteditableBackend.ts | 134 +- .../src/field-editor/editContextBackend.ts | 593 ++++- .../dom/src/field-editor/fieldEditorImpl.ts | 78 +- .../src/__tests__/editorCaretOverlay.test.tsx | 153 +- .../__tests__/selectedTextDeletion.test.tsx | 2080 +++++++++++++---- .../src/primitives/editor/caretOverlay.tsx | 50 +- .../src/primitives/editor/inlineContent.tsx | 74 +- .../react/src/primitives/editor/root.tsx | 80 +- 8 files changed, 2570 insertions(+), 672 deletions(-) diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index 43b3ae7..435f0a9 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -1,4 +1,9 @@ -import type { DocumentOp, Editor, InlineDecoration, InputBackend } from "@pen/types"; +import type { + DocumentOp, + Editor, + InlineDecoration, + InputBackend, +} from "@pen/types"; import type { FieldEditorInputController } from "./controller"; import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; import { @@ -37,6 +42,7 @@ export class ContentEditableBackend implements InputBackend { private compositionStartTimestamp = 0; private compositionStartText: string | null = null; private deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }> = []; + private pendingDomSyncFrame: number | null = null; private pendingSelectionOverride: { blockId: string; anchorOffset: number; @@ -128,6 +134,10 @@ export class ContentEditableBackend implements InputBackend { this.mutationObserver.disconnect(); this.mutationObserver = null; } + if (this.pendingDomSyncFrame != null) { + cancelAnimationFrame(this.pendingDomSyncFrame); + this.pendingDomSyncFrame = null; + } if (this.observer && this.ytext) { this.ytext.unobserve(this.observer); } @@ -230,7 +240,9 @@ export class ContentEditableBackend implements InputBackend { } else { this.fieldEditor.syncTextSelection(blockId, nextOffset, nextOffset); } + this.ensureActiveDOMMatchesYText(); this.restoreDOMSelectionFromEditor(); + this.scheduleActiveDOMMatchCheck(); this.pendingSelectionOverride = null; } @@ -305,18 +317,18 @@ export class ContentEditableBackend implements InputBackend { const anchor = pendingSelection != null ? { - blockId: pendingSelection.blockId, - offset: pendingSelection.anchorOffset, - } + blockId: pendingSelection.blockId, + offset: pendingSelection.anchorOffset, + } : selection?.type === "text" ? selection.anchor : null; const focus = pendingSelection != null ? { - blockId: pendingSelection.blockId, - offset: pendingSelection.focusOffset, - } + blockId: pendingSelection.blockId, + offset: pendingSelection.focusOffset, + } : selection?.type === "text" ? selection.focus : null; @@ -352,6 +364,13 @@ export class ContentEditableBackend implements InputBackend { const handler = DIRECT_HANDLERS[event.inputType]; if (handler) { + if ( + requiresResolvedInputRange(event.inputType) && + !this.ensureResolvableInputRange(event) + ) { + return; + } + event.preventDefault(); handler( event, @@ -367,6 +386,19 @@ export class ContentEditableBackend implements InputBackend { // Let the mutation observer reconcile input types we do not handle directly. }; + private ensureResolvableInputRange(event: InputEvent): boolean { + if (!this.element) { + return false; + } + if (canResolveInputRange(event, this.element)) { + return true; + } + + this.restoreDOMSelectionFromEditor(); + + return canResolveInputRange(event, this.element); + } + // ── Composition handling ────────────────────────────────── private handleCompositionStart = (): void => { @@ -465,7 +497,9 @@ export class ContentEditableBackend implements InputBackend { } const blockId = this.fieldEditor.focusBlockId; - const isActiveCell = blockId ? !!this._getActiveCellCoord(blockId) : false; + const isActiveCell = blockId + ? !!this._getActiveCellCoord(blockId) + : false; if (isActiveCell) { fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { preserveSelection: true, @@ -552,7 +586,10 @@ export class ContentEditableBackend implements InputBackend { blockId, offset: op.offset, text: op.text, - marks: this.fieldEditor.resolveInsertMarks(ytext, op.offset), + marks: this.fieldEditor.resolveInsertMarks( + ytext, + op.offset, + ), }); } } @@ -565,7 +602,9 @@ export class ContentEditableBackend implements InputBackend { blockId, anchorOffset: range.start, focusOffset: range.end, - cell: cellCoord ? { row: cellCoord.row, col: cellCoord.col } : undefined, + cell: cellCoord + ? { row: cellCoord.row, col: cellCoord.col } + : undefined, }; } @@ -575,12 +614,45 @@ export class ContentEditableBackend implements InputBackend { if (cellCoord) { this.activeCellSelection = range; } else { - this.fieldEditor.syncTextSelection(blockId, range.start, range.end); + this.fieldEditor.syncTextSelection( + blockId, + range.start, + range.end, + ); } } + this.ensureActiveDOMMatchesYText(); + this.restoreDOMSelectionFromEditor(); + this.scheduleActiveDOMMatchCheck(); this.pendingSelectionOverride = null; } + private ensureActiveDOMMatchesYText(): boolean { + if (!this.element || !this.ytext) return false; + if (extractTextFromDOM(this.element) === this.ytext.toString()) { + return false; + } + + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + return true; + } + + private scheduleActiveDOMMatchCheck(): void { + if (this.pendingDomSyncFrame != null) { + cancelAnimationFrame(this.pendingDomSyncFrame); + } + + this.pendingDomSyncFrame = requestAnimationFrame(() => { + this.pendingDomSyncFrame = null; + if (this.ensureActiveDOMMatchesYText()) { + this.restoreDOMSelectionFromEditor(); + } + }); + } + private getInlineDecorationsForBlock(): readonly InlineDecoration[] { const blockId = this.fieldEditor.focusBlockId; if (!blockId) { @@ -590,7 +662,8 @@ export class ContentEditableBackend implements InputBackend { .getDecorations() .forBlock(blockId) .filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", + (decoration): decoration is InlineDecoration => + decoration.type === "inline", ); } @@ -614,7 +687,11 @@ export class ContentEditableBackend implements InputBackend { private handleSelectionChange = (): void => { if (!this.element) return; - if (!this.fieldEditor.shouldHandleDomSelectionChange(this.isApplyingSelection)) { + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + this.isApplyingSelection, + ) + ) { return; } @@ -955,6 +1032,33 @@ function hasMultiBlockTextSelection(editor: Editor): boolean { return selection?.type === "text" && selection.isMultiBlock; } +function requiresResolvedInputRange(inputType: string): boolean { + return ( + inputType === "insertText" || + inputType === "insertReplacementText" || + inputType === "deleteContentBackward" || + inputType === "deleteContentForward" || + inputType === "deleteByCut" || + inputType === "deleteWordBackward" || + inputType === "deleteWordForward" || + inputType === "insertLineBreak" + ); +} + +function canResolveInputRange( + event: InputEvent, + element: HTMLElement, +): boolean { + if (event.inputType === "insertReplacementText") { + const targetRanges = event.getTargetRanges?.(); + if (targetRanges?.length) { + return staticRangeToOffsets(targetRanges[0], element) !== null; + } + } + + return getSelectionOffsets(element) !== null; +} + /** * Convert a StaticRange (from getTargetRanges) to character offsets * within the inline content element. @@ -1034,7 +1138,8 @@ function setSelectionOffsets( selection.addRange(collapseRange); if ( - (startPoint.node !== endPoint.node || startPoint.offset !== endPoint.offset) && + (startPoint.node !== endPoint.node || + startPoint.offset !== endPoint.offset) && typeof selection.extend === "function" ) { selection.extend(endPoint.node, endPoint.offset); @@ -1171,4 +1276,3 @@ function mapOffsetThroughRemoteDeltas( return mappedOffset; } - diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index 176f846..f28001a 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -1,5 +1,10 @@ import { INPUT_RULES_ENGINE_SLOT_KEY } from "@pen/types"; -import type { DocumentOp, Editor, InlineDecoration, InputBackend } from "@pen/types"; +import type { + DocumentOp, + Editor, + InlineDecoration, + InputBackend, +} from "@pen/types"; import { supportsInlineInputRules } from "@pen/types"; import type { FieldEditorInputController } from "./controller"; import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; @@ -30,6 +35,21 @@ type EditContextTextUpdateEvent = Event & { selectionEnd?: number; }; +type EditContextSelection = { + blockId: string; + anchorOffset: number; + focusOffset: number; +}; + +type EditContextSelectionOptions = { + source?: "text-update"; +}; + +type EditContextRange = { + start: number; + end: number; +}; + type EditContextTextFormat = { rangeStart: number; rangeEnd: number; @@ -46,6 +66,8 @@ type EditContextCharacterBoundsUpdateEvent = Event & { rangeEnd: number; }; +const ZERO_WIDTH_SPACE = "\u200B"; + declare class EditContext { constructor(options?: { text?: string; @@ -73,11 +95,11 @@ export class EditContextBackend implements InputBackend { private ytext: FieldEditorTextLike | null = null; private observer: FieldEditorObserver | null = null; private isApplyingSelection = 0; - private pendingSelectionOverride: { - blockId: string; - anchorOffset: number; - focusOffset: number; - } | null = null; + private editContextSelection: EditContextSelection | null = null; + // A textupdate carries the freshest post-input caret. Keep it authoritative + // until a real user selection gesture or navigation key moves the caret. + private authoritativeTextInputSelection: EditContextSelection | null = null; + private pendingSelectionOverride: EditContextSelection | null = null; private editor: Editor; private fieldEditor: FieldEditorInputController; @@ -91,15 +113,23 @@ export class EditContextBackend implements InputBackend { this.ytext = ytext as FieldEditorTextLike; this.fieldEditor.setComposing(false); - const editContextConstructor = (globalThis as EditContextGlobal).EditContext; + const editContextConstructor = (globalThis as EditContextGlobal) + .EditContext; if (!editContextConstructor) { - throw new Error("EditContext is not available in this environment."); + throw new Error( + "EditContext is not available in this environment.", + ); } + const initialText = this.ytext.toString(); + const initialEditContextText = toEditContextText(initialText); + const initialSelectionOffset = isLogicallyEmptyText(initialText) + ? 0 + : initialEditContextText.length; this.editContext = new editContextConstructor({ - text: this.ytext.toString(), - selectionStart: 0, - selectionEnd: 0, + text: initialEditContextText, + selectionStart: initialSelectionOffset, + selectionEnd: initialSelectionOffset, }); const ec = this.editContext!; @@ -114,6 +144,7 @@ export class EditContextBackend implements InputBackend { element.addEventListener("paste", this.handlePasteEvent); element.addEventListener("dragstart", this.handleDragStart); element.addEventListener("drop", this.handleDrop); + element.addEventListener("pointerdown", this.handlePointerDown); ec.addEventListener("textupdate", this.handleTextUpdate); ec.addEventListener("textformatupdate", this.handleTextFormatUpdate); ec.addEventListener( @@ -132,7 +163,7 @@ export class EditContextBackend implements InputBackend { inlineDecorations: this.getInlineDecorationsForBlock(), }); this.isApplyingSelection++; - this.updateSelection(null); + this.updateSelection(); element.focus({ preventScroll: true }); requestAnimationFrame(() => { this.isApplyingSelection--; @@ -164,6 +195,10 @@ export class EditContextBackend implements InputBackend { this.element.removeEventListener("paste", this.handlePasteEvent); this.element.removeEventListener("dragstart", this.handleDragStart); this.element.removeEventListener("drop", this.handleDrop); + this.element.removeEventListener( + "pointerdown", + this.handlePointerDown, + ); this.element.ownerDocument?.removeEventListener( "selectionchange", this.handleSelectionChange, @@ -178,11 +213,13 @@ export class EditContextBackend implements InputBackend { this.element = null; this.ytext = null; this.observer = null; + this.editContextSelection = null; + this.authoritativeTextInputSelection = null; this.pendingSelectionOverride = null; this.fieldEditor.setComposing(false); } - updateSelection(_relPos: unknown): void { + updateSelection(): void { if (!this.editContext || !this.ytext) return; const selection = this.fieldEditor.selection; @@ -193,24 +230,36 @@ export class EditContextBackend implements InputBackend { selection.anchor.blockId === blockId && selection.focus.blockId === blockId ) { - this.editContext.updateSelection( + const anchorOffset = this.resolveEditContextOffset( selection.anchor.offset, - selection.focus.offset, ); - this.isApplyingSelection++; - this.projectDOMSelection( - blockId, - selection.anchor.offset, + const focusOffset = this.resolveEditContextOffset( selection.focus.offset, ); + this.setEditContextSelection({ + blockId, + anchorOffset, + focusOffset, + }); + this.isApplyingSelection++; + this.projectDOMSelection(blockId, anchorOffset, focusOffset); requestAnimationFrame(() => { this.isApplyingSelection--; }); return; } - const len = this.ytext.length; + const len = isLogicallyEmptyText(this.ytext.toString()) + ? 0 + : this.ytext.length; this.editContext.updateSelection(len, len); + this.editContextSelection = blockId + ? { + blockId, + anchorOffset: len, + focusOffset: len, + } + : null; } private projectDOMSelection( @@ -248,21 +297,30 @@ export class EditContextBackend implements InputBackend { return; } - const range = { - start: updateRangeStart, - end: updateRangeEnd, - }; + const resolvedTextUpdate = this.resolveTextUpdateRange({ + blockId, + updateRangeStart, + updateRangeEnd, + text, + selectionStart, + selectionEnd, + }); + const { range } = resolvedTextUpdate; const listInputRuleTarget = applyListInputRule(this.editor, { blockId, range, text, }); if (listInputRuleTarget) { - this.pendingSelectionOverride = { + const nextSelection = { blockId: listInputRuleTarget.blockId, anchorOffset: listInputRuleTarget.anchorOffset, focusOffset: listInputRuleTarget.focusOffset, }; + this.pendingSelectionOverride = nextSelection; + this.setEditContextSelection(nextSelection, { + source: "text-update", + }); this.fieldEditor.syncTextSelection( listInputRuleTarget.blockId, listInputRuleTarget.anchorOffset, @@ -280,6 +338,9 @@ export class EditContextBackend implements InputBackend { ); if (inlineInputRuleTarget) { this.pendingSelectionOverride = inlineInputRuleTarget; + this.setEditContextSelection(inlineInputRuleTarget, { + source: "text-update", + }); this.fieldEditor.syncTextSelection( inlineInputRuleTarget.blockId, inlineInputRuleTarget.anchorOffset, @@ -290,18 +351,10 @@ export class EditContextBackend implements InputBackend { return; } - this.pendingSelectionOverride = - typeof selectionStart === "number" && - typeof selectionEnd === "number" - ? { - blockId, - anchorOffset: selectionStart, - focusOffset: selectionEnd, - } - : null; + this.pendingSelectionOverride = resolvedTextUpdate.selection; const ops: DocumentOp[] = []; - if (updateRangeEnd > updateRangeStart) { + if (range.end > range.start) { ops.push({ type: "delete-text" as const, blockId, @@ -315,21 +368,24 @@ export class EditContextBackend implements InputBackend { blockId, offset: range.start, text, - marks: this.fieldEditor.resolveInsertMarks(this.ytext, range.start), + marks: this.fieldEditor.resolveInsertMarks( + this.ytext, + range.start, + ), }); } if (ops.length > 0) { this.editor.apply(ops, { origin: "user" }); } - if ( - typeof selectionStart === "number" && - typeof selectionEnd === "number" - ) { + if (resolvedTextUpdate.selection) { + this.setEditContextSelection(resolvedTextUpdate.selection, { + source: "text-update", + }); this.fieldEditor.syncTextSelection( blockId, - selectionStart, - selectionEnd, + resolvedTextUpdate.selection.anchorOffset, + resolvedTextUpdate.selection.focusOffset, ); this.restoreDOMCaret(); } @@ -337,17 +393,167 @@ export class EditContextBackend implements InputBackend { this.pendingSelectionOverride = null; }; + private resolveTextUpdateRange(input: { + blockId: string; + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; + }): { + range: { start: number; end: number }; + selection: EditContextSelection | null; + } { + const selection = this.fieldEditor.selection; + const isLogicallyEmpty = isLogicallyEmptyText( + this.ytext?.toString() ?? "", + ); + const editorSelectionRange = this.resolveEditorSelectionRange( + input.blockId, + ); + const isCollapsedInsert = + input.text.length > 0 && + input.updateRangeStart === input.updateRangeEnd; + const editContextCaret = collapsedSelectionOffset( + this.editContextSelection, + input.blockId, + ); + const authoritativeInputCaret = collapsedSelectionOffset( + this.authoritativeTextInputSelection, + input.blockId, + ); + const editorCaret = + selection?.type === "text" && + selection.isCollapsed && + selection.focus.blockId === input.blockId + ? selection.focus.offset + : null; + const trustedCaret = + authoritativeInputCaret ?? + (isLogicallyEmpty ? 0 : (editContextCaret ?? editorCaret)); + const shouldUseTrustedCaret = + isCollapsedInsert && + trustedCaret != null && + trustedCaret !== input.updateRangeStart; + const shouldUseEditorSelectionRange = + editorSelectionRange != null && + input.updateRangeStart === input.updateRangeEnd && + (input.updateRangeStart !== editorSelectionRange.start || + input.updateRangeEnd !== editorSelectionRange.end); + const shouldClampEmptyRange = + isLogicallyEmpty && authoritativeInputCaret == null; + const rangeStart = shouldUseEditorSelectionRange + ? editorSelectionRange.start + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeStart; + const rangeEnd = shouldUseEditorSelectionRange + ? editorSelectionRange.end + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeEnd; + const hasCollapsedEventSelection = + typeof input.selectionStart !== "number" || + typeof input.selectionEnd !== "number" || + input.selectionStart === input.selectionEnd; + const nextSelectionOffset = + input.text.length > 0 && hasCollapsedEventSelection + ? rangeStart + input.text.length + : null; + const anchorOffset = + nextSelectionOffset ?? + (typeof input.selectionStart === "number" + ? input.selectionStart + : null); + const focusOffset = + nextSelectionOffset ?? + (typeof input.selectionEnd === "number" + ? input.selectionEnd + : null); + + return { + range: { + start: rangeStart, + end: rangeEnd, + }, + selection: + anchorOffset != null && focusOffset != null + ? { + blockId: input.blockId, + anchorOffset, + focusOffset, + } + : null, + }; + } + + private setEditContextSelection( + selection: EditContextSelection, + options?: EditContextSelectionOptions, + ): void { + const resolvedSelection = { + blockId: selection.blockId, + anchorOffset: this.resolveEditContextOffset( + selection.anchorOffset, + options, + ), + focusOffset: this.resolveEditContextOffset( + selection.focusOffset, + options, + ), + }; + this.editContextSelection = resolvedSelection; + if (options?.source === "text-update") { + this.authoritativeTextInputSelection = resolvedSelection; + } + this.editContext?.updateSelection( + resolvedSelection.anchorOffset, + resolvedSelection.focusOffset, + ); + } + + private resolveEditContextOffset( + offset: number, + options?: EditContextSelectionOptions, + ): number { + return options?.source !== "text-update" && + isLogicallyEmptyText(this.ytext?.toString() ?? "") + ? 0 + : offset; + } + + private resolveEditorSelectionRange( + blockId: string, + ): EditContextRange | null { + const selection = this.fieldEditor.selection; + if ( + selection?.type !== "text" || + selection.isCollapsed || + selection.anchor.blockId !== blockId || + selection.focus.blockId !== blockId + ) { + return null; + } + + return { + start: Math.min(selection.anchor.offset, selection.focus.offset), + end: Math.max(selection.anchor.offset, selection.focus.offset), + }; + } + private applyInlineInputRule( blockId: string, offset: number, text: string, - ): - | { - blockId: string; - anchorOffset: number; - focusOffset: number; - } - | null { + ): { + blockId: string; + anchorOffset: number; + focusOffset: number; + } | null { if (text.length !== 1) { return null; } @@ -369,7 +575,13 @@ export class EditContextBackend implements InputBackend { const ops = inputRuleEngine?.tryMatchInline(this.editor, blockId, text, { offset, - }) ?? this.resolveFallbackInlineInputRule(blockId, block.textContent(), offset, text); + }) ?? + this.resolveFallbackInlineInputRule( + blockId, + block.textContent(), + offset, + text, + ); if (!ops) { return null; } @@ -425,7 +637,8 @@ export class EditContextBackend implements InputBackend { if (!this.element) return; const ranges = - (event as EditContextTextFormatUpdateEvent).getTextFormats?.() ?? []; + (event as EditContextTextFormatUpdateEvent).getTextFormats?.() ?? + []; for (const fmt of ranges) { const { rangeStart, rangeEnd, underlineStyle, underlineThickness } = fmt; @@ -481,7 +694,11 @@ export class EditContextBackend implements InputBackend { private handleSelectionChange = (): void => { if (!this.element || !this.editContext) return; - if (!this.fieldEditor.shouldHandleDomSelectionChange(this.isApplyingSelection)) { + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + this.isApplyingSelection, + ) + ) { return; } @@ -507,7 +724,8 @@ export class EditContextBackend implements InputBackend { } if ( - normalizedSelection.anchor.blockId !== normalizedSelection.focus.blockId + normalizedSelection.anchor.blockId !== + normalizedSelection.focus.blockId ) { this.fieldEditor.applyDocumentTextSelection( normalizedSelection.anchor, @@ -534,8 +752,29 @@ export class EditContextBackend implements InputBackend { const offsets = getDirectionalSelectionOffsets(this.element); if (!offsets) return; + const authoritativeSelection = this.getAuthoritativeTextInputSelection( + normalizedSelection.anchor.blockId, + ); + if ( + authoritativeSelection && + offsets.anchor === offsets.focus && + (offsets.anchor !== authoritativeSelection.anchorOffset || + offsets.focus !== authoritativeSelection.focusOffset) + ) { + this.setEditContextSelection(authoritativeSelection, { + source: "text-update", + }); + this.restoreDOMCaret(); + return; + } this.editContext.updateSelection(offsets.start, offsets.end); + const nextSelection = { + blockId: normalizedSelection.anchor.blockId, + anchorOffset: offsets.anchor, + focusOffset: offsets.focus, + }; + this.editContextSelection = nextSelection; this.fieldEditor.syncTextSelection( normalizedSelection.anchor.blockId, offsets.anchor, @@ -547,8 +786,12 @@ export class EditContextBackend implements InputBackend { if (!this.editContext || !this.element || !this.ytext) return; const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); if (isHistory) { - const nextText = this.ytext?.toString?.() ?? ""; - this.editContext.updateText(0, this.editContext.text.length, nextText); + const nextText = toEditContextText(this.ytext?.toString?.() ?? ""); + this.editContext.updateText( + 0, + this.editContext.text.length, + nextText, + ); const clampedSelectionStart = Math.min( this.editContext.selectionStart, nextText.length, @@ -561,22 +804,17 @@ export class EditContextBackend implements InputBackend { clampedSelectionStart, clampedSelectionEnd, ); + const blockId = this.fieldEditor.focusBlockId; + this.editContextSelection = blockId + ? { + blockId, + anchorOffset: clampedSelectionStart, + focusOffset: clampedSelectionEnd, + } + : null; return; } - const delta = event.delta; - let offset = 0; - for (const entry of delta) { - if (entry.retain != null) { - offset += entry.retain; - } else if (typeof entry.insert === "string") { - this.editContext.updateText(offset, offset, entry.insert); - offset += entry.insert.length; - } else if (entry.delete != null) { - this.editContext.updateText(offset, offset + entry.delete, ""); - } - } - const applied = applyDeltaToDOM( event.delta, this.element, @@ -589,6 +827,42 @@ export class EditContextBackend implements InputBackend { }); } + if ( + shouldReplaceEditContextText( + event.delta, + this.editContext.text.length, + ) + ) { + const nextText = toEditContextText(this.ytext.toString()); + this.editContext.updateText( + 0, + this.editContext.text.length, + nextText, + ); + } else { + const delta = event.delta; + let offset = 0; + for (const entry of delta) { + if (entry.retain != null) { + offset += entry.retain; + } else if (typeof entry.insert === "string") { + this.editContext.updateText(offset, offset, entry.insert); + offset += entry.insert.length; + } else if (entry.delete != null) { + this.editContext.updateText( + offset, + offset + entry.delete, + "", + ); + } + } + } + + if (this.pendingSelectionOverride) { + this.setEditContextSelection(this.pendingSelectionOverride, { + source: "text-update", + }); + } this.restoreDOMCaret(); }; @@ -605,8 +879,19 @@ export class EditContextBackend implements InputBackend { this.pendingSelectionOverride?.blockId === blockId ? this.pendingSelectionOverride : null; + const authoritativeInputSelection = + blockId != null && + this.authoritativeTextInputSelection?.blockId === blockId + ? this.authoritativeTextInputSelection + : null; + const editContextSelection = + blockId != null && this.editContextSelection?.blockId === blockId + ? this.editContextSelection + : null; const anchorOffset = pendingSelection?.anchorOffset ?? + authoritativeInputSelection?.anchorOffset ?? + editContextSelection?.anchorOffset ?? (selection?.type === "text" && blockId && selection.anchor.blockId === blockId && @@ -615,6 +900,8 @@ export class EditContextBackend implements InputBackend { : null); const focusOffset = pendingSelection?.focusOffset ?? + authoritativeInputSelection?.focusOffset ?? + editContextSelection?.focusOffset ?? (selection?.type === "text" && blockId && selection.anchor.blockId === blockId && @@ -622,11 +909,15 @@ export class EditContextBackend implements InputBackend { ? selection.focus.offset : null); if (root && blockId && anchorOffset != null && focusOffset != null) { + this.isApplyingSelection++; editorSelectionToDOM( root, { blockId, offset: anchorOffset }, { blockId, offset: focusOffset }, ); + requestAnimationFrame(() => { + this.isApplyingSelection--; + }); return; } @@ -641,11 +932,15 @@ export class EditContextBackend implements InputBackend { const sel = this.element.ownerDocument?.getSelection(); if (!sel) return; + this.isApplyingSelection++; sel.removeAllRanges(); const range = document.createRange(); range.setStart(anchorPoint.node, anchorPoint.offset); range.setEnd(focusPoint.node, focusPoint.offset); sel.addRange(range); + requestAnimationFrame(() => { + this.isApplyingSelection--; + }); } private getInlineDecorationsForBlock(): readonly InlineDecoration[] { @@ -657,31 +952,76 @@ export class EditContextBackend implements InputBackend { .getDecorations() .forBlock(blockId) .filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", + (decoration): decoration is InlineDecoration => + decoration.type === "inline", ); } private handleKeyDown = (event: KeyboardEvent): void => { if (!this.editContext || !this.element || !this.ytext) return; + if (isNavigationSelectionKey(event)) { + this.authoritativeTextInputSelection = null; + } + const blockId = this.fieldEditor.focusBlockId; const liveDomOffsets = getDirectionalSelectionOffsets(this.element); - const range = liveDomOffsets - ? { - start: liveDomOffsets.start, - end: liveDomOffsets.end, - } - : { - start: Math.min( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - end: Math.max( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - }; - if (liveDomOffsets) { + const editorSelectionRange = blockId + ? this.resolveEditorSelectionRange(blockId) + : null; + const shouldUseEditorSelectionRange = + editorSelectionRange != null && + (!liveDomOffsets || + (liveDomOffsets.start === liveDomOffsets.end && + (liveDomOffsets.start !== editorSelectionRange.start || + liveDomOffsets.end !== editorSelectionRange.end))); + const range = shouldUseEditorSelectionRange + ? editorSelectionRange + : liveDomOffsets + ? { + start: liveDomOffsets.start, + end: liveDomOffsets.end, + } + : { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + const authoritativeSelection = this.fieldEditor.focusBlockId + ? this.getAuthoritativeTextInputSelection( + this.fieldEditor.focusBlockId, + ) + : null; + const shouldUseLiveDomSelection = + !!liveDomOffsets && + !( + authoritativeSelection && + liveDomOffsets.anchor === liveDomOffsets.focus && + (liveDomOffsets.anchor !== + authoritativeSelection.anchorOffset || + liveDomOffsets.focus !== authoritativeSelection.focusOffset) + ); + if (blockId && shouldUseEditorSelectionRange) { this.editContext.updateSelection(range.start, range.end); + this.editContextSelection = { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + }; + } else if (liveDomOffsets && shouldUseLiveDomSelection) { + this.editContext.updateSelection(range.start, range.end); + const nextSelection = blockId + ? { + blockId, + anchorOffset: liveDomOffsets.anchor, + focusOffset: liveDomOffsets.focus, + } + : null; + this.editContextSelection = nextSelection; } const handled = handleFieldEditorKeyDown({ @@ -725,18 +1065,33 @@ export class EditContextBackend implements InputBackend { private handleDrop = (event: DragEvent): void => { event.preventDefault(); }; + + private handlePointerDown = (): void => { + this.authoritativeTextInputSelection = null; + }; + + private getAuthoritativeTextInputSelection( + blockId: string, + ): EditContextSelection | null { + const selection = + this.authoritativeTextInputSelection?.blockId === blockId + ? this.authoritativeTextInputSelection + : null; + if (!selection || selection.anchorOffset !== selection.focusOffset) { + return null; + } + return selection; + } } function resolveInlineSelectionTarget( blockId: string, ops: DocumentOp[], -): - | { - blockId: string; - anchorOffset: number; - focusOffset: number; - } - | null { +): { + blockId: string; + anchorOffset: number; + focusOffset: number; +} | null { let nextOffset: number | null = null; for (const op of ops) { if (op.type === "insert-text" && op.blockId === blockId) { @@ -809,3 +1164,57 @@ function findTextPosition( } return { node: container, offset: 0 }; } + +function isLogicallyEmptyText(text: string): boolean { + return text.length === 0 || text === ZERO_WIDTH_SPACE; +} + +function toEditContextText(text: string): string { + return text === ZERO_WIDTH_SPACE ? "" : text; +} + +function shouldReplaceEditContextText( + delta: FieldEditorTextChangeEvent["delta"], + editContextTextLength: number, +): boolean { + let offset = 0; + for (const entry of delta) { + if (entry.retain != null) { + offset += entry.retain; + if (offset > editContextTextLength) return true; + } else if (typeof entry.insert === "string") { + if (entry.insert === ZERO_WIDTH_SPACE) return true; + if (offset > editContextTextLength) return true; + offset += entry.insert.length; + } else if (entry.delete != null) { + if (offset + entry.delete > editContextTextLength) return true; + } + } + return false; +} + +function collapsedSelectionOffset( + selection: EditContextSelection | null, + blockId: string, +): number | null { + if ( + selection?.blockId !== blockId || + selection.anchorOffset !== selection.focusOffset + ) { + return null; + } + return selection.focusOffset; +} + +function isNavigationSelectionKey(event: KeyboardEvent): boolean { + return ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" + ); +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index 4e0378f..6d84a59 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -7,9 +7,7 @@ import type { Unsubscribe, InputBackend, } from "@pen/types"; -import { - DocumentRangeImpl, -} from "@pen/core"; +import { DocumentRangeImpl } from "@pen/core"; import { hasFieldEditorSurface, resolveFieldEditorInputMode, @@ -33,7 +31,11 @@ import { resolveCellInlineElement, } from "./contentResolution"; import type { FieldEditorTextLike } from "./crdt"; -import { domSelectionToEditor, queryBlockElement, queryInlineElement } from "./selectionBridge"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; import { getEditorBlockSelectionLength, getEditorBlockSelectionRole, @@ -50,6 +52,7 @@ import { type FieldEditorOptions = { selectAllBehavior?: EditorSelectAllBehavior; + inputBackend?: "contenteditable" | "edit-context"; }; export class FieldEditorImpl implements FieldEditorSession { @@ -77,6 +80,7 @@ export class FieldEditorImpl implements FieldEditorSession { private readonly _sessionReconciler: SessionReconciler; private readonly _historySelectionCoordinator: HistorySelectionCoordinator; private _selectAllBehavior: EditorSelectAllBehavior; + private _inputBackend: "contenteditable" | "edit-context"; private _selectAllCycle: { blockId: string; scope: "cell" | "block" | "document"; @@ -87,7 +91,9 @@ export class FieldEditorImpl implements FieldEditorSession { constructor(editor: Editor, options?: FieldEditorOptions) { this._editor = editor; this._selectAllBehavior = - options?.selectAllBehavior ?? resolveSelectAllBehavior("content-first"); + options?.selectAllBehavior ?? + resolveSelectAllBehavior("content-first"); + this._inputBackend = options?.inputBackend ?? "edit-context"; this._historySelectionCoordinator = new HistorySelectionCoordinator( this._editor, ); @@ -115,9 +121,11 @@ export class FieldEditorImpl implements FieldEditorSession { }); }, ); - this._unsubscribeHistoryApplied = this._editor.onHistoryApplied((event) => { - this._handleHistoryApplied(event); - }); + this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( + (event) => { + this._handleHistoryApplied(event); + }, + ); this._sessionReconciler = new SessionReconciler(this._editor, { getSnapshot: () => this.getSnapshot(), getAttachedElement: () => this._attachedElement, @@ -212,7 +220,11 @@ export class FieldEditorImpl implements FieldEditorSession { const coord = this._activeCellCoord; if (!coord) return; - const ytext = this._getYTextForCell(coord.blockId, coord.row, coord.col); + const ytext = this._getYTextForCell( + coord.blockId, + coord.row, + coord.col, + ); if (!ytext) return; const root = this._findEditorRoot(); @@ -295,14 +307,19 @@ export class FieldEditorImpl implements FieldEditorSession { const blockId = this._resolveSelectAllBlockId(rootElement); if (blockId) { - const blockLength = getEditorBlockSelectionLength(this._editor, blockId); - const blockRole = getEditorBlockSelectionRole(this._editor, blockId); + const blockLength = getEditorBlockSelectionLength( + this._editor, + blockId, + ); + const blockRole = getEditorBlockSelectionRole( + this._editor, + blockId, + ); const shouldSelectDocument = blockLength === 0 || (this._selectAllCycle?.blockId === blockId && this._selectAllCycle.scope === "block"); - const nextScope = - shouldSelectDocument ? "document" : "block"; + const nextScope = shouldSelectDocument ? "document" : "block"; if (nextScope === "block") { if (blockRole && blockRole !== "editable-inline") { this.deactivate(); @@ -331,7 +348,10 @@ export class FieldEditorImpl implements FieldEditorSession { this._editor.selectTextRange(range.start, range.end); this._recomputeSurfaceFromSelection(); if (this._selectAllBehavior === "block-first") { - this._recordSelectAllScope(blockId ?? range.focusBlockId, "document"); + this._recordSelectAllScope( + blockId ?? range.focusBlockId, + "document", + ); } this._syncSelectionToDOM(); return true; @@ -692,7 +712,10 @@ export class FieldEditorImpl implements FieldEditorSession { this._editor, cycle.blockId, ); - const blockRole = getEditorBlockSelectionRole(this._editor, cycle.blockId); + const blockRole = getEditorBlockSelectionRole( + this._editor, + cycle.blockId, + ); if (blockRole && blockRole !== "editable-inline") { return ( selection?.type === "block" && @@ -709,9 +732,9 @@ export class FieldEditorImpl implements FieldEditorSession { selection.anchor.blockId === cycle.blockId && selection.focus.blockId === cycle.blockId && Math.min(selection.anchor.offset, selection.focus.offset) === - 0 && + 0 && Math.max(selection.anchor.offset, selection.focus.offset) === - blockLength + blockLength ); } @@ -779,7 +802,7 @@ export class FieldEditorImpl implements FieldEditorSession { const selection = this._editor.selection; const anchor = selection?.type === "text" && - selection.blockRange.includes(this._focusBlockId) + selection.blockRange.includes(this._focusBlockId) ? selection.anchor : { blockId: this._focusBlockId, offset: 0 }; const doc = this._editor.documentState; @@ -874,6 +897,7 @@ export class FieldEditorImpl implements FieldEditorSession { return ContentEditableBackend; } if ( + this._inputBackend === "edit-context" && "EditContext" in globalThis && typeof (globalThis as typeof globalThis & { EditContext?: unknown }) .EditContext === "function" @@ -1076,9 +1100,7 @@ export class FieldEditorImpl implements FieldEditorSession { return true; } - private _handleHistoryApplied( - event: HistoryAppliedEvent, - ): void { + private _handleHistoryApplied(event: HistoryAppliedEvent): void { const selection = event.selection; const nextFocusBlockId = event.focusBlockId ?? @@ -1098,7 +1120,9 @@ export class FieldEditorImpl implements FieldEditorSession { this._focusBlockId = nextFocusBlockId; } - this._historySelectionCoordinator.beginDeferredProjection(event.requestId); + this._historySelectionCoordinator.beginDeferredProjection( + event.requestId, + ); this._recomputeSurfaceFromSelection({ syncSelectionToBackend: false, @@ -1109,7 +1133,8 @@ export class FieldEditorImpl implements FieldEditorSession { if (!this._attachedElement) { return false; } - const activeElement = this._attachedElement.ownerDocument?.activeElement; + const activeElement = + this._attachedElement.ownerDocument?.activeElement; return activeElement instanceof Node ? this._attachedElement.contains(activeElement) : false; @@ -1260,7 +1285,12 @@ export class FieldEditorImpl implements FieldEditorSession { col: number, root?: HTMLElement | null, ): HTMLElement | null { - return resolveCellInlineElement(blockId, row, col, root ?? this._findEditorRoot()); + return resolveCellInlineElement( + blockId, + row, + col, + root ?? this._findEditorRoot(), + ); } private _resolveActiveCellElement( diff --git a/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx b/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx index 319967f..39754fb 100644 --- a/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx +++ b/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx @@ -15,6 +15,11 @@ import { FIELD_EDITOR_SLOT_KEY } from "../constants/fieldEditor"; describe("@pen/react editor caret overlay", () => { it("renders a custom local caret for collapsed selections only", async () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty(navigator, "userAgent", { + value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + configurable: true, + }); const editor = createEditor({ preset: defaultPreset({ documentOps: false, @@ -35,13 +40,24 @@ describe("@pen/react editor caret overlay", () => { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + let caretStyle: React.CSSProperties | null = null; try { await act(async () => { root.render( - + { + caretStyle = props.caretStyle; + return ( +
+ ); + }} + /> , ); }); @@ -55,7 +71,9 @@ describe("@pen/react editor caret overlay", () => { if (!inlineElement) { throw new Error("Missing inline content element"); } - expect(container.querySelector("[data-pen-editor-caret]")).toBeNull(); + expect( + container.querySelector("[data-pen-editor-caret]"), + ).toBeNull(); Object.defineProperty(inlineElement, "getBoundingClientRect", { configurable: true, @@ -64,7 +82,9 @@ describe("@pen/react editor caret overlay", () => { await act(async () => { fieldEditor.activateTextSelection(blockId, 2, 2); - inlineElement?.dispatchEvent(new Event("focusin", { bubbles: true })); + inlineElement?.dispatchEvent( + new Event("focusin", { bubbles: true }), + ); }); const caretElement = container.querySelector( @@ -73,6 +93,16 @@ describe("@pen/react editor caret overlay", () => { expect(caretElement?.getAttribute("data-block-id")).toBe(blockId); expect(caretElement?.getAttribute("data-offset")).toBe("2"); expect(caretElement?.style.animation).toBe("none"); + const resolvedCaretStyle = caretStyle as React.CSSProperties | null; + expect(resolvedCaretStyle?.width).toBe( + "var(--pen-editor-caret-width, var(--pen-caret-width, 1px))", + ); + expect(resolvedCaretStyle?.borderRadius).toBe( + "var(--pen-editor-caret-radius, var(--pen-caret-radius, 0px))", + ); + expect(resolvedCaretStyle?.background).toBe( + "var(--pen-editor-caret-color, var(--pen-caret-color, var(--palette-b100, currentColor)))", + ); expect(inlineElement?.style.caretColor).toBe("transparent"); expect( container @@ -89,7 +119,10 @@ describe("@pen/react editor caret overlay", () => { await act(async () => { inlineElement.dispatchEvent( - new Event("beforeinput", { bubbles: true, cancelable: true }), + new Event("beforeinput", { + bubbles: true, + cancelable: true, + }), ); }); expect(caretElement?.style.animation).toBe("none"); @@ -98,7 +131,9 @@ describe("@pen/react editor caret overlay", () => { editor.selectText(blockId, 1, 4); }); - expect(container.querySelector("[data-pen-editor-caret]")).toBeNull(); + expect( + container.querySelector("[data-pen-editor-caret]"), + ).toBeNull(); expect(inlineElement?.style.caretColor).toBe(""); expect( container @@ -109,6 +144,102 @@ describe("@pen/react editor caret overlay", () => { await act(async () => { root.unmount(); }); + Object.defineProperty(navigator, "userAgent", { + value: originalUserAgent, + configurable: true, + }); + container.remove(); + editor.destroy(); + } + }); + + it("uses macOS caret defaults", async () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty(navigator, "userAgent", { + value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + configurable: true, + }); + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Hello world", + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let caretStyle: React.CSSProperties | null = null; + + try { + await act(async () => { + root.render( + + + { + caretStyle = props.caretStyle; + return ( +
+ ); + }} + /> + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + expect(inlineElement).not.toBeNull(); + if (!inlineElement) { + throw new Error("Missing inline content element"); + } + + Object.defineProperty(inlineElement, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(24, 32, 240, 24), + }); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 2, 2); + inlineElement.dispatchEvent( + new Event("focusin", { bubbles: true }), + ); + }); + + const resolvedCaretStyle = caretStyle as React.CSSProperties | null; + expect(resolvedCaretStyle?.width).toBe( + "var(--pen-editor-caret-width, var(--pen-caret-width, 2px))", + ); + expect(resolvedCaretStyle?.borderRadius).toBe( + "var(--pen-editor-caret-radius, var(--pen-caret-radius, 999px))", + ); + expect(resolvedCaretStyle?.background).toBe( + "var(--pen-editor-caret-color, var(--pen-caret-color, var(--palette-blue, #0a84ff)))", + ); + } finally { + await act(async () => { + root.unmount(); + }); + Object.defineProperty(navigator, "userAgent", { + value: originalUserAgent, + configurable: true, + }); container.remove(); editor.destroy(); } @@ -156,16 +287,22 @@ describe("@pen/react editor caret overlay", () => { await act(async () => { fieldEditor.activateTextSelection(blockId, 2, 2); - inlineElement.dispatchEvent(new Event("focusin", { bubbles: true })); + inlineElement.dispatchEvent( + new Event("focusin", { bubbles: true }), + ); }); - expect(container.querySelector("[data-pen-editor-caret]")).toBeNull(); + expect( + container.querySelector("[data-pen-editor-caret]"), + ).toBeNull(); await act(async () => { root.render(); }); - expect(container.querySelector("[data-pen-editor-caret]")).not.toBeNull(); + expect( + container.querySelector("[data-pen-editor-caret]"), + ).not.toBeNull(); } finally { await act(async () => { root.unmount(); diff --git a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx index 0084e22..230aac4 100644 --- a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx +++ b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx @@ -3,8 +3,12 @@ import React, { act } from "react"; import { describe, expect, it } from "vitest"; import { createRoot } from "react-dom/client"; -import { createEditor as createCoreEditor } from "@pen/core"; +import { + createDecorationSet, + createEditor as createCoreEditor, +} from "@pen/core"; import { defaultPreset } from "@pen/preset-default"; +import { defineExtension } from "@pen/types"; import type { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; import { FIELD_EDITOR_SLOT_KEY } from "../constants/fieldEditor"; import { domSelectionToEditor } from "../field-editor/selectionBridge"; @@ -132,14 +136,9 @@ function setNativeSelectionRange( } describe("@pen/react selected text deletion", () => { - it("preserves the full native selection on mouseup after a word select gesture", async () => { + it("keeps the active inline DOM synchronized after direct text input", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello world" }, - ]); - const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -153,14 +152,10 @@ describe("@pen/react selected text deletion", () => { }); const fieldEditor = getFieldEditor(editor); - const rootElement = container.querySelector( - "[data-pen-editor-root]", - ) as HTMLElement | null; const inlineElement = container.querySelector( "[data-pen-inline-content]", ) as HTMLElement | null; - expect(rootElement).not.toBeNull(); expect(inlineElement).not.toBeNull(); await act(async () => { @@ -168,70 +163,25 @@ describe("@pen/react selected text deletion", () => { await flushAnimationFrames(2); }); - const originalCaretRangeFromPoint = ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint; - - try { - ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 2); - range.collapse(true); - return range; - }; + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); - await act(async () => { + await act(async () => { + for (const character of "Hello") { inlineElement!.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - }), - ); - - const selection = document.getSelection(); - const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 0); - range.setEnd(inlineElement!.firstChild ?? inlineElement!, 5); - selection?.removeAllRanges(); - selection?.addRange(range); - - document.dispatchEvent( - new MouseEvent("mouseup", { + new InputEvent("beforeinput", { bubbles: true, - button: 0, - clientX: 12, - clientY: 8, + cancelable: true, + inputType: "insertText", + data: character, }), ); - await flushAnimationFrames(3); - }); - } finally { - ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint = originalCaretRangeFromPoint; - } - - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 0 }, - focus: { blockId, offset: 5 }, - isCollapsed: false, - }); - expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 0 }, - focus: { blockId, offset: 5 }, + } + await flushAnimationFrames(2); }); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello"); + expect(inlineElement!.textContent).toBe("Hello"); + await act(async () => { root.unmount(); }); @@ -239,108 +189,67 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("collapses a selected inline range to a caret when clicking inside it", async () => { + it("keeps active inline text visible after a parent rerender", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello world" }, - ]); - const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - await act(async () => { - root.render( + function RerenderingEditor() { + const [, setCommitCount] = React.useState(0); + + React.useEffect( + () => + editor.onDocumentCommit(() => + setCommitCount((count) => count + 1), + ), + [], + ); + + return ( - , + ); + } + + await act(async () => { + root.render(); }); const fieldEditor = getFieldEditor(editor); - const rootElement = container.querySelector( - "[data-pen-editor-root]", - ) as HTMLElement | null; const inlineElement = container.querySelector( "[data-pen-inline-content]", ) as HTMLElement | null; - expect(rootElement).not.toBeNull(); expect(inlineElement).not.toBeNull(); await act(async () => { - fieldEditor.activateTextSelection(blockId, 1, 5); - await flushAnimationFrames(3); + fieldEditor.activate(blockId); + await flushAnimationFrames(2); }); - const originalCaretRangeFromPoint = ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint; - - try { - ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 3); - range.collapse(true); - return range; - }; + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); - await act(async () => { + await act(async () => { + for (const character of "Hello") { inlineElement!.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - }), - ); - - const collapsedRange = document.createRange(); - collapsedRange.setStart( - inlineElement!.firstChild ?? inlineElement!, - 3, - ); - collapsedRange.collapse(true); - document.getSelection()?.removeAllRanges(); - document.getSelection()?.addRange(collapsedRange); - - document.dispatchEvent( - new MouseEvent("mouseup", { + new InputEvent("beforeinput", { bubbles: true, - button: 0, - clientX: 12, - clientY: 8, + cancelable: true, + inputType: "insertText", + data: character, }), ); - await flushAnimationFrames(3); - }); - } finally { - ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint = originalCaretRangeFromPoint; - } - - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 3 }, - focus: { blockId, offset: 3 }, - isCollapsed: true, - }); - expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 3 }, - focus: { blockId, offset: 3 }, + await flushAnimationFrames(1); + } + await flushAnimationFrames(2); }); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello"); + expect(inlineElement!.textContent).toBe("Hello"); + await act(async () => { root.unmount(); }); @@ -348,14 +257,34 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("preserves third-click block selection when the native range settles after mouseup", async () => { - const editor = createEditor(); + it("reconciles active inline decorations when text is unchanged", async () => { + let decorationState = "initial"; + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "active-inline-decoration-test", + decorations(_state, currentEditor) { + const firstBlock = currentEditor.firstBlock(); + if (!firstBlock || firstBlock.length() === 0) { + return createDecorationSet([]); + } + + return createDecorationSet([ + { + type: "inline", + blockId: firstBlock.id, + from: 0, + to: firstBlock.length(), + attributes: { + "data-decoration-state": decorationState, + }, + }, + ]); + }, + }), + ], + }); const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello world" }, - ]); - const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -369,14 +298,10 @@ describe("@pen/react selected text deletion", () => { }); const fieldEditor = getFieldEditor(editor); - const rootElement = container.querySelector( - "[data-pen-editor-root]", - ) as HTMLElement | null; const inlineElement = container.querySelector( "[data-pen-inline-content]", ) as HTMLElement | null; - expect(rootElement).not.toBeNull(); expect(inlineElement).not.toBeNull(); await act(async () => { @@ -384,76 +309,37 @@ describe("@pen/react selected text deletion", () => { await flushAnimationFrames(2); }); - const originalCaretRangeFromPoint = ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint; - - try { - ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 2); - range.collapse(true); - return range; - }; + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); - await act(async () => { + await act(async () => { + for (const character of "Hello") { inlineElement!.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - detail: 3, - }), - ); - - const collapsedRange = document.createRange(); - collapsedRange.setStart( - inlineElement!.firstChild ?? inlineElement!, - 2, - ); - collapsedRange.collapse(true); - document.getSelection()?.removeAllRanges(); - document.getSelection()?.addRange(collapsedRange); - - document.dispatchEvent( - new MouseEvent("mouseup", { + new InputEvent("beforeinput", { bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - detail: 3, + cancelable: true, + inputType: "insertText", + data: character, }), ); + } + await flushAnimationFrames(2); + }); - setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); - await flushAnimationFrames(3); - }); - } finally { - ( - document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - } - ).caretRangeFromPoint = originalCaretRangeFromPoint; - } + expect( + inlineElement!.querySelector('[data-decoration-state="initial"]'), + ).not.toBeNull(); - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 0 }, - focus: { blockId, offset: 11 }, - isCollapsed: false, - }); - expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 0 }, - focus: { blockId, offset: 11 }, + decorationState = "updated"; + await act(async () => { + editor.requestDecorationUpdate(); + await flushAnimationFrames(2); }); + expect(inlineElement!.textContent).toBe("Hello"); + expect( + inlineElement!.querySelector('[data-decoration-state="updated"]'), + ).not.toBeNull(); + await act(async () => { root.unmount(); }); @@ -461,20 +347,202 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("selects the full block on the third click after a word selection", async () => { + it("can opt into contenteditable even when EditContext is available", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + const editor = createEditor(); const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello world" }, - ]); - const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - await act(async () => { - root.render( + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + expect(rootElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activate(blockId); + await flushAnimationFrames(2); + }); + + expect(inlineElement!.editContext).toBeFalsy(); + expect(inlineElement!.contentEditable).toBe("true"); + + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + + await act(async () => { + for (const character of "Hey") { + inlineElement!.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: character, + }), + ); + } + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hey"); + expect(inlineElement!.textContent).toBe("Hey"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + + it("keeps active EditContext text visible after a parent rerender", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + function RerenderingEditor() { + const [, setCommitCount] = React.useState(0); + + React.useEffect( + () => + editor.onDocumentCommit(() => + setCommitCount((count) => count + 1), + ), + [], + ); + + return ( + + + + ); + } + + try { + await act(async () => { + root.render(); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + expect(rootElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activate(blockId); + await flushAnimationFrames(2); + }); + + const editContext = inlineElement!.editContext; + expect(editContext).toBeInstanceOf(FakeEditContext); + + await act(async () => { + for (const character of "Hello") { + const start = editContext!.selectionStart; + const end = editContext!.selectionEnd; + editContext!.emit("textupdate", { + updateRangeStart: start, + updateRangeEnd: end, + text: character, + selectionStart: start + character.length, + selectionEnd: start + character.length, + }); + await flushAnimationFrames(1); + } + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello"); + expect(inlineElement!.textContent).toBe("Hello"); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + + it("preserves the full native selection on mouseup after a word select gesture", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello world" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( , @@ -493,8 +561,8 @@ describe("@pen/react selected text deletion", () => { expect(inlineElement).not.toBeNull(); await act(async () => { - fieldEditor.activateTextSelection(blockId, 0, 5); - await flushAnimationFrames(3); + fieldEditor.activate(blockId); + await flushAnimationFrames(2); }); const originalCaretRangeFromPoint = ( @@ -506,11 +574,14 @@ describe("@pen/react selected text deletion", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = () => { const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 3); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 2); range.collapse(true); return range; }; @@ -522,11 +593,15 @@ describe("@pen/react selected text deletion", () => { button: 0, clientX: 12, clientY: 8, - detail: 3, }), ); - setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); + const selection = document.getSelection(); + const range = document.createRange(); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 0); + range.setEnd(inlineElement!.firstChild ?? inlineElement!, 5); + selection?.removeAllRanges(); + selection?.addRange(range); document.dispatchEvent( new MouseEvent("mouseup", { @@ -534,7 +609,6 @@ describe("@pen/react selected text deletion", () => { button: 0, clientX: 12, clientY: 8, - detail: 3, }), ); await flushAnimationFrames(3); @@ -542,7 +616,10 @@ describe("@pen/react selected text deletion", () => { } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -550,12 +627,12 @@ describe("@pen/react selected text deletion", () => { expect(editor.selection).toMatchObject({ type: "text", anchor: { blockId, offset: 0 }, - focus: { blockId, offset: 11 }, + focus: { blockId, offset: 5 }, isCollapsed: false, }); expect(domSelectionToEditor(rootElement!)).toMatchObject({ anchor: { blockId, offset: 0 }, - focus: { blockId, offset: 11 }, + focus: { blockId, offset: 5 }, }); await act(async () => { @@ -565,7 +642,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("collapses a full-block text selection to a caret on the fourth click", async () => { + it("collapses a selected inline range to a caret when clicking inside it", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -597,7 +674,7 @@ describe("@pen/react selected text deletion", () => { expect(inlineElement).not.toBeNull(); await act(async () => { - fieldEditor.activateTextSelection(blockId, 0, 11); + fieldEditor.activateTextSelection(blockId, 1, 5); await flushAnimationFrames(3); }); @@ -610,7 +687,10 @@ describe("@pen/react selected text deletion", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = () => { const range = document.createRange(); @@ -626,11 +706,17 @@ describe("@pen/react selected text deletion", () => { button: 0, clientX: 12, clientY: 8, - detail: 4, }), ); - setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); + const collapsedRange = document.createRange(); + collapsedRange.setStart( + inlineElement!.firstChild ?? inlineElement!, + 3, + ); + collapsedRange.collapse(true); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(collapsedRange); document.dispatchEvent( new MouseEvent("mouseup", { @@ -638,7 +724,6 @@ describe("@pen/react selected text deletion", () => { button: 0, clientX: 12, clientY: 8, - detail: 4, }), ); await flushAnimationFrames(3); @@ -646,7 +731,10 @@ describe("@pen/react selected text deletion", () => { } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -669,7 +757,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("collapses an immediate fourth click after triple-click paragraph selection", async () => { + it("preserves third-click block selection when the native range settles after mouseup", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -714,11 +802,14 @@ describe("@pen/react selected text deletion", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = () => { const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 3); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 2); range.collapse(true); return range; }; @@ -734,7 +825,14 @@ describe("@pen/react selected text deletion", () => { }), ); - setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); + const collapsedRange = document.createRange(); + collapsedRange.setStart( + inlineElement!.firstChild ?? inlineElement!, + 2, + ); + collapsedRange.collapse(true); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(collapsedRange); document.dispatchEvent( new MouseEvent("mouseup", { @@ -746,57 +844,29 @@ describe("@pen/react selected text deletion", () => { }), ); - inlineElement!.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - detail: 4, - }), - ); - setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); - - document.dispatchEvent( - new MouseEvent("mouseup", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - detail: 4, - }), - ); - - inlineElement!.dispatchEvent( - new MouseEvent("click", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - detail: 4, - }), - ); - - await flushAnimationFrames(4); + await flushAnimationFrames(3); }); } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } expect(editor.selection).toMatchObject({ type: "text", - anchor: { blockId, offset: 3 }, - focus: { blockId, offset: 3 }, - isCollapsed: true, + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 11 }, + isCollapsed: false, }); expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 3 }, - focus: { blockId, offset: 3 }, + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 11 }, }); await act(async () => { @@ -806,7 +876,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("collapses an immediate follow-up single click after triple-click paragraph selection", async () => { + it("selects the full block on the third click after a word selection", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -838,8 +908,8 @@ describe("@pen/react selected text deletion", () => { expect(inlineElement).not.toBeNull(); await act(async () => { - fieldEditor.activate(blockId); - await flushAnimationFrames(2); + fieldEditor.activateTextSelection(blockId, 0, 5); + await flushAnimationFrames(3); }); const originalCaretRangeFromPoint = ( @@ -851,7 +921,10 @@ describe("@pen/react selected text deletion", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = () => { const range = document.createRange(); @@ -882,52 +955,125 @@ describe("@pen/react selected text deletion", () => { detail: 3, }), ); + await flushAnimationFrames(3); + }); + } finally { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = originalCaretRangeFromPoint; + } - inlineElement!.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - button: 0, - clientX: 12, - clientY: 8, - detail: 1, - }), - ); - - const collapsedRange = document.createRange(); - collapsedRange.setStart( - inlineElement!.firstChild ?? inlineElement!, - 3, - ); - collapsedRange.collapse(true); - document.getSelection()?.removeAllRanges(); - document.getSelection()?.addRange(collapsedRange); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 11 }, + isCollapsed: false, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 11 }, + }); - document.dispatchEvent( - new MouseEvent("mouseup", { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("collapses a full-block text selection to a caret on the fourth click", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello world" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 0, 11); + await flushAnimationFrames(3); + }); + + const originalCaretRangeFromPoint = ( + document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + } + ).caretRangeFromPoint; + + try { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 3); + range.collapse(true); + return range; + }; + + await act(async () => { + inlineElement!.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 12, clientY: 8, - detail: 1, + detail: 4, }), ); - inlineElement!.dispatchEvent( - new MouseEvent("click", { + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); + + document.dispatchEvent( + new MouseEvent("mouseup", { bubbles: true, button: 0, clientX: 12, clientY: 8, - detail: 1, + detail: 4, }), ); - - await flushAnimationFrames(4); + await flushAnimationFrames(3); }); } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -950,7 +1096,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("ignores a late native full-block selectionchange after collapsing to a caret", async () => { + it("collapses an immediate fourth click after triple-click paragraph selection", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -995,7 +1141,10 @@ describe("@pen/react selected text deletion", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = () => { const range = document.createRange(); @@ -1011,18 +1160,11 @@ describe("@pen/react selected text deletion", () => { button: 0, clientX: 12, clientY: 8, - detail: 1, + detail: 3, }), ); - const collapsedRange = document.createRange(); - collapsedRange.setStart( - inlineElement!.firstChild ?? inlineElement!, - 3, - ); - collapsedRange.collapse(true); - document.getSelection()?.removeAllRanges(); - document.getSelection()?.addRange(collapsedRange); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); document.dispatchEvent( new MouseEvent("mouseup", { @@ -1030,29 +1172,51 @@ describe("@pen/react selected text deletion", () => { button: 0, clientX: 12, clientY: 8, - detail: 1, + detail: 3, }), ); inlineElement!.dispatchEvent( - new MouseEvent("click", { + new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 12, clientY: 8, - detail: 1, + detail: 4, }), ); setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); - document.dispatchEvent(new Event("selectionchange")); + + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 4, + }), + ); + + inlineElement!.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 4, + }), + ); await flushAnimationFrames(4); }); } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -1075,7 +1239,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("collapses a double-click word selection to a caret after a paused single click", async () => { + it("collapses an immediate follow-up single click after triple-click paragraph selection", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -1107,8 +1271,8 @@ describe("@pen/react selected text deletion", () => { expect(inlineElement).not.toBeNull(); await act(async () => { - fieldEditor.activateTextSelection(blockId, 0, 5); - await flushAnimationFrames(3); + fieldEditor.activate(blockId); + await flushAnimationFrames(2); }); const originalCaretRangeFromPoint = ( @@ -1120,7 +1284,10 @@ describe("@pen/react selected text deletion", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = () => { const range = document.createRange(); @@ -1130,6 +1297,28 @@ describe("@pen/react selected text deletion", () => { }; await act(async () => { + inlineElement!.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 3, + }), + ); + + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); + + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 3, + }), + ); + inlineElement!.dispatchEvent( new MouseEvent("mousedown", { bubbles: true, @@ -1140,7 +1329,14 @@ describe("@pen/react selected text deletion", () => { }), ); - setNativeSelectionRange(inlineElement!, 0, inlineElement!, 5); + const collapsedRange = document.createRange(); + collapsedRange.setStart( + inlineElement!.firstChild ?? inlineElement!, + 3, + ); + collapsedRange.collapse(true); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(collapsedRange); document.dispatchEvent( new MouseEvent("mouseup", { @@ -1151,12 +1347,26 @@ describe("@pen/react selected text deletion", () => { detail: 1, }), ); - await flushAnimationFrames(3); + + inlineElement!.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 1, + }), + ); + + await flushAnimationFrames(4); }); } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -1179,12 +1389,12 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("collapses backspace deletion to the normalized range start", async () => { + it("ignores a late native full-block selectionchange after collapsing to a caret", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello" }, + { type: "insert-text", blockId, offset: 0, text: "Hello world" }, ]); const container = document.createElement("div"); @@ -1212,51 +1422,292 @@ describe("@pen/react selected text deletion", () => { await act(async () => { fieldEditor.activate(blockId); - await flushAnimationFrames(1); + await flushAnimationFrames(2); }); - await act(async () => { - const selection = document.getSelection(); - const range = document.createRange(); - range.setStart(inlineElement!.firstChild ?? inlineElement!, 1); - range.setEnd(inlineElement!.firstChild ?? inlineElement!, 4); + const originalCaretRangeFromPoint = ( + document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + } + ).caretRangeFromPoint; - selection?.removeAllRanges(); - selection?.addRange(range); - document.dispatchEvent(new Event("selectionchange")); - await flushAnimationFrames(1); - }); + try { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 3); + range.collapse(true); + return range; + }; - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 1 }, - focus: { blockId, offset: 4 }, - isCollapsed: false, - isMultiBlock: false, - }); + await act(async () => { + inlineElement!.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 1, + }), + ); - await act(async () => { - inlineElement!.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: true, - inputType: "deleteContentBackward", - }), - ); - await flushAnimationFrames(2); - }); + const collapsedRange = document.createRange(); + collapsedRange.setStart( + inlineElement!.firstChild ?? inlineElement!, + 3, + ); + collapsedRange.collapse(true); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(collapsedRange); - expect(editor.getBlock(blockId)?.textContent()).toBe("Ho"); - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 1 }, - focus: { blockId, offset: 1 }, - isCollapsed: true, - isMultiBlock: false, - }); - expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 1 }, - focus: { blockId, offset: 1 }, + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 1, + }), + ); + + inlineElement!.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 1, + }), + ); + + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 11); + document.dispatchEvent(new Event("selectionchange")); + + await flushAnimationFrames(4); + }); + } finally { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = originalCaretRangeFromPoint; + } + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + isCollapsed: true, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("collapses a double-click word selection to a caret after a paused single click", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello world" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 0, 5); + await flushAnimationFrames(3); + }); + + const originalCaretRangeFromPoint = ( + document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + } + ).caretRangeFromPoint; + + try { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 3); + range.collapse(true); + return range; + }; + + await act(async () => { + inlineElement!.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 1, + }), + ); + + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 5); + + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 12, + clientY: 8, + detail: 1, + }), + ); + await flushAnimationFrames(3); + }); + } finally { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = originalCaretRangeFromPoint; + } + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + isCollapsed: true, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("collapses backspace deletion to the normalized range start", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activate(blockId); + await flushAnimationFrames(1); + }); + + await act(async () => { + const selection = document.getSelection(); + const range = document.createRange(); + range.setStart(inlineElement!.firstChild ?? inlineElement!, 1); + range.setEnd(inlineElement!.firstChild ?? inlineElement!, 4); + + selection?.removeAllRanges(); + selection?.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + await flushAnimationFrames(1); + }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 4 }, + isCollapsed: false, + isMultiBlock: false, + }); + + await act(async () => { + inlineElement!.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "deleteContentBackward", + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Ho"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, + isCollapsed: true, + isMultiBlock: false, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, }); await act(async () => { @@ -1270,7 +1721,9 @@ describe("@pen/react selected text deletion", () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; - editor.apply([{ type: "convert-block", blockId, newType: "blockquote" }]); + editor.apply([ + { type: "convert-block", blockId, newType: "blockquote" }, + ]); const container = document.createElement("div"); document.body.appendChild(container); @@ -1767,7 +2220,9 @@ describe("@pen/react selected text deletion", () => { }); await act(async () => { - inlineElement!.dispatchEvent(createKeyEvent("Backspace", { cancelable: true })); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); await flushAnimationFrames(2); }); @@ -1821,7 +2276,9 @@ describe("@pen/react selected text deletion", () => { }); await act(async () => { - inlineElement!.dispatchEvent(createKeyEvent("Backspace", { cancelable: true })); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); await flushAnimationFrames(2); }); @@ -1875,7 +2332,9 @@ describe("@pen/react selected text deletion", () => { }); await act(async () => { - inlineElement!.dispatchEvent(createKeyEvent("Backspace", { cancelable: true })); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); await flushAnimationFrames(2); }); @@ -1918,9 +2377,9 @@ describe("@pen/react selected text deletion", () => { }); const fieldEditor = getFieldEditor(editor); - const toolbarButton = container.querySelector("button") as - | HTMLButtonElement - | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; const inlineElement = container.querySelector( "[data-pen-inline-content]", ) as HTMLElement | null; @@ -1983,9 +2442,9 @@ describe("@pen/react selected text deletion", () => { }); const fieldEditor = getFieldEditor(editor); - const toolbarButton = container.querySelector("button") as - | HTMLButtonElement - | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; expect(toolbarButton).not.toBeNull(); @@ -2064,7 +2523,9 @@ describe("@pen/react selected text deletion", () => { }); await act(async () => { - inlineElement!.dispatchEvent(createKeyEvent("Backspace", { cancelable: true })); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); await flushAnimationFrames(2); }); @@ -2096,7 +2557,12 @@ describe("@pen/react selected text deletion", () => { const secondBlockId = crypto.randomUUID(); editor.apply([ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, { type: "insert-block", blockId: secondBlockId, @@ -2104,7 +2570,12 @@ describe("@pen/react selected text deletion", () => { props: {}, position: { after: firstBlockId }, }, - { type: "insert-text", blockId: secondBlockId, offset: 0, text: "World" }, + { + type: "insert-text", + blockId: secondBlockId, + offset: 0, + text: "World", + }, ]); const container = document.createElement("div"); @@ -2149,7 +2620,9 @@ describe("@pen/react selected text deletion", () => { }); await act(async () => { - inlineElement!.dispatchEvent(createKeyEvent("Backspace", { cancelable: true })); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); await flushAnimationFrames(4); }); @@ -2265,7 +2738,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("moves the caret into the inserted block after Enter at block end", async () => { + it("restores the DOM selection before insertText when the active selection is stale", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -2286,7 +2759,77 @@ describe("@pen/react selected text deletion", () => { }); const fieldEditor = getFieldEditor(editor); - const rootElement = container.querySelector( + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 5, 5); + await flushAnimationFrames(3); + }); + + const outsideText = document.createTextNode("outside"); + document.body.appendChild(outsideText); + const outsideRange = document.createRange(); + outsideRange.setStart(outsideText, 0); + outsideRange.collapse(true); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(outsideRange); + + const inputEvent = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "!", + }); + + await act(async () => { + inlineElement!.dispatchEvent(inputEvent); + await flushAnimationFrames(2); + }); + + expect(inputEvent.defaultPrevented).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello!"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 6 }, + focus: { blockId, offset: 6 }, + isCollapsed: true, + }); + + await act(async () => { + root.unmount(); + }); + outsideText.remove(); + container.remove(); + editor.destroy(); + }); + + it("moves the caret into the inserted block after Enter at block end", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const rootElement = container.querySelector( "[data-pen-editor-root]", ) as HTMLElement | null; const inlineElement = container.querySelector( @@ -2588,29 +3131,422 @@ describe("@pen/react selected text deletion", () => { await flushAnimationFrames(2); }); - const dragStartEvent = new Event("dragstart", { - bubbles: true, - cancelable: true, - }); - const dropEvent = new Event("drop", { - bubbles: true, - cancelable: true, - }); + const dragStartEvent = new Event("dragstart", { + bubbles: true, + cancelable: true, + }); + const dropEvent = new Event("drop", { + bubbles: true, + cancelable: true, + }); + + expect(inlineElement?.dispatchEvent(dragStartEvent)).toBe(false); + expect(dragStartEvent.defaultPrevented).toBe(true); + expect(inlineElement?.dispatchEvent(dropEvent)).toBe(false); + expect(dropEvent.defaultPrevented).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("keeps advancing the caret for EditContext textupdate events", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 2, 2); + await flushAnimationFrames(3); + }); + + const editContext = inlineElement?.editContext; + expect(editContext).toBeTruthy(); + const originalUpdateText = + editContext!.updateText.bind(editContext); + editContext!.updateText = (start, end, text) => { + originalUpdateText(start, end, text); + editContext!.selectionStart = start; + editContext!.selectionEnd = start; + }; + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 2, + updateRangeEnd: 2, + text: "X", + selectionStart: 3, + selectionEnd: 3, + }); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("HeXllo"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + isCollapsed: true, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 3, + updateRangeEnd: 3, + text: "Y", + selectionStart: 4, + selectionEnd: 4, + }); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("HeXYllo"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 4 }, + focus: { blockId, offset: 4 }, + isCollapsed: true, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 4 }, + focus: { blockId, offset: 4 }, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + + it("uses the editor caret when EditContext reports a stale collapsed insert range", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + expect(rootElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 0, 0); + await flushAnimationFrames(2); + }); + + const editContext = inlineElement?.editContext; + expect(editContext).toBeTruthy(); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 0, + updateRangeEnd: 0, + text: "H", + selectionStart: 0, + selectionEnd: 0, + }); + await flushAnimationFrames(2); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + document.dispatchEvent(new Event("selectionchange")); + await flushAnimationFrames(2); + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, + }); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 1, + updateRangeEnd: 1, + text: "e", + selectionStart: 1, + selectionEnd: 1, + }); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 2 }, + focus: { blockId, offset: 2 }, + }); + await act(async () => { + fieldEditor.syncTextSelection(blockId, 1, 1); + await flushAnimationFrames(1); + }); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, + }); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 1, + updateRangeEnd: 1, + text: "y", + selectionStart: 1, + selectionEnd: 1, + }); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hey"); + expect(inlineElement!.textContent).toBe("Hey"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + expect(editContext?.selectionStart).toBe(3); + expect(editContext?.selectionEnd).toBe(3); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + + it("treats the initial zero-width placeholder as offset zero for EditContext input", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "\u200B" }], + { origin: "import" }, + ); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + expect(rootElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 1, 1); + await flushAnimationFrames(2); + }); + + const editContext = inlineElement?.editContext; + expect(editContext).toBeTruthy(); + expect(editContext?.text).toBe(""); + expect(editContext?.selectionStart).toBe(0); + expect(editContext?.selectionEnd).toBe(0); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 1, + updateRangeEnd: 1, + text: "H", + selectionStart: 1, + selectionEnd: 1, + }); + await flushAnimationFrames(2); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + document.dispatchEvent(new Event("selectionchange")); + await flushAnimationFrames(2); + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, + }); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 0, + updateRangeEnd: 0, + text: "e", + selectionStart: 0, + selectionEnd: 0, + }); + await flushAnimationFrames(2); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + document.dispatchEvent(new Event("selectionchange")); + await flushAnimationFrames(2); + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 2 }, + focus: { blockId, offset: 2 }, + }); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 0, + updateRangeEnd: 0, + text: "y", + selectionStart: 0, + selectionEnd: 0, + }); + await flushAnimationFrames(2); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + document.dispatchEvent(new Event("selectionchange")); + await flushAnimationFrames(2); + }); - expect(inlineElement?.dispatchEvent(dragStartEvent)).toBe(false); - expect(dragStartEvent.defaultPrevented).toBe(true); - expect(inlineElement?.dispatchEvent(dropEvent)).toBe(false); - expect(dropEvent.defaultPrevented).toBe(true); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hey"); + expect(inlineElement!.textContent).toBe("Hey"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, + }); + expect(editContext?.selectionStart).toBe(3); + expect(editContext?.selectionEnd).toBe(3); - await act(async () => { - root.unmount(); - }); - container.remove(); - editor.destroy(); + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } }); - it("keeps advancing the caret for EditContext textupdate events", async () => { + it("updates EditContext text before projecting the post-insert selection", async () => { const originalEditContext = ( globalThis as typeof globalThis & { EditContext?: typeof FakeEditContext; @@ -2624,11 +3560,6 @@ describe("@pen/react selected text deletion", () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello" }, - ]); - const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -2636,78 +3567,155 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); }); const fieldEditor = getFieldEditor(editor); - const rootElement = container.querySelector( - "[data-pen-editor-root]", - ) as HTMLElement | null; const inlineElement = container.querySelector( "[data-pen-inline-content]", ) as | (HTMLElement & { editContext?: FakeEditContext | null }) | null; - expect(rootElement).not.toBeNull(); expect(inlineElement).not.toBeNull(); await act(async () => { - fieldEditor.activateTextSelection(blockId, 2, 2); - await flushAnimationFrames(3); + fieldEditor.activateTextSelection(blockId, 0, 0); + await flushAnimationFrames(2); }); const editContext = inlineElement?.editContext; expect(editContext).toBeTruthy(); + const calls: string[] = []; + const originalUpdateText = + editContext!.updateText.bind(editContext); + const originalUpdateSelection = + editContext!.updateSelection.bind(editContext); + editContext!.updateText = (start, end, text) => { + calls.push( + `dom-before-text:${inlineElement!.textContent ?? ""}`, + ); + calls.push(`text:${start}:${end}:${text}`); + originalUpdateText(start, end, text); + }; + editContext!.updateSelection = (start, end) => { + calls.push(`selection:${start}:${end}`); + originalUpdateSelection(start, end); + }; await act(async () => { editContext!.emit("textupdate", { - updateRangeStart: 2, - updateRangeEnd: 2, - text: "X", - selectionStart: 3, - selectionEnd: 3, + updateRangeStart: 0, + updateRangeEnd: 0, + text: "H", + selectionStart: 0, + selectionEnd: 0, }); await flushAnimationFrames(2); }); - expect(editor.getBlock(blockId)?.textContent()).toBe("HeXllo"); + const textUpdateIndex = calls.indexOf("text:0:0:H"); + const postInsertSelectionIndex = calls.indexOf("selection:1:1"); + expect(calls).toContain("dom-before-text:H"); + expect(textUpdateIndex).toBeGreaterThanOrEqual(0); + expect(postInsertSelectionIndex).toBeGreaterThan(textUpdateIndex); + expect(editor.getBlock(blockId)?.textContent()).toBe("H"); expect(editor.selection).toMatchObject({ type: "text", - anchor: { blockId, offset: 3 }, - focus: { blockId, offset: 3 }, - isCollapsed: true, + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, }); - expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 3 }, - focus: { blockId, offset: 3 }, + expect(editContext?.selectionStart).toBe(1); + expect(editContext?.selectionEnd).toBe(1); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + + it("ignores stale native selectionchange while projecting the EditContext caret", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 0, 0); + await flushAnimationFrames(2); }); + const editContext = inlineElement?.editContext; + expect(editContext).toBeTruthy(); + await act(async () => { editContext!.emit("textupdate", { - updateRangeStart: 3, - updateRangeEnd: 3, - text: "Y", - selectionStart: 4, - selectionEnd: 4, + updateRangeStart: 0, + updateRangeEnd: 0, + text: "H", + selectionStart: 0, + selectionEnd: 0, }); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + document.dispatchEvent(new Event("selectionchange")); await flushAnimationFrames(2); }); - expect(editor.getBlock(blockId)?.textContent()).toBe("HeXYllo"); + expect(editor.getBlock(blockId)?.textContent()).toBe("H"); expect(editor.selection).toMatchObject({ type: "text", - anchor: { blockId, offset: 4 }, - focus: { blockId, offset: 4 }, - isCollapsed: true, - }); - expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 4 }, - focus: { blockId, offset: 4 }, + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 1 }, }); + expect(editContext?.selectionStart).toBe(1); + expect(editContext?.selectionEnd).toBe(1); await act(async () => { root.unmount(); @@ -2745,7 +3753,10 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -2828,7 +3839,10 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -2943,7 +3957,10 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -2952,7 +3969,9 @@ describe("@pen/react selected text deletion", () => { const fieldEditor = getFieldEditor(editor); const inlineElement = container.querySelector( "[data-pen-inline-content]", - ) as (HTMLElement & { editContext?: FakeEditContext | null }) | null; + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; expect(inlineElement).not.toBeNull(); @@ -3024,7 +4043,10 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3033,7 +4055,9 @@ describe("@pen/react selected text deletion", () => { const fieldEditor = getFieldEditor(editor); const inlineElement = container.querySelector( "[data-pen-inline-content]", - ) as (HTMLElement & { editContext?: FakeEditContext | null }) | null; + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; expect(inlineElement).not.toBeNull(); @@ -3084,6 +4108,99 @@ describe("@pen/react selected text deletion", () => { } }); + it("deletes a cmd+a selection on Backspace when the native EditContext range is collapsed", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Title" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activate(blockId); + await flushAnimationFrames(2); + }); + + await act(async () => { + inlineElement!.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 5 }, + isCollapsed: false, + isMultiBlock: false, + }); + + await act(async () => { + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 0 }, + isCollapsed: true, + isMultiBlock: false, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + it("restores logical selection on undo and redo", async () => { const editor = createUndoSelectionDeletionEditor(); const blockId = editor.firstBlock()!.id; @@ -3225,9 +4342,10 @@ describe("@pen/react selected text deletion", () => { await flushAnimationFrames(4); }); - const insertedBlockId = editor.selection?.type === "text" - ? editor.selection.focus.blockId - : null; + const insertedBlockId = + editor.selection?.type === "text" + ? editor.selection.focus.blockId + : null; expect(insertedBlockId).toBeTruthy(); expect(domSelectionToEditor(rootElement!)).toMatchObject({ anchor: { blockId: insertedBlockId, offset: 0 }, @@ -3255,9 +4373,10 @@ describe("@pen/react selected text deletion", () => { await flushAnimationFrames(4); }); - const redoneBlockId = editor.selection?.type === "text" - ? editor.selection.focus.blockId - : null; + const redoneBlockId = + editor.selection?.type === "text" + ? editor.selection.focus.blockId + : null; expect(redoneBlockId).toBeTruthy(); expect(editor.selection).toMatchObject({ type: "text", @@ -3372,9 +4491,9 @@ describe("@pen/react selected text deletion", () => { const inlineElement = container.querySelector( "[data-pen-inline-content]", ) as HTMLElement | null; - const toolbarButton = container.querySelector("button") as - | HTMLButtonElement - | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; expect(inlineElement).not.toBeNull(); expect(toolbarButton).not.toBeNull(); @@ -3467,7 +4586,10 @@ describe("@pen/react selected text deletion", () => { await act(async () => { root.render( - + , @@ -3477,10 +4599,12 @@ describe("@pen/react selected text deletion", () => { const fieldEditor = getFieldEditor(editor); const inlineElement = container.querySelector( "[data-pen-inline-content]", - ) as (HTMLElement & { editContext?: FakeEditContext | null }) | null; - const toolbarButton = container.querySelector("button") as - | HTMLButtonElement + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; expect(inlineElement).not.toBeNull(); expect(toolbarButton).not.toBeNull(); @@ -3581,7 +4705,10 @@ describe("@pen/react selected text deletion", () => { await act(async () => { root.render( - + , ); @@ -3590,7 +4717,9 @@ describe("@pen/react selected text deletion", () => { const fieldEditor = getFieldEditor(editor); const inlineElement = container.querySelector( "[data-pen-inline-content]", - ) as (HTMLElement & { editContext?: FakeEditContext | null }) | null; + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; expect(inlineElement).not.toBeNull(); @@ -3703,9 +4832,9 @@ describe("@pen/react selected text deletion", () => { container.querySelectorAll("[data-pen-inline-content]"), ) as HTMLElement[]; const secondInlineElement = inlineElements[1] ?? null; - const toolbarButton = container.querySelector("button") as - | HTMLButtonElement - | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; expect(secondInlineElement).not.toBeNull(); expect(toolbarButton).not.toBeNull(); @@ -3837,9 +4966,9 @@ describe("@pen/react selected text deletion", () => { container.querySelectorAll("[data-pen-inline-content]"), ) as HTMLElement[]; const thirdInlineElement = inlineElements[2] ?? null; - const toolbarButton = container.querySelector("button") as - | HTMLButtonElement - | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; expect(thirdInlineElement).not.toBeNull(); expect(toolbarButton).not.toBeNull(); @@ -3942,5 +5071,4 @@ describe("@pen/react selected text deletion", () => { container.remove(); editor.destroy(); }); - }); diff --git a/packages/rendering/react/src/primitives/editor/caretOverlay.tsx b/packages/rendering/react/src/primitives/editor/caretOverlay.tsx index cb1c3b2..6e6528a 100644 --- a/packages/rendering/react/src/primitives/editor/caretOverlay.tsx +++ b/packages/rendering/react/src/primitives/editor/caretOverlay.tsx @@ -46,14 +46,15 @@ export function EditorCaretOverlay(props: EditorCaretOverlayProps) { const selection = useSelection(editor); const fieldEditorState = useFieldEditorState(fieldEditor); - const { elementRef, rootElement, layoutVersion } = useOverlayLayout([ - selection, - fieldEditorState.focusBlockId, - fieldEditorState.isEditing, - fieldEditorState.isFocused, - fieldEditorState.isComposing, - fieldEditorState.mode, - ]); + const { elementRef, rootElement, layoutVersion } = + useOverlayLayout([ + selection, + fieldEditorState.focusBlockId, + fieldEditorState.isEditing, + fieldEditorState.isFocused, + fieldEditorState.isComposing, + fieldEditorState.mode, + ]); const caretSelection = resolveCaretSelection(selection, fieldEditorState); const rect = @@ -110,11 +111,11 @@ export function EditorCaretOverlay(props: EditorCaretOverlayProps) { rect, blinkPaused, ); - caretNode = renderCaret - ? renderCaret(renderProps) - : ( -
- ); + caretNode = renderCaret ? ( + renderCaret(renderProps) + ) : ( +
+ ); } return renderAsChild( @@ -162,15 +163,20 @@ function createCaretRenderProps( ): EditorCaretRenderProps { const height = Math.max(rect.height, 16); const point = selection.focus; + const isMacOS = isMacOSPlatform(); + const defaultCaretColor = isMacOS + ? "var(--palette-blue, #0a84ff)" + : "var(--palette-b100, currentColor)"; + const defaultCaretWidth = isMacOS ? "2px" : "1px"; + const defaultCaretRadius = isMacOS ? "999px" : "0px"; const caretStyle: CaretStyle = { position: "fixed", left: `${rect.left}px`, top: `${rect.top}px`, height: `${height}px`, - width: "var(--pen-editor-caret-width, var(--pen-caret-width, 1px))", - borderRadius: "var(--pen-editor-caret-radius, 999px)", - background: - "var(--pen-editor-caret-color, var(--pen-caret-color, currentColor))", + width: `var(--pen-editor-caret-width, var(--pen-caret-width, ${defaultCaretWidth}))`, + borderRadius: `var(--pen-editor-caret-radius, var(--pen-caret-radius, ${defaultCaretRadius}))`, + background: `var(--pen-editor-caret-color, var(--pen-caret-color, ${defaultCaretColor}))`, boxShadow: "var(--pen-editor-caret-shadow, none)", animation: blinkPaused ? "none" @@ -194,13 +200,21 @@ function createCaretRenderProps( }; } +function isMacOSPlatform(): boolean { + return ( + typeof navigator !== "undefined" && + /\bMacintosh\b|\bMac OS X\b/.test(navigator.userAgent) + ); +} + function useCaretBlinkPauseState(options: { rootElement: HTMLElement | null; layoutVersion: number; caretSelection: TextSelection | null; isCaretVisible: boolean; }): boolean { - const { rootElement, layoutVersion, caretSelection, isCaretVisible } = options; + const { rootElement, layoutVersion, caretSelection, isCaretVisible } = + options; const [blinkPaused, setBlinkPaused] = useState(false); const resumeTimeoutRef = useRef(null); diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 20c8542..2f6ac46 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -35,6 +35,7 @@ export function InlineContent(props: InlineContentProps) { const textSnapshot = useBlockTextSnapshot(editor, blockId); const elementRef = useRef(null); const previousCommitRevisionRef = useRef(blockCommit.revision); + const previousRenderedDeltasSignatureRef = useRef(null); const isExpandedOwnedBlock = fieldEditorState.mode === "expanded" && fieldEditorState.activeBlockIds.includes(blockId); @@ -62,24 +63,26 @@ export function InlineContent(props: InlineContentProps) { !!schemaPlaceholder && !showDocumentPlaceholder; - const placeholder = - showDocumentPlaceholder - ? emptyPlaceholder - : showExplicitPlaceholder - ? placeholderProp - : showBlockPlaceholder - ? schemaPlaceholder - : undefined; + const placeholder = showDocumentPlaceholder + ? emptyPlaceholder + : showExplicitPlaceholder + ? placeholderProp + : showBlockPlaceholder + ? schemaPlaceholder + : undefined; const inlineDecorations = blockDecorations.filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", + (decoration): decoration is InlineDecoration => + decoration.type === "inline", ); const renderedDeltas = inlineDecorations.length > 0 ? applyInlineDecorationsToDeltas( - textSnapshot.deltas, - inlineDecorations, - ) + textSnapshot.deltas, + inlineDecorations, + ) : textSnapshot.deltas; + const renderedDeltasText = getDeltaText(renderedDeltas); + const renderedDeltasSignature = getDeltaSignature(renderedDeltas); useLayoutEffect(() => { if (fieldEditorState.mode === "expanded") { @@ -106,6 +109,29 @@ export function InlineContent(props: InlineContentProps) { didCommitAdvance && blockCommit.origin === "history"; if (isExpandedOwnedBlock || isActive) { + if (!elementRef.current || fieldEditorState.isComposing) { + return; + } + if (!textSnapshot.exists) { + elementRef.current.replaceChildren(); + previousRenderedDeltasSignatureRef.current = null; + return; + } + if ( + elementRef.current.textContent === renderedDeltasText && + previousRenderedDeltasSignatureRef.current === + renderedDeltasSignature + ) { + return; + } + fullReconcileDeltasToDOM( + [...renderedDeltas], + elementRef.current, + editor.schema, + { preserveSelection: true }, + ); + previousRenderedDeltasSignatureRef.current = + renderedDeltasSignature; return; } if (!elementRef.current) { @@ -119,6 +145,7 @@ export function InlineContent(props: InlineContentProps) { } if (!textSnapshot.exists) { elementRef.current.replaceChildren(); + previousRenderedDeltasSignatureRef.current = null; return; } fullReconcileDeltasToDOM( @@ -127,6 +154,7 @@ export function InlineContent(props: InlineContentProps) { editor.schema, { preserveSelection: false }, ); + previousRenderedDeltasSignatureRef.current = renderedDeltasSignature; }, [ editor, isExpandedOwnedBlock, @@ -136,11 +164,15 @@ export function InlineContent(props: InlineContentProps) { blockCommit, isActive, renderedDeltas, + renderedDeltasSignature, + renderedDeltasText, textSnapshot, ]); const showPlaceholder = - showDocumentPlaceholder || showExplicitPlaceholder || showBlockPlaceholder; + showDocumentPlaceholder || + showExplicitPlaceholder || + showBlockPlaceholder; const isActiveSurface = isActive && fieldEditorState.mode !== "expanded"; const primitiveProps: Record = { @@ -151,8 +183,8 @@ export function InlineContent(props: InlineContentProps) { "data-placeholder": showPlaceholder ? placeholder : undefined, style: showPlaceholder ? { - position: "relative" as const, - } + position: "relative" as const, + } : undefined, }; @@ -167,3 +199,15 @@ function resolveSchemaPlaceholder( if (!block) return undefined; return editor.schema.resolve(block.type)?.placeholder; } + +function getDeltaText(deltas: readonly { insert: string }[]): string { + return deltas.map((delta) => delta.insert).join(""); +} + +function getDeltaSignature( + deltas: readonly { attributes?: Record; insert: string }[], +): string { + return JSON.stringify( + deltas.map((delta) => [delta.insert, delta.attributes ?? null]), + ); +} diff --git a/packages/rendering/react/src/primitives/editor/root.tsx b/packages/rendering/react/src/primitives/editor/root.tsx index 2701f79..72c4566 100644 --- a/packages/rendering/react/src/primitives/editor/root.tsx +++ b/packages/rendering/react/src/primitives/editor/root.tsx @@ -1,8 +1,6 @@ import React, { useRef, useEffect, useState } from "react"; import { FIELD_EDITOR_SLOT_KEY as CORE_FIELD_EDITOR_SLOT_KEY } from "@pen/types"; -import { - generateId, -} from "@pen/types"; +import { generateId } from "@pen/types"; import type { AssetProvider, Editor, @@ -33,7 +31,10 @@ import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { composeRefs } from "../../utils/composeRefs"; import { DATA_ATTRS } from "../../utils/dataAttributes"; import { handleEscapeSelectionTransition } from "../../utils/escapeSelection"; -import { handleSelectAllShortcut, handleHistoryShortcut } from "../../field-editor/keyHandling"; +import { + handleSelectAllShortcut, + handleHistoryShortcut, +} from "../../field-editor/keyHandling"; import { getAdjacentVisibleBlockId } from "../../utils/parentIdTree"; import { handleTableCellSelectionKeyDown } from "../../utils/tableCellNavigation"; import { BlockDragSessionProvider } from "./blockDragSession"; @@ -47,6 +48,7 @@ type DatabaseRowSelectionController = { export interface EditorRootProps extends AsChildProps { editor: Editor; readonly?: boolean; + inputBackend?: "contenteditable" | "edit-context"; importers?: PasteImporters; assets?: AssetProvider; renderers?: RendererOverrides; @@ -61,6 +63,7 @@ export function EditorRoot(props: EditorRootProps) { const { editor, readonly = false, + inputBackend = "edit-context", importers, assets, renderers, @@ -92,6 +95,7 @@ export function EditorRoot(props: EditorRootProps) { if (!fieldEditorRef.current) { fieldEditorRef.current = new FieldEditorImpl(editor, { selectAllBehavior: resolvedInteractionModel.selectAllBehavior, + inputBackend, }); } if (!regionSelectionStoreRef.current) { @@ -197,14 +201,21 @@ export function EditorRoot(props: EditorRootProps) { return; } - if (handleDeleteSelectionShortcut(event, editor, fieldEditor, root)) { + if ( + handleDeleteSelectionShortcut(event, editor, fieldEditor, root) + ) { event.preventDefault(); event.stopImmediatePropagation(); return; } if ( - handleTableCellSelectionKeyDown({ event, editor, fieldEditor, root }) + handleTableCellSelectionKeyDown({ + event, + editor, + fieldEditor, + root, + }) ) { event.preventDefault(); event.stopImmediatePropagation(); @@ -221,7 +232,14 @@ export function EditorRoot(props: EditorRootProps) { return; } - if (handleBlockSelectionEnter(event, editor, fieldEditor, interactionModelRef.current)) { + if ( + handleBlockSelectionEnter( + event, + editor, + fieldEditor, + interactionModelRef.current, + ) + ) { event.preventDefault(); event.stopImmediatePropagation(); return; @@ -352,7 +370,10 @@ function shouldHandleEditorKeyboardEvent( ) { return true; } - return shouldHandleCollapsedFieldEditorSelectAll(event, activeElement); + return shouldHandleCollapsedFieldEditorSelectAll( + event, + activeElement, + ); } return true; } @@ -520,7 +541,10 @@ function handleBlockSelectionEnter( return false; } - if (interactionModelResolved.model === "block-first" && selection.blockIds.length === 1) { + if ( + interactionModelResolved.model === "block-first" && + selection.blockIds.length === 1 + ) { const schema = editor.schema.resolve(anchorBlock.type); if (usesInlineTextSelection(schema)) { const offset = anchorBlock.length(); @@ -532,15 +556,18 @@ function handleBlockSelectionEnter( const anchorSchema = editor.schema.resolve(anchorBlock.type); const newBlockId = generateId(); - editor.apply([ - { - type: "insert-block", - blockId: newBlockId, - blockType: "paragraph", - props: {}, - position: { after: anchorBlockId }, - }, - ], { origin: "user" }); + editor.apply( + [ + { + type: "insert-block", + blockId: newBlockId, + blockType: "paragraph", + props: {}, + position: { after: anchorBlockId }, + }, + ], + { origin: "user" }, + ); fieldEditor.activateTextSelection(newBlockId, 0, 0); return true; @@ -622,15 +649,18 @@ function tryDeleteSelectedDatabaseRows( root: HTMLElement, editor: Editor, ): boolean { - const controller = editor.internals.getSlot( - DATABASE_ROW_SELECTION_SLOT, - ) as DatabaseRowSelectionController | undefined; + const controller = editor.internals.getSlot(DATABASE_ROW_SELECTION_SLOT) as + | DatabaseRowSelectionController + | undefined; if (!controller) { return false; } const activeElement = root.ownerDocument?.activeElement; - if (!(activeElement instanceof HTMLElement) || !root.contains(activeElement)) { + if ( + !(activeElement instanceof HTMLElement) || + !root.contains(activeElement) + ) { return false; } @@ -657,7 +687,10 @@ function shouldUseDocumentTextDeletionFallback( } const activeElement = root.ownerDocument?.activeElement; - if (!(activeElement instanceof HTMLElement) || !root.contains(activeElement)) { + if ( + !(activeElement instanceof HTMLElement) || + !root.contains(activeElement) + ) { return true; } @@ -674,4 +707,3 @@ function shouldUseDocumentTextDeletionFallback( return false; } - From 4cc0ba355c28c0acf980bb04db1f03c7eefe38a3 Mon Sep 17 00:00:00 2001 From: krijn Date: Mon, 11 May 2026 15:17:01 +0200 Subject: [PATCH 08/20] Cover repeated EditContext selection deletion --- .../__tests__/selectedTextDeletion.test.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx index 230aac4..6dec723 100644 --- a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx +++ b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx @@ -4201,6 +4201,126 @@ describe("@pen/react selected text deletion", () => { } }); + it("keeps repeated cmd+a deletion working after retyping in EditContext", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Title" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activate(blockId); + await flushAnimationFrames(2); + }); + + await act(async () => { + inlineElement!.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + + const editContext = inlineElement?.editContext; + expect(editContext).toBeTruthy(); + + await act(async () => { + editContext!.emit("textupdate", { + updateRangeStart: 0, + updateRangeEnd: 0, + text: "Again", + selectionStart: 5, + selectionEnd: 5, + }); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Again"); + + await act(async () => { + inlineElement!.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 5 }, + isCollapsed: false, + isMultiBlock: false, + }); + + await act(async () => { + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + inlineElement!.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 0 }, + isCollapsed: true, + isMultiBlock: false, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + it("restores logical selection on undo and redo", async () => { const editor = createUndoSelectionDeletionEditor(); const blockId = editor.firstBlock()!.id; From e75c518664a56b0e2ea025e6c35d8516e0363608 Mon Sep 17 00:00:00 2001 From: krijn Date: Mon, 11 May 2026 15:31:42 +0200 Subject: [PATCH 09/20] Ignore stale collapsed EditContext DOM selections --- .../src/field-editor/editContextBackend.ts | 48 +++++++++++++++++++ .../__tests__/selectedTextDeletion.test.tsx | 3 ++ 2 files changed, 51 insertions(+) diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index f28001a..f6f9ec9 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -545,6 +545,32 @@ export class EditContextBackend implements InputBackend { }; } + private shouldIgnoreStaleCollapsedDomSelection( + selection: ReturnType, + ): boolean { + if (selection.type === "block") { + return false; + } + if ( + selection.anchor.blockId !== selection.focus.blockId || + selection.anchor.offset !== selection.focus.offset + ) { + return false; + } + + const editorSelectionRange = this.resolveEditorSelectionRange( + selection.anchor.blockId, + ); + if (!editorSelectionRange) { + return false; + } + + return ( + selection.anchor.offset !== editorSelectionRange.start || + selection.focus.offset !== editorSelectionRange.end + ); + } + private applyInlineInputRule( blockId: string, offset: number, @@ -714,6 +740,11 @@ export class EditContextBackend implements InputBackend { mappedSelection, ); + if (this.shouldIgnoreStaleCollapsedDomSelection(normalizedSelection)) { + this.restoreDOMCaret(); + return; + } + if (normalizedSelection.type === "block") { this.fieldEditor.deactivate(); this.editor.setSelection({ @@ -752,6 +783,23 @@ export class EditContextBackend implements InputBackend { const offsets = getDirectionalSelectionOffsets(this.element); if (!offsets) return; + const editorSelectionRange = this.resolveEditorSelectionRange( + normalizedSelection.anchor.blockId, + ); + if ( + editorSelectionRange && + offsets.anchor === offsets.focus && + (offsets.start !== editorSelectionRange.start || + offsets.end !== editorSelectionRange.end) + ) { + this.setEditContextSelection({ + blockId: normalizedSelection.anchor.blockId, + anchorOffset: editorSelectionRange.start, + focusOffset: editorSelectionRange.end, + }); + this.restoreDOMCaret(); + return; + } const authoritativeSelection = this.getAuthoritativeTextInputSelection( normalizedSelection.anchor.blockId, ); diff --git a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx index 6dec723..d21b6b3 100644 --- a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx +++ b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx @@ -4159,6 +4159,9 @@ describe("@pen/react selected text deletion", () => { await act(async () => { inlineElement!.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(4); + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + document.dispatchEvent(new Event("selectionchange")); await flushAnimationFrames(2); }); From 42a723355ead3b31fd4a83df05806e356c7e17a7 Mon Sep 17 00:00:00 2001 From: krijn Date: Wed, 13 May 2026 00:22:25 +0200 Subject: [PATCH 10/20] Enhance editor functionality and improve keyboard handling - Added a test for splitting a block at offset zero by inserting an empty block above, ensuring correct block order and text content. - Introduced new keyboard handling utilities for editor document interactions, including handling keyboard events for text entry targets and table cell navigation. - Updated the editor context to support block selection options, allowing for more flexible interaction models. - Refactored editor components to improve readability and maintainability, including updates to the caret overlay and content handling logic. --- .../core/src/__tests__/editorCore.test.ts | 29 + .../dom/src/__tests__/publicApi.test.ts | 238 ++++- .../dom/src/field-editor/controller.ts | 45 +- .../src/field-editor/editContextBackend.ts | 314 ++++-- .../dom/src/field-editor/fieldEditorImpl.ts | 26 +- packages/rendering/dom/src/index.ts | 12 + .../dom/src/utils/documentShortcuts.ts | 282 +++++ .../src/utils/escapeSelection.ts | 0 .../src/utils/tableCellNavigation.ts | 14 +- .../dom/src/utils/textEntryTarget.ts | 233 +++++ .../src/__tests__/blockSelection.test.tsx | 284 ++++++ .../src/__tests__/editorCaretOverlay.test.tsx | 21 +- .../__tests__/selectedTextDeletion.test.tsx | 451 ++++++-- .../src/__tests__/tableCellNavigation.test.ts | 2 +- .../react/src/context/editorContext.ts | 23 + packages/rendering/react/src/context/index.ts | 3 + packages/rendering/react/src/index.ts | 4 + .../src/primitives/editor/caretOverlay.tsx | 26 +- .../react/src/primitives/editor/content.tsx | 43 +- .../react/src/primitives/editor/index.ts | 2 + .../react/src/primitives/editor/root.tsx | 486 +-------- .../src/primitives/editor/selectionRect.tsx | 12 +- .../rendering/react/src/primitives/index.ts | 3 + .../react/src/primitives/toolbar/root.tsx | 3 + .../rendering/vue/src/__tests__/mount.test.ts | 33 +- .../rendering/vue/src/components/PenEditor.ts | 963 ++++-------------- spec/charter/architecture.md | 4 +- spec/packages/core.md | 2 + spec/packages/presets/default.md | 4 + spec/packages/rendering/dom.md | 18 +- spec/packages/rendering/react.md | 15 +- spec/packages/rendering/vue.md | 9 +- 32 files changed, 2168 insertions(+), 1436 deletions(-) create mode 100644 packages/rendering/dom/src/utils/documentShortcuts.ts rename packages/rendering/{react => dom}/src/utils/escapeSelection.ts (100%) rename packages/rendering/{react => dom}/src/utils/tableCellNavigation.ts (97%) create mode 100644 packages/rendering/dom/src/utils/textEntryTarget.ts create mode 100644 packages/rendering/react/src/__tests__/blockSelection.test.tsx diff --git a/packages/core/src/__tests__/editorCore.test.ts b/packages/core/src/__tests__/editorCore.test.ts index c628d4d..cca3e44 100644 --- a/packages/core/src/__tests__/editorCore.test.ts +++ b/packages/core/src/__tests__/editorCore.test.ts @@ -719,6 +719,35 @@ describe("@pen/core createEditor", () => { editor.destroy(); }); + it("splits at offset zero by inserting an empty block above", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "hello world", + }, + ]); + + editor.apply([ + { + type: "split-block", + blockId, + offset: 0, + newBlockId: "b2", + }, + ]); + + expect(editor.documentState.blockOrder).toEqual([blockId, "b2"]); + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.getBlock("b2")?.textContent()).toBe("hello world"); + + editor.destroy(); + }); + it("preserves full text offsets for code blocks", () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; diff --git a/packages/rendering/dom/src/__tests__/publicApi.test.ts b/packages/rendering/dom/src/__tests__/publicApi.test.ts index e7734ea..f5ed62e 100644 --- a/packages/rendering/dom/src/__tests__/publicApi.test.ts +++ b/packages/rendering/dom/src/__tests__/publicApi.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_SELECT_ALL_BEHAVIOR, + handleEditorDocumentKeyDown, + isActiveFieldEditorTextEntryTarget, + isFieldEditorTextEditingKey, + isFieldEditorTextEntryTarget, + isNativeTextEntryTarget, resolveSelectAllBehavior, + shouldHandleEditorKeyboardEvent, } from "../index"; import { DATA_ATTRS, @@ -13,7 +21,9 @@ describe("@pen/dom public helpers", () => { it("resolves select-all behavior from the interaction model", () => { expect(DEFAULT_SELECT_ALL_BEHAVIOR).toBe("document-first"); expect(resolveSelectAllBehavior("block-first")).toBe("block-first"); - expect(resolveSelectAllBehavior("content-first")).toBe("document-first"); + expect(resolveSelectAllBehavior("content-first")).toBe( + "document-first", + ); }); it("builds DOM data attributes predictably", () => { @@ -33,4 +43,228 @@ describe("@pen/dom public helpers", () => { "data-index": "2", }); }); + + it("classifies native and field-editor text entry targets", () => { + const input = document.createElement("input"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + const textbox = document.createElement("div"); + textbox.setAttribute("role", "textbox"); + const fieldSurface = document.createElement("div"); + fieldSurface.setAttribute(DATA_ATTRS.fieldEditorSurface, ""); + const activeFieldSurface = document.createElement("div"); + activeFieldSurface.setAttribute( + DATA_ATTRS.fieldEditorActiveSurface, + "", + ); + + expect(isNativeTextEntryTarget(input)).toBe(true); + expect(isNativeTextEntryTarget(checkbox)).toBe(false); + expect(isNativeTextEntryTarget(textbox)).toBe(true); + expect(isFieldEditorTextEntryTarget(fieldSurface)).toBe(true); + expect(isActiveFieldEditorTextEntryTarget(activeFieldSurface)).toBe( + true, + ); + }); + + it("rejects modified or composing field-editor editing keys", () => { + expect( + isFieldEditorTextEditingKey( + new KeyboardEvent("keydown", { key: "a" }), + ), + ).toBe(true); + expect( + isFieldEditorTextEditingKey( + new KeyboardEvent("keydown", { key: "a", metaKey: true }), + ), + ).toBe(false); + expect( + isFieldEditorTextEditingKey( + new KeyboardEvent("keydown", { + key: "Backspace", + isComposing: true, + }), + ), + ).toBe(false); + }); + + it("routes document keyboard handling through the shared text-entry model", () => { + const root = document.createElement("div"); + root.setAttribute(DATA_ATTRS.editorRoot, ""); + const activeFieldSurface = document.createElement("div"); + activeFieldSurface.setAttribute(DATA_ATTRS.fieldEditorSurface, ""); + activeFieldSurface.setAttribute( + DATA_ATTRS.fieldEditorActiveSurface, + "", + ); + root.append(activeFieldSurface); + document.body.append(root); + + let shouldHandleCollapsedText = true; + activeFieldSurface.addEventListener( + "keydown", + (event) => { + shouldHandleCollapsedText = shouldHandleEditorKeyboardEvent({ + root, + event, + selection: { + type: "text", + isCollapsed: true, + isMultiBlock: false, + }, + }); + }, + { once: true }, + ); + activeFieldSurface.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + expect(shouldHandleCollapsedText).toBe(false); + + let shouldHandleMultiBlockText = false; + activeFieldSurface.addEventListener( + "keydown", + (event) => { + shouldHandleMultiBlockText = shouldHandleEditorKeyboardEvent({ + root, + event, + selection: { + type: "text", + isCollapsed: false, + isMultiBlock: true, + }, + }); + }, + { once: true }, + ); + activeFieldSurface.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + expect(shouldHandleMultiBlockText).toBe(true); + + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { key: "Backspace" }), + selection: { + type: "block", + blockIds: ["block-1"], + }, + }), + ).toBe(true); + + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { + key: "z", + metaKey: true, + }), + selection: { + type: "text", + isCollapsed: true, + isMultiBlock: false, + }, + }), + ).toBe(true); + + const externalInput = document.createElement("input"); + document.body.append(externalInput); + externalInput.focus(); + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { + key: "z", + metaKey: true, + }), + selection: { + type: "text", + isCollapsed: true, + isMultiBlock: false, + }, + }), + ).toBe(false); + externalInput.remove(); + + root.remove(); + }); + + it("keeps keyboard routing scoped to the active editor root", () => { + const root = document.createElement("div"); + root.setAttribute(DATA_ATTRS.editorRoot, ""); + const otherRoot = document.createElement("div"); + otherRoot.setAttribute(DATA_ATTRS.editorRoot, ""); + const otherFieldSurface = document.createElement("div"); + otherFieldSurface.setAttribute(DATA_ATTRS.fieldEditorSurface, ""); + otherRoot.append(otherFieldSurface); + document.body.append(root, otherRoot); + + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { + key: "Backspace", + }), + selection: null, + hasMappedDomSelection: () => true, + }), + ).toBe(true); + + let shouldHandleOtherRootEvent = true; + otherFieldSurface.addEventListener( + "keydown", + (event) => { + shouldHandleOtherRootEvent = shouldHandleEditorKeyboardEvent({ + root, + event, + selection: { + type: "block", + blockIds: ["block-1"], + }, + }); + }, + { once: true }, + ); + otherFieldSurface.dispatchEvent( + new KeyboardEvent("keydown", { key: "Backspace", bubbles: true }), + ); + expect(shouldHandleOtherRootEvent).toBe(false); + + root.remove(); + otherRoot.remove(); + }); + + it("deletes document selections through the shared keydown handler with user origin", () => { + const root = document.createElement("div"); + const deleteSelection = vi.fn(); + const deactivate = vi.fn(); + const editor = { + selection: { + type: "block", + blockIds: ["block-1"], + }, + deleteSelection, + firstBlock: () => null, + internals: { + getSlot: () => undefined, + }, + }; + const fieldEditor = { + deactivate, + isComposing: false, + isEditing: false, + }; + + const handled = handleEditorDocumentKeyDown({ + event: new KeyboardEvent("keydown", { key: "Backspace" }), + editor: editor as never, + fieldEditor: fieldEditor as never, + root, + }); + + expect(handled).toBe(true); + expect(deleteSelection).toHaveBeenCalledWith({ origin: "user" }); + expect(deactivate).toHaveBeenCalled(); + }); }); diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index c67931e..8371481 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -10,11 +10,7 @@ export type ActiveCellCoord = { type FieldEditorSelectionState = Pick< FieldEditorStore, - | "focusBlockId" - | "selection" - | "inputMode" - | "isEditing" - | "isComposing" + "focusBlockId" | "selection" | "inputMode" | "isEditing" | "isComposing" > & { readonly activeCellCoord: ActiveCellCoord | null; }; @@ -22,6 +18,7 @@ type FieldEditorSelectionState = Pick< export interface FieldEditorRootHandle { setRootElement(element: HTMLElement | null): void; setFocused(focused: boolean): void; + setInputBackend(inputBackend: "contenteditable" | "edit-context"): void; setSelectAllBehavior(behavior: EditorSelectAllBehavior): void; deactivate(): void; activateTextSelection( @@ -62,8 +59,10 @@ export interface FieldEditorDomController extends FieldEditorSelectionState { deactivate(): void; } -export interface FieldEditorKeyboardController - extends Pick { +export interface FieldEditorKeyboardController extends Pick< + FieldEditorSelectionState, + "focusBlockId" | "inputMode" +> { readonly activeCellCoord: ActiveCellCoord | null; activateCell(blockId: string, row: number, col: number): void; activateTextSelection( @@ -92,11 +91,10 @@ export interface FieldEditorTableNavigationController { deactivate(): void; } -export interface FieldEditorEscapeController - extends Pick< - FieldEditorSelectionState, - "focusBlockId" | "isEditing" | "isComposing" - > { +export interface FieldEditorEscapeController extends Pick< + FieldEditorSelectionState, + "focusBlockId" | "isEditing" | "isComposing" +> { readonly activeCellCoord: ActiveCellCoord | null; collapseSelectionToFocus(): void; deactivate(): void; @@ -120,13 +118,16 @@ export type FieldEditorSession = FieldEditorStore & FieldEditorEscapeController & { beginPointerSelection(): void; endPointerSelection(): void; - selectAll(rootElement?: HTMLElement | null): boolean; - resetSelectAllCycle(): void; - suspendForPointerSelection(): void; - getPendingMarks(): Readonly>; - togglePendingMark(markType: string): boolean; - clearPendingMarks(): void; - collapseSelectionToAnchor(): void; - collapseSelectionToPoint(point: { blockId: string; offset: number }): void; - delegate(blockSchema: BlockSchema): boolean; -}; + selectAll(rootElement?: HTMLElement | null): boolean; + resetSelectAllCycle(): void; + suspendForPointerSelection(): void; + getPendingMarks(): Readonly>; + togglePendingMark(markType: string): boolean; + clearPendingMarks(): void; + collapseSelectionToAnchor(): void; + collapseSelectionToPoint(point: { + blockId: string; + offset: number; + }): void; + delegate(blockSchema: BlockSchema): boolean; + }; diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index f6f9ec9..db6693c 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -19,6 +19,7 @@ import { isHistoryTransactionOrigin } from "./historyOrigin"; import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; import type { PasteImporters } from "../types/paste"; import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; import type { FieldEditorObserver, FieldEditorTextChangeEvent, @@ -50,6 +51,16 @@ type EditContextRange = { end: number; }; +type DirectionalSelectionOffsets = NonNullable< + ReturnType +>; + +type KeyDownRangeResolution = { + range: EditContextRange; + nextSelection: EditContextSelection | null; + shouldSyncEditContextSelection: boolean; +}; + type EditContextTextFormat = { rangeStart: number; rangeEnd: number; @@ -936,26 +947,25 @@ export class EditContextBackend implements InputBackend { blockId != null && this.editContextSelection?.blockId === blockId ? this.editContextSelection : null; + const editorSelection = + selection?.type === "text" && + blockId && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ? selection + : null; const anchorOffset = pendingSelection?.anchorOffset ?? authoritativeInputSelection?.anchorOffset ?? + editorSelection?.anchor.offset ?? editContextSelection?.anchorOffset ?? - (selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? selection.anchor.offset - : null); + null; const focusOffset = pendingSelection?.focusOffset ?? authoritativeInputSelection?.focusOffset ?? + editorSelection?.focus.offset ?? editContextSelection?.focusOffset ?? - (selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? selection.focus.offset - : null); + null; if (root && blockId && anchorOffset != null && focusOffset != null) { this.isApplyingSelection++; editorSelectionToDOM( @@ -1013,62 +1023,11 @@ export class EditContextBackend implements InputBackend { const blockId = this.fieldEditor.focusBlockId; const liveDomOffsets = getDirectionalSelectionOffsets(this.element); - const editorSelectionRange = blockId - ? this.resolveEditorSelectionRange(blockId) - : null; - const shouldUseEditorSelectionRange = - editorSelectionRange != null && - (!liveDomOffsets || - (liveDomOffsets.start === liveDomOffsets.end && - (liveDomOffsets.start !== editorSelectionRange.start || - liveDomOffsets.end !== editorSelectionRange.end))); - const range = shouldUseEditorSelectionRange - ? editorSelectionRange - : liveDomOffsets - ? { - start: liveDomOffsets.start, - end: liveDomOffsets.end, - } - : { - start: Math.min( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - end: Math.max( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - }; - const authoritativeSelection = this.fieldEditor.focusBlockId - ? this.getAuthoritativeTextInputSelection( - this.fieldEditor.focusBlockId, - ) - : null; - const shouldUseLiveDomSelection = - !!liveDomOffsets && - !( - authoritativeSelection && - liveDomOffsets.anchor === liveDomOffsets.focus && - (liveDomOffsets.anchor !== - authoritativeSelection.anchorOffset || - liveDomOffsets.focus !== authoritativeSelection.focusOffset) - ); - if (blockId && shouldUseEditorSelectionRange) { - this.editContext.updateSelection(range.start, range.end); - this.editContextSelection = { - blockId, - anchorOffset: range.start, - focusOffset: range.end, - }; - } else if (liveDomOffsets && shouldUseLiveDomSelection) { + const { range, nextSelection, shouldSyncEditContextSelection } = + this.resolveKeyDownRange(blockId, event, liveDomOffsets); + + if (shouldSyncEditContextSelection) { this.editContext.updateSelection(range.start, range.end); - const nextSelection = blockId - ? { - blockId, - anchorOffset: liveDomOffsets.anchor, - focusOffset: liveDomOffsets.focus, - } - : null; this.editContextSelection = nextSelection; } @@ -1084,6 +1043,193 @@ export class EditContextBackend implements InputBackend { } }; + private resolveKeyDownRange( + blockId: string | null, + event: KeyboardEvent, + liveDomOffsets: DirectionalSelectionOffsets | null, + ): KeyDownRangeResolution { + if (!blockId) { + return { + range: liveDomOffsets + ? directionalSelectionToRange(liveDomOffsets) + : this.resolveEditContextSelectionRange(), + nextSelection: null, + shouldSyncEditContextSelection: false, + }; + } + + const editorSelectionRange = this.resolveEditorSelectionRange(blockId); + const trustedKeyRange = this.resolveTrustedKeyDownRange( + blockId, + event, + editorSelectionRange, + ); + if (trustedKeyRange) { + return { + range: trustedKeyRange, + nextSelection: rangeToSelection(blockId, trustedKeyRange), + shouldSyncEditContextSelection: true, + }; + } + + if ( + editorSelectionRange && + (!liveDomOffsets || + (liveDomOffsets.start === liveDomOffsets.end && + !rangesEqual(liveDomOffsets, editorSelectionRange))) + ) { + return { + range: editorSelectionRange, + nextSelection: rangeToSelection(blockId, editorSelectionRange), + shouldSyncEditContextSelection: true, + }; + } + + if (liveDomOffsets && this.shouldUseLiveDomSelection(blockId, liveDomOffsets)) { + return { + range: directionalSelectionToRange(liveDomOffsets), + nextSelection: { + blockId, + anchorOffset: liveDomOffsets.anchor, + focusOffset: liveDomOffsets.focus, + }, + shouldSyncEditContextSelection: true, + }; + } + + return { + range: liveDomOffsets + ? directionalSelectionToRange(liveDomOffsets) + : this.resolveEditContextSelectionRange(), + nextSelection: null, + shouldSyncEditContextSelection: false, + }; + } + + private shouldUseLiveDomSelection( + blockId: string, + liveDomOffsets: DirectionalSelectionOffsets, + ): boolean { + const authoritativeSelection = + this.getAuthoritativeTextInputSelection(blockId); + return !( + authoritativeSelection && + liveDomOffsets.anchor === liveDomOffsets.focus && + (liveDomOffsets.anchor !== authoritativeSelection.anchorOffset || + liveDomOffsets.focus !== authoritativeSelection.focusOffset) + ); + } + + private resolveEditContextSelectionRange(): EditContextRange { + if (!this.editContext) { + return { start: 0, end: 0 }; + } + + return { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + } + + private resolveTrustedKeyDownRange( + blockId: string, + event: KeyboardEvent, + editorSelectionRange: EditContextRange | null, + ): EditContextRange | null { + if (!isFieldEditorTextEditingKey(event)) { + return null; + } + + if (editorSelectionRange) { + return editorSelectionRange; + } + + const authoritativeSelection = + this.getAuthoritativeTextInputSelection(blockId); + if (authoritativeSelection) { + return selectionToRange(authoritativeSelection); + } + + const collapsedEditorSelection = + this.resolveCollapsedEditorSelectionRange(blockId); + if (collapsedEditorSelection) { + return collapsedEditorSelection; + } + + const projectedSelection = this.getProjectedTextSelection(blockId); + if (projectedSelection) { + return selectionToRange(projectedSelection); + } + + const synchronizedEditContextRange = + this.resolveSynchronizedEditContextRange(blockId); + if (synchronizedEditContextRange) { + return synchronizedEditContextRange; + } + + return null; + } + + private getProjectedTextSelection( + blockId: string, + ): EditContextSelection | null { + return this.editContextSelection?.blockId === blockId + ? this.editContextSelection + : null; + } + + private resolveCollapsedEditorSelectionRange( + blockId: string, + ): EditContextRange | null { + const selection = this.fieldEditor.selection; + if ( + selection?.type === "text" && + selection.isCollapsed && + selection.focus.blockId === blockId + ) { + return { + start: selection.focus.offset, + end: selection.focus.offset, + }; + } + + return null; + } + + private resolveSynchronizedEditContextRange( + blockId: string, + ): EditContextRange | null { + if (!this.editContext) { + return null; + } + + const editContextRange = { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + const editorRange = + this.resolveEditorSelectionRange(blockId) ?? + this.resolveCollapsedEditorSelectionRange(blockId); + + if (editorRange && rangesEqual(editContextRange, editorRange)) { + return editContextRange; + } + + return null; + } + private handleCopyEvent = (event: ClipboardEvent): void => { event.preventDefault(); handleCopy(this.editor, event); @@ -1254,6 +1400,40 @@ function collapsedSelectionOffset( return selection.focusOffset; } +function selectionToRange(selection: EditContextSelection): EditContextRange { + return { + start: Math.min(selection.anchorOffset, selection.focusOffset), + end: Math.max(selection.anchorOffset, selection.focusOffset), + }; +} + +function directionalSelectionToRange( + selection: DirectionalSelectionOffsets, +): EditContextRange { + return { + start: selection.start, + end: selection.end, + }; +} + +function rangeToSelection( + blockId: string, + range: EditContextRange, +): EditContextSelection { + return { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + }; +} + +function rangesEqual( + left: EditContextRange, + right: EditContextRange, +): boolean { + return left.start === right.start && left.end === right.end; +} + function isNavigationSelectionKey(event: KeyboardEvent): boolean { return ( event.key === "ArrowLeft" || diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index 6d84a59..c444e2f 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -176,6 +176,14 @@ export class FieldEditorImpl implements FieldEditorSession { this.resetSelectAllCycle(); } + setInputBackend(inputBackend: "contenteditable" | "edit-context"): void { + if (this._inputBackend === inputBackend) { + return; + } + this._inputBackend = inputBackend; + this._syncBackendForSurfaceMode(); + } + // ── Lifecycle ───────────────────────────────────────────── activate(blockId: string): void { @@ -420,17 +428,27 @@ export class FieldEditorImpl implements FieldEditorSession { if (!inlineEl) return; + const selection = this._editor.selection; inlineEl.focus({ preventScroll: false }); - const selection = root.ownerDocument?.getSelection(); - if (!selection) return; + if ( + selection?.type === "text" && + selection.anchor.blockId === this._focusBlockId && + selection.focus.blockId === this._focusBlockId + ) { + this._backend?.updateSelection(null); + return; + } + + const nativeSelection = root.ownerDocument?.getSelection(); + if (!nativeSelection) return; const range = root.ownerDocument.createRange(); range.selectNodeContents(inlineEl); range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); + nativeSelection.removeAllRanges(); + nativeSelection.addRange(range); } blur(): void { diff --git a/packages/rendering/dom/src/index.ts b/packages/rendering/dom/src/index.ts index 7eb8bab..d3ea6dc 100644 --- a/packages/rendering/dom/src/index.ts +++ b/packages/rendering/dom/src/index.ts @@ -1,5 +1,17 @@ export { FieldEditorImpl } from "./field-editor/fieldEditorImpl"; export type { FieldEditorSession } from "./field-editor/controller"; +export { handleEditorDocumentKeyDown } from "./utils/documentShortcuts"; +export { handleEscapeSelectionTransition } from "./utils/escapeSelection"; +export { handleTableCellSelectionKeyDown } from "./utils/tableCellNavigation"; +export { + getClosestEditorRoot, + isActiveFieldEditorTextEntryTarget, + isFieldEditorTextEditingKey, + isFieldEditorTextEntryTarget, + isNativeTextEntryTarget, + isTextEntryTarget, + shouldHandleEditorKeyboardEvent, +} from "./utils/textEntryTarget"; export { DEFAULT_SELECT_ALL_BEHAVIOR, resolveSelectAllBehavior, diff --git a/packages/rendering/dom/src/utils/documentShortcuts.ts b/packages/rendering/dom/src/utils/documentShortcuts.ts new file mode 100644 index 0000000..a721424 --- /dev/null +++ b/packages/rendering/dom/src/utils/documentShortcuts.ts @@ -0,0 +1,282 @@ +import { + generateId, + type Editor, + type InteractionModel, + usesInlineTextSelection, +} from "@pen/types"; +import type { FieldEditorSession } from "../field-editor/controller"; +import { + handleHistoryShortcut, + handleSelectAllShortcut, +} from "../field-editor/keyHandling"; +import { DATA_ATTRS } from "./dataAttributes"; +import { handleEscapeSelectionTransition } from "./escapeSelection"; +import { getAdjacentVisibleBlockId } from "./parentIdTree"; +import { handleTableCellSelectionKeyDown } from "./tableCellNavigation"; + +const DATABASE_ROW_SELECTION_SLOT = "database:row-selection"; + +type DatabaseRowSelectionController = { + deleteSelectedRows: (blockId: string) => boolean; +}; + +export function handleEditorDocumentKeyDown(options: { + event: KeyboardEvent; + editor: Editor; + fieldEditor: FieldEditorSession; + interactionModel?: InteractionModel; + root: HTMLElement; +}): boolean { + const { event, editor, fieldEditor, interactionModel, root } = options; + + return ( + handleEscapeSelectionTransition({ event, editor, fieldEditor, root }) || + handleDeleteSelectionShortcut(event, editor, fieldEditor, root) || + handleTableCellSelectionKeyDown({ event, editor, fieldEditor, root }) || + handleSelectAllShortcut(editor, event, fieldEditor, { + rootElement: root, + }) || + handleBlockSelectionEnter(event, editor, fieldEditor, interactionModel) || + handleBlockSelectionArrow(event, editor, fieldEditor) || + handleHistoryShortcut(editor, event) + ); +} + +function handleBlockSelectionArrow( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorSession, +): boolean { + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing + ) { + return false; + } + + const isUp = event.key === "ArrowUp" || event.key === "ArrowLeft"; + const isDown = event.key === "ArrowDown" || event.key === "ArrowRight"; + if (!isUp && !isDown) return false; + + const selection = editor.selection; + if (selection?.type !== "block" || selection.blockIds.length === 0) { + return false; + } + + const blockId = isUp + ? selection.blockIds[0]! + : selection.blockIds[selection.blockIds.length - 1]!; + const direction = isUp ? "previous" : "next"; + + const adjacentId = getAdjacentVisibleBlockId(editor, blockId, direction); + if (!adjacentId) return false; + + const adjacentBlock = editor.getBlock(adjacentId); + if (!adjacentBlock) return false; + + const schema = editor.schema.resolve(adjacentBlock.type); + if (usesInlineTextSelection(schema)) { + const offset = isUp ? adjacentBlock.length() : 0; + fieldEditor.activateTextSelection(adjacentId, offset, offset); + return true; + } + + editor.selectBlock(adjacentId); + return true; +} + +function handleBlockSelectionEnter( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorSession, + interactionModel: InteractionModel = "content-first", +): boolean { + if ( + event.key !== "Enter" || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing + ) { + return false; + } + + const selection = editor.selection; + if (selection?.type !== "block" || selection.blockIds.length === 0) { + return false; + } + + const anchorBlockId = selection.blockIds[selection.blockIds.length - 1]!; + const anchorBlock = editor.getBlock(anchorBlockId); + if (!anchorBlock) { + return false; + } + const anchorSchema = editor.schema.resolve(anchorBlock.type); + + if ( + interactionModel === "block-first" && + selection.blockIds.length === 1 && + usesInlineTextSelection(anchorSchema) + ) { + const offset = anchorBlock.length(); + fieldEditor.activateTextSelection(anchorBlockId, offset, offset); + return true; + } + + const newBlockId = generateId(); + + editor.apply( + [ + { + type: "insert-block", + blockId: newBlockId, + blockType: "paragraph", + props: {}, + position: { after: anchorBlockId }, + }, + ], + { origin: "user" }, + ); + + fieldEditor.activateTextSelection(newBlockId, 0, 0); + return true; +} + +function handleDeleteSelectionShortcut( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorSession, + root: HTMLElement, +): boolean { + if ( + (event.key !== "Backspace" && event.key !== "Delete") || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing || + fieldEditor.isComposing + ) { + return false; + } + + const selection = editor.selection; + if (tryDeleteSelectedDatabaseRows(root, editor)) { + fieldEditor.deactivate(); + return true; + } + if (!selection) { + return false; + } + + if (selection.type === "text" && !selection.isCollapsed) { + if ( + !selection.isMultiBlock && + !shouldUseDocumentTextDeletionFallback(root, fieldEditor) + ) { + return false; + } + if (selection.isMultiBlock) { + fieldEditor.deactivate(); + } + editor.deleteSelection({ origin: "user" }); + const nextSelection = editor.selection; + if (nextSelection?.type === "text") { + fieldEditor.activateTextSelection( + nextSelection.focus.blockId, + nextSelection.focus.offset, + nextSelection.focus.offset, + ); + } else { + fieldEditor.deactivate(); + } + return true; + } + + if (selection.type === "block" && selection.blockIds.length > 0) { + editor.deleteSelection({ origin: "user" }); + fieldEditor.deactivate(); + const firstBlock = editor.firstBlock(); + if (firstBlock) { + const schema = editor.schema.resolve(firstBlock.type); + if (usesInlineTextSelection(schema)) { + fieldEditor.activateTextSelection(firstBlock.id, 0, 0); + } + } + return true; + } + + if (selection.type === "cell") { + editor.deleteSelection({ origin: "user" }); + return true; + } + + return false; +} + +function tryDeleteSelectedDatabaseRows( + root: HTMLElement, + editor: Editor, +): boolean { + const controller = editor.internals.getSlot(DATABASE_ROW_SELECTION_SLOT) as + | DatabaseRowSelectionController + | undefined; + if (!controller) { + return false; + } + + const activeElement = root.ownerDocument?.activeElement; + if ( + !(activeElement instanceof HTMLElement) || + !root.contains(activeElement) + ) { + return false; + } + + const blockElement = activeElement.closest("[data-block-id]"); + const blockId = blockElement?.getAttribute("data-block-id"); + if (!blockId) { + return false; + } + + const block = editor.getBlock(blockId); + if (!block || block.type !== "database") { + return false; + } + + return controller.deleteSelectedRows(blockId); +} + +function shouldUseDocumentTextDeletionFallback( + root: HTMLElement, + fieldEditor: FieldEditorSession, +): boolean { + if (!fieldEditor.isEditing) { + return true; + } + + const activeElement = root.ownerDocument?.activeElement; + if ( + !(activeElement instanceof HTMLElement) || + !root.contains(activeElement) + ) { + return true; + } + + if (activeElement === root) { + return true; + } + + const activeInlineSurface = activeElement.closest( + `[${DATA_ATTRS.inlineContent}]`, + ); + if (activeInlineSurface === null) { + return true; + } + + return false; +} diff --git a/packages/rendering/react/src/utils/escapeSelection.ts b/packages/rendering/dom/src/utils/escapeSelection.ts similarity index 100% rename from packages/rendering/react/src/utils/escapeSelection.ts rename to packages/rendering/dom/src/utils/escapeSelection.ts diff --git a/packages/rendering/react/src/utils/tableCellNavigation.ts b/packages/rendering/dom/src/utils/tableCellNavigation.ts similarity index 97% rename from packages/rendering/react/src/utils/tableCellNavigation.ts rename to packages/rendering/dom/src/utils/tableCellNavigation.ts index 3fd7c65..dde0440 100644 --- a/packages/rendering/react/src/utils/tableCellNavigation.ts +++ b/packages/rendering/dom/src/utils/tableCellNavigation.ts @@ -41,7 +41,10 @@ export function handleTableCellSelectionKeyDown(options: { } const cellKeyDownSlot = editor.internals.getSlot("database:cell-keydown") as - | ((event: KeyboardEvent, context: { blockId: string; row: number; col: number; root: HTMLElement }) => boolean) + | (( + event: KeyboardEvent, + context: { blockId: string; row: number; col: number; root: HTMLElement }, + ) => boolean) | undefined; const slotCoord = resolveCellSelectionCoord(block, selection, { row: head.row, @@ -113,7 +116,7 @@ export function handleTableCellSelectionKeyDown(options: { if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) { event.preventDefault(); - editor.deleteSelection(); + editor.deleteSelection({ origin: "user" }); return true; } @@ -151,7 +154,7 @@ export function handleTableCellSelectionKeyDown(options: { const cellCoord = head; clearCellContent(editor, selection, blockId, cellCoord.row, cellCoord.col); activateCellEditing(editor, fieldEditor, blockId, selection, cellCoord.row, cellCoord.col, root); - insertCharInActiveCell(fieldEditor, editor, selection, blockId, cellCoord.row, cellCoord.col, event.key); + insertCharInActiveCell(editor, selection, blockId, cellCoord.row, cellCoord.col, event.key); return true; } @@ -327,7 +330,6 @@ function clearCellContent( } function insertCharInActiveCell( - _fieldEditor: FieldEditorTableNavigationController, editor: Editor, selection: CellSelection, blockId: string, @@ -529,7 +531,9 @@ function pasteCellSelection(editor: Editor, selection: CellSelection): void { const parsed = parseEncodedCellPayload(penCellsMatch[2]); applyPastedCells(editor, selection, parsed.cells); return; - } catch { /* fall through to plain text */ } + } catch { + // Fall through to plain-text clipboard reads below. + } } pasteFromPlainText(editor, selection); }); diff --git a/packages/rendering/dom/src/utils/textEntryTarget.ts b/packages/rendering/dom/src/utils/textEntryTarget.ts new file mode 100644 index 0000000..09f5b98 --- /dev/null +++ b/packages/rendering/dom/src/utils/textEntryTarget.ts @@ -0,0 +1,233 @@ +import { DATA_ATTRS } from "./dataAttributes"; + +const TEXTBOX_ROLE_SELECTOR = '[role~="textbox"]'; +const FIELD_EDITOR_SURFACE_SELECTOR = `[${DATA_ATTRS.fieldEditorSurface}]`; +const ACTIVE_FIELD_EDITOR_SURFACE_SELECTOR = `[${DATA_ATTRS.fieldEditorActiveSurface}]`; + +type KeyboardRoutingSelection = + | null + | undefined + | { + type: string; + isCollapsed?: boolean; + isMultiBlock?: boolean; + blockIds?: readonly string[]; + }; + +type EditorKeyboardRoutingOptions = { + root: HTMLElement; + event: KeyboardEvent; + selection: KeyboardRoutingSelection; + hasMappedDomSelection?: () => boolean; + handleCollapsedTextSelection?: boolean; +}; + +export function isNativeTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target instanceof HTMLInputElement) { + return isTextEntryInput(target); + } + + return ( + target.isContentEditable || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.closest(TEXTBOX_ROLE_SELECTOR) !== null + ); +} + +export function isFieldEditorTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + const element = getClosestElement(target); + return element + ? element.closest(FIELD_EDITOR_SURFACE_SELECTOR) !== null + : false; +} + +export function isActiveFieldEditorTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + const element = getClosestElement(target); + return element + ? element.closest(ACTIVE_FIELD_EDITOR_SURFACE_SELECTOR) !== null + : false; +} + +export function isTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + return ( + isNativeTextEntryTarget(target) || isFieldEditorTextEntryTarget(target) + ); +} + +export function isFieldEditorTextEditingKey(event: KeyboardEvent): boolean { + if (event.metaKey || event.ctrlKey || event.altKey || event.isComposing) { + return false; + } + + return ( + event.key.length === 1 || + event.key === "Enter" || + event.key === "Backspace" || + event.key === "Delete" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "Home" || + event.key === "End" + ); +} + +export function shouldHandleEditorKeyboardEvent({ + root, + event, + selection, + hasMappedDomSelection, + handleCollapsedTextSelection = false, +}: EditorKeyboardRoutingOptions): boolean { + const targetRoot = getClosestEditorRoot(event.target); + if (targetRoot && targetRoot !== root) { + return false; + } + + if ( + isFieldEditorTextEditingKey(event) && + isActiveFieldEditorTextEntryTarget(event.target) + ) { + return isSelectionThatOverridesActiveTextEditingKey(selection); + } + + if ( + isNativeTextEntryTarget(event.target) && + !isFieldEditorTextEntryTarget(event.target) && + !root.contains(event.target) + ) { + return false; + } + + const activeElement = root.ownerDocument?.activeElement; + const activeRoot = getClosestEditorRoot(activeElement); + if (activeRoot && activeRoot !== root) { + return false; + } + + if (activeElement instanceof Node && root.contains(activeElement)) { + if (isFieldEditorTextEntryTarget(activeElement)) { + if (event.key === "Escape" || isCollapsedSelectAll(event)) { + return true; + } + + return isDocumentSelection(selection, handleCollapsedTextSelection); + } + + if (isNativeTextEntryTarget(activeElement)) { + return false; + } + + return true; + } + + if ( + activeElement instanceof Node && + !root.contains(activeElement) && + isNativeTextEntryTarget(activeElement) + ) { + return false; + } + + if (hasMappedDomSelection?.()) { + return true; + } + + if (isDocumentShortcut(event)) { + return isDocumentSelection(selection, true); + } + + return isDocumentSelection(selection, handleCollapsedTextSelection); +} + +export function getClosestEditorRoot( + target: EventTarget | null, +): HTMLElement | null { + const element = getClosestElement(target); + return element?.closest(`[${DATA_ATTRS.editorRoot}]`) as HTMLElement | null; +} + +function getClosestElement(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Node)) { + return null; + } + + return target instanceof HTMLElement ? target : target.parentElement; +} + +function isDocumentSelection( + selection: KeyboardRoutingSelection, + handleCollapsedTextSelection: boolean, +): boolean { + if (selection?.type === "cell") { + return true; + } + + if (selection?.type === "block") { + return (selection.blockIds?.length ?? 0) > 0; + } + + if (selection?.type === "text") { + return ( + handleCollapsedTextSelection || + selection.isMultiBlock === true || + selection.isCollapsed !== true + ); + } + + return false; +} + +function isSelectionThatOverridesActiveTextEditingKey( + selection: KeyboardRoutingSelection, +): boolean { + return ( + selection?.type === "cell" || + (selection?.type === "text" && selection.isMultiBlock === true) + ); +} + +function isCollapsedSelectAll(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "a" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +function isDocumentShortcut(event: KeyboardEvent): boolean { + const key = event.key.toLowerCase(); + return ( + !event.altKey && + (event.metaKey || event.ctrlKey) && + (key === "a" || key === "z") + ); +} + +function isTextEntryInput(input: HTMLInputElement): boolean { + return !( + input.type === "checkbox" || + input.type === "radio" || + input.type === "button" || + input.type === "submit" || + input.type === "reset" || + input.type === "range" || + input.type === "color" || + input.type === "file" + ); +} diff --git a/packages/rendering/react/src/__tests__/blockSelection.test.tsx b/packages/rendering/react/src/__tests__/blockSelection.test.tsx new file mode 100644 index 0000000..9ac352f --- /dev/null +++ b/packages/rendering/react/src/__tests__/blockSelection.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import { Pen } from "../primitives/index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function cleanupEditor( + editor: ReturnType, + root: Root, + container: HTMLElement, +): Promise { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function setBlockRect(blockElement: Element | null, rect: DOMRect): void { + if (!(blockElement instanceof HTMLElement)) { + throw new Error("Missing rendered block element"); + } + + blockElement.getBoundingClientRect = () => rect; +} + +function createCaretRangeResolver( + points: Record, +): (x: number, y: number) => Range | null { + return (x: number) => { + const point = x < 50 ? points.start : points.end; + const range = document.createRange(); + range.setStart(point.element.firstChild ?? point.element, point.offset); + range.setEnd(point.element.firstChild ?? point.element, point.offset); + return range; + }; +} + +function getInlineSurface(container: HTMLElement, blockId: string): HTMLElement { + const inlineSurface = container.querySelector( + `[data-block-id="${blockId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + if (!inlineSurface) { + throw new Error(`Missing inline surface for block ${blockId}`); + } + return inlineSurface; +} + +describe("@pen/react block selection", () => { + it("prevents region selector block selection when root block selection is disabled", async () => { + const editor = createEditor({ documentProfile: "flow" }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + + editor.apply([ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { type: "insert-text", blockId: secondBlockId, offset: 0, text: "Beta" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + , + ); + }); + + const content = container.querySelector( + "[data-pen-editor-content]", + ) as HTMLElement | null; + expect(content).not.toBeNull(); + + setBlockRect( + container.querySelector(`[data-block-id="${firstBlockId}"]`), + new DOMRect(10, 10, 80, 20), + ); + setBlockRect( + container.querySelector(`[data-block-id="${secondBlockId}"]`), + new DOMRect(10, 40, 80, 20), + ); + + await act(async () => { + content!.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: 0, + clientY: 0, + }), + ); + document.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + button: 0, + clientX: 120, + clientY: 120, + }), + ); + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 120, + clientY: 120, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toBeNull(); + + await cleanupEditor(editor, root, container); + }); + + it("prevents pointer drag from promoting an existing block selection to a block range", async () => { + const editor = createEditor({ documentProfile: "flow" }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + + editor.apply([ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { type: "insert-text", blockId: secondBlockId, offset: 0, text: "Beta" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const originalCaretRangeFromPoint = ( + document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + } + ).caretRangeFromPoint; + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const firstSurface = getInlineSurface(container, firstBlockId); + const secondSurface = getInlineSurface(container, secondBlockId); + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = createCaretRangeResolver({ + start: { element: firstSurface, offset: 0 }, + end: { element: secondSurface, offset: 1 }, + }); + + await act(async () => { + editor.selectBlock(firstBlockId); + firstSurface.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: 10, + clientY: 8, + }), + ); + document.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + button: 0, + clientX: 100, + clientY: 48, + }), + ); + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 100, + clientY: 48, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "block", + blockIds: [firstBlockId], + }); + } finally { + ( + document as Document & { + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; + } + ).caretRangeFromPoint = originalCaretRangeFromPoint; + await cleanupEditor(editor, root, container); + } + }); + + it("hides the selection rectangle when root block selection is disabled", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Alpha" }]); + editor.selectBlock(blockId); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + , + ); + await flushAnimationFrames(2); + }); + + expect(container.querySelector("[data-pen-selection-rect]")).toBeNull(); + expect(container.textContent).not.toContain("1 block selected"); + + await cleanupEditor(editor, root, container); + }); +}); diff --git a/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx b/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx index 39754fb..5e0a68d 100644 --- a/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx +++ b/packages/rendering/react/src/__tests__/editorCaretOverlay.test.tsx @@ -15,11 +15,6 @@ import { FIELD_EDITOR_SLOT_KEY } from "../constants/fieldEditor"; describe("@pen/react editor caret overlay", () => { it("renders a custom local caret for collapsed selections only", async () => { - const originalUserAgent = navigator.userAgent; - Object.defineProperty(navigator, "userAgent", { - value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", - configurable: true, - }); const editor = createEditor({ preset: defaultPreset({ documentOps: false, @@ -144,21 +139,12 @@ describe("@pen/react editor caret overlay", () => { await act(async () => { root.unmount(); }); - Object.defineProperty(navigator, "userAgent", { - value: originalUserAgent, - configurable: true, - }); container.remove(); editor.destroy(); } }); - it("uses macOS caret defaults", async () => { - const originalUserAgent = navigator.userAgent; - Object.defineProperty(navigator, "userAgent", { - value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", - configurable: true, - }); + it("uses macOS caret defaults when requested", async () => { const editor = createEditor({ preset: defaultPreset({ documentOps: false, @@ -187,6 +173,7 @@ describe("@pen/react editor caret overlay", () => { { caretStyle = props.caretStyle; return ( @@ -236,10 +223,6 @@ describe("@pen/react editor caret overlay", () => { await act(async () => { root.unmount(); }); - Object.defineProperty(navigator, "userAgent", { - value: originalUserAgent, - configurable: true, - }); container.remove(); editor.destroy(); } diff --git a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx index d21b6b3..627fbcc 100644 --- a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx +++ b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx @@ -347,7 +347,7 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); - it("can opt into contenteditable even when EditContext is available", async () => { + it("falls back to contenteditable when EditContext is unavailable", async () => { const originalEditContext = ( globalThis as typeof globalThis & { EditContext?: typeof FakeEditContext; @@ -357,7 +357,7 @@ describe("@pen/react selected text deletion", () => { globalThis as typeof globalThis & { EditContext?: typeof FakeEditContext; } - ).EditContext = FakeEditContext; + ).EditContext = undefined; const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -368,10 +368,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -465,7 +462,7 @@ describe("@pen/react selected text deletion", () => { ); return ( - + ); @@ -2488,6 +2485,69 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); + it("preserves the active text selection when refocusing from editor chrome", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const toolbarButton = container.querySelector( + "button", + ) as HTMLButtonElement | null; + + expect(rootElement).not.toBeNull(); + expect(toolbarButton).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 1, 4); + await flushAnimationFrames(3); + }); + + await act(async () => { + toolbarButton!.focus(); + fieldEditor.activateTextSelection(blockId, 1, 4); + fieldEditor.focus(); + await flushAnimationFrames(3); + }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 4 }, + isCollapsed: false, + isMultiBlock: false, + }); + expect(domSelectionToEditor(rootElement!)).toMatchObject({ + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 4 }, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + it("resets an empty heading to paragraph on backspace keydown", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -2878,6 +2938,318 @@ describe("@pen/react selected text deletion", () => { editor.destroy(); }); + it("moves the caret into the inserted block after Enter at block end in flow EditContext documents", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor({ documentProfile: "flow" }); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 5, 5); + await flushAnimationFrames(4); + }); + + expect(inlineElement!.getAttribute("contenteditable")).toBeNull(); + + await act(async () => { + inlineElement!.dispatchEvent(createKeyEvent("Enter")); + await flushAnimationFrames(4); + }); + + const blockIds = editor.documentState.blockOrder; + const newBlockId = blockIds[1]; + + expect(newBlockId).toBeTruthy(); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId: newBlockId, offset: 0 }, + focus: { blockId: newBlockId, offset: 0 }, + isCollapsed: true, + isMultiBlock: false, + }); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("uses the EditContext caret for Enter when native DOM selection is stale at block start", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor({ documentProfile: "flow" }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as + | (HTMLElement & { editContext?: FakeEditContext | null }) + | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 5, 5); + await flushAnimationFrames(4); + }); + + const editContext = inlineElement?.editContext; + expect(editContext).toBeTruthy(); + expect(editContext?.selectionStart).toBe(5); + expect(editContext?.selectionEnd).toBe(5); + + setNativeSelectionRange(inlineElement!, 0, inlineElement!, 0); + editContext?.updateSelection(0, 0); + + await act(async () => { + inlineElement!.dispatchEvent( + createKeyEvent("Enter", { cancelable: true }), + ); + await flushAnimationFrames(4); + }); + + const blockIds = editor.documentState.blockOrder; + const newBlockId = blockIds[1]; + expect(blockIds).toHaveLength(2); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello"); + expect(editor.getBlock(newBlockId!)?.textContent()).toBe(""); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId: newBlockId, offset: 0 }, + focus: { blockId: newBlockId, offset: 0 }, + isCollapsed: true, + isMultiBlock: false, + }); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("inserts a paragraph on Enter from a selected content-first flow paragraph", async () => { + const editor = createEditor({ documentProfile: "flow" }); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + expect(rootElement).not.toBeNull(); + + await act(async () => { + editor.selectBlock(blockId); + rootElement!.focus(); + rootElement!.dispatchEvent( + createKeyEvent("Enter", { cancelable: true }), + ); + await flushAnimationFrames(4); + }); + + const blockIds = editor.documentState.blockOrder; + const newBlockId = blockIds[1]; + + expect(blockIds).toHaveLength(2); + expect(blockIds[0]).toBe(blockId); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello"); + expect(editor.getBlock(newBlockId!)?.textContent()).toBe(""); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId: newBlockId, offset: 0 }, + focus: { blockId: newBlockId, offset: 0 }, + isCollapsed: true, + isMultiBlock: false, + }); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("re-enters text editing on Enter from a single selected block-first flow paragraph", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + const editor = createEditor({ documentProfile: "flow" }); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + }); + + const rootElement = container.querySelector( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + + await act(async () => { + editor.selectBlock(blockId); + rootElement!.focus(); + rootElement!.dispatchEvent( + createKeyEvent("Enter", { cancelable: true }), + ); + await flushAnimationFrames(4); + }); + + expect(editor.documentState.blockOrder).toEqual([blockId]); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 5 }, + focus: { blockId, offset: 5 }, + isCollapsed: true, + isMultiBlock: false, + }); + + const activeInlineElement = container.querySelector( + "[data-pen-inline-content][data-pen-field-editor-active-surface]", + ) as HTMLElement | null; + expect(activeInlineElement).not.toBeNull(); + + await act(async () => { + document.dispatchEvent( + createKeyEvent("Backspace", { cancelable: true }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.documentState.blockOrder).toEqual([blockId]); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello"); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + it("shows the next ordered-list marker after Enter continues a numbered list", async () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; @@ -3179,10 +3551,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3297,10 +3666,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3435,10 +3801,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3567,10 +3930,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3669,10 +4029,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3753,10 +4110,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3839,10 +4193,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -3957,10 +4308,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -4043,10 +4391,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -4134,10 +4479,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -4230,10 +4572,7 @@ describe("@pen/react selected text deletion", () => { try { await act(async () => { root.render( - + , ); @@ -4709,10 +5048,7 @@ describe("@pen/react selected text deletion", () => { await act(async () => { root.render( - + , @@ -4828,10 +5164,7 @@ describe("@pen/react selected text deletion", () => { await act(async () => { root.render( - + , ); diff --git a/packages/rendering/react/src/__tests__/tableCellNavigation.test.ts b/packages/rendering/react/src/__tests__/tableCellNavigation.test.ts index 9c0fb00..ff8b80e 100644 --- a/packages/rendering/react/src/__tests__/tableCellNavigation.test.ts +++ b/packages/rendering/react/src/__tests__/tableCellNavigation.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEditor } from "@pen/core"; +import { handleTableCellSelectionKeyDown } from "@pen/dom"; import { defaultPreset } from "@pen/preset-default"; import type { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; -import { handleTableCellSelectionKeyDown } from "../utils/tableCellNavigation"; class MockClipboardItem { readonly types: string[]; diff --git a/packages/rendering/react/src/context/editorContext.ts b/packages/rendering/react/src/context/editorContext.ts index b9f121b..3415e80 100644 --- a/packages/rendering/react/src/context/editorContext.ts +++ b/packages/rendering/react/src/context/editorContext.ts @@ -30,6 +30,16 @@ export interface ResolvedBlockDragAndDropOptions { enabled: boolean; } +export type BlockSelectionOptions = + | boolean + | { + enabled?: boolean; + }; + +export interface ResolvedBlockSelectionOptions { + enabled: boolean; +} + export interface ResolvedInteractionModel { model: InteractionModel; selectAllBehavior: EditorSelectAllBehavior; @@ -61,6 +71,18 @@ export function resolveInteractionModel( }; } +export function resolveBlockSelection( + blockSelection?: BlockSelectionOptions, +): ResolvedBlockSelectionOptions { + if (typeof blockSelection === "boolean") { + return { enabled: blockSelection }; + } + + return { + enabled: blockSelection?.enabled ?? true, + }; +} + export interface BlockControlsProps { blockId: string; blockType: string; @@ -78,6 +100,7 @@ export interface EditorContextValue { editorViewMode: EditorViewMode; interactionModel: ResolvedInteractionModel; blockDragAndDrop: ResolvedBlockDragAndDropOptions; + blockSelection: ResolvedBlockSelectionOptions; blockControls?: BlockControlsRenderer; importers?: PasteImporters; assets?: AssetProvider; diff --git a/packages/rendering/react/src/context/index.ts b/packages/rendering/react/src/context/index.ts index 43f1ae9..8794bd2 100644 --- a/packages/rendering/react/src/context/index.ts +++ b/packages/rendering/react/src/context/index.ts @@ -2,11 +2,14 @@ export { EditorContext, useEditorContext, resolveInteractionModel, + resolveBlockSelection, type EditorContextValue, type BlockControlsProps, type BlockControlsRenderer, type BlockDragAndDropOptions, + type BlockSelectionOptions, type ResolvedBlockDragAndDropOptions, + type ResolvedBlockSelectionOptions, type ResolvedInteractionModel, type PasteImporters, type RendererOverrides, diff --git a/packages/rendering/react/src/index.ts b/packages/rendering/react/src/index.ts index 78fc304..e96ceba 100644 --- a/packages/rendering/react/src/index.ts +++ b/packages/rendering/react/src/index.ts @@ -17,6 +17,7 @@ export { Pen } from "./primitives/index"; // ── Editor primitives ─────────────────────────────────────── export { + CARET, EditorRoot, EditorContent, EditorBlock, @@ -27,6 +28,7 @@ export { EditorRegionSelector, EditorSelectionRect, EditorFieldEditor, + type EditorCaretVariant, type EditorRootProps, type EditorContentProps, type EditorBlockProps, @@ -265,7 +267,9 @@ export { type BlockControlsProps, type BlockControlsRenderer, type BlockDragAndDropOptions, + type BlockSelectionOptions, type ResolvedBlockDragAndDropOptions, + type ResolvedBlockSelectionOptions, type ResolvedInteractionModel, type PasteImporters, type RendererOverrides, diff --git a/packages/rendering/react/src/primitives/editor/caretOverlay.tsx b/packages/rendering/react/src/primitives/editor/caretOverlay.tsx index 6e6528a..4cfde72 100644 --- a/packages/rendering/react/src/primitives/editor/caretOverlay.tsx +++ b/packages/rendering/react/src/primitives/editor/caretOverlay.tsx @@ -13,6 +13,13 @@ import { isDevelopmentEnvironment } from "../../utils/environment"; type CaretStyle = React.CSSProperties & Record; const CARET_BLINK_RESUME_DELAY_MS = 500; +export const CARET = { + DEFAULT: "default", + MACOS: "macos", +} as const; + +export type EditorCaretVariant = (typeof CARET)[keyof typeof CARET]; + export interface EditorCaretRenderProps { selection: TextSelection; point: { @@ -25,12 +32,18 @@ export interface EditorCaretRenderProps { export interface EditorCaretOverlayProps extends AsChildProps { editor?: Editor; + variant?: EditorCaretVariant; renderCaret?: (props: EditorCaretRenderProps) => React.ReactNode; ref?: React.Ref; } export function EditorCaretOverlay(props: EditorCaretOverlayProps) { - const { editor: editorProp, renderCaret, ...rest } = props; + const { + editor: editorProp, + variant = CARET.DEFAULT, + renderCaret, + ...rest + } = props; const editorContext = useContext(EditorContext); const editor = editorProp ?? editorContext?.editor; const fieldEditor = useFieldEditorContext(); @@ -110,6 +123,7 @@ export function EditorCaretOverlay(props: EditorCaretOverlayProps) { caretSelection, rect, blinkPaused, + variant, ); caretNode = renderCaret ? ( renderCaret(renderProps) @@ -160,10 +174,11 @@ function createCaretRenderProps( selection: TextSelection, rect: DOMRect, blinkPaused: boolean, + variant: EditorCaretVariant, ): EditorCaretRenderProps { const height = Math.max(rect.height, 16); const point = selection.focus; - const isMacOS = isMacOSPlatform(); + const isMacOS = variant === CARET.MACOS; const defaultCaretColor = isMacOS ? "var(--palette-blue, #0a84ff)" : "var(--palette-b100, currentColor)"; @@ -200,13 +215,6 @@ function createCaretRenderProps( }; } -function isMacOSPlatform(): boolean { - return ( - typeof navigator !== "undefined" && - /\bMacintosh\b|\bMac OS X\b/.test(navigator.userAgent) - ); -} - function useCaretBlinkPauseState(options: { rootElement: HTMLElement | null; layoutVersion: number; diff --git a/packages/rendering/react/src/primitives/editor/content.tsx b/packages/rendering/react/src/primitives/editor/content.tsx index b7b54da..07c8710 100644 --- a/packages/rendering/react/src/primitives/editor/content.tsx +++ b/packages/rendering/react/src/primitives/editor/content.tsx @@ -79,7 +79,7 @@ export interface EditorContentProps extends AsChildProps { export function EditorContent(props: EditorContentProps) { const { virtualize: _virtualize, emptyPlaceholder, ...rest } = props; - const { editor, readonly, blockDragAndDrop, interactionModel } = useEditorContext(); + const { editor, readonly, blockDragAndDrop, blockSelection, interactionModel } = useEditorContext(); const fieldEditor = useFieldEditorContext(); const { store: regionSelectionStore } = useEditorRegionSelectionContext(); const fieldEditorState = useFieldEditorState(fieldEditor); @@ -308,6 +308,7 @@ export function EditorContent(props: EditorContentProps) { const getRegionSelectorConfig = ( event: MouseEvent, ): RegionSelectorConfig | null => { + if (!blockSelection.enabled) return null; const config = regionSelectionStore.getSnapshot().config; if (!config?.enabled) return null; if (config.selectionMode !== "block") return null; @@ -394,6 +395,7 @@ export function EditorContent(props: EditorContentProps) { focus: focusPoint, }); if (normalizedSelection.type === "block") { + if (!blockSelection.enabled) return; gestureEl.ownerDocument?.getSelection()?.removeAllRanges(); editor.selectBlocks(normalizedSelection.blockIds); fieldEditor.deactivate(); @@ -407,9 +409,11 @@ export function EditorContent(props: EditorContentProps) { if (!selectedIds) return; if (shouldUseBlockSelection(editor, selectedIds.length)) { - editor.selectBlocks(selectedIds); - fieldEditor.deactivate(); - return; + if (blockSelection.enabled) { + editor.selectBlocks(selectedIds); + fieldEditor.deactivate(); + return; + } } fieldEditor.applyDocumentTextSelection( @@ -471,7 +475,10 @@ export function EditorContent(props: EditorContentProps) { selectingForward ? "end" : "start", ); - if (shouldUseBlockSelection(editor, selectedIds.length)) { + if ( + blockSelection.enabled && + shouldUseBlockSelection(editor, selectedIds.length) + ) { editor.selectBlocks(selectedIds); fieldEditor.deactivate(); event.preventDefault(); @@ -600,7 +607,7 @@ export function EditorContent(props: EditorContentProps) { const gesture = regionGestureRef.current; if (gesture) { const config = regionSelectionStore.getSnapshot().config; - if (!config?.enabled) { + if (!blockSelection.enabled || !config?.enabled) { clearRegionSelectionState(); return; } @@ -682,6 +689,7 @@ export function EditorContent(props: EditorContentProps) { pointerGesture.promotedDuringDrag = true; skipNextClickRef.current = true; if (resolvedSelection.mode === "block") { + if (!blockSelection.enabled) return; editor.selectBlocks(resolvedSelection.blockIds); fieldEditor.deactivate(); return; @@ -709,6 +717,11 @@ export function EditorContent(props: EditorContentProps) { "[data-pen-editor-root]", ) as HTMLElement | null; if (wasSelecting) { + if (!blockSelection.enabled) { + skipNextClickRef.current = true; + clearRegionSelectionState(); + return; + } const liveRect = createClientRect( regionGesture.clientX, regionGesture.clientY, @@ -962,6 +975,7 @@ export function EditorContent(props: EditorContentProps) { } if (resolvedSelection.mode === "block") { + if (!blockSelection.enabled) return false; editor.selectBlocks(resolvedSelection.blockIds); fieldEditor.deactivate(); if (root) { @@ -998,6 +1012,9 @@ export function EditorContent(props: EditorContentProps) { if (resolvedSelection?.mode !== "block") { return false; } + if (!blockSelection.enabled) { + return false; + } editor.selectBlocks(resolvedSelection.blockIds); fieldEditor.deactivate(); @@ -1026,6 +1043,11 @@ export function EditorContent(props: EditorContentProps) { cellCoord, }) ) { + if (!blockSelection.enabled) { + editor.selectCell(blockId, cellCoord.row, cellCoord.col); + skipNextClickRef.current = true; + return true; + } editor.selectBlock(blockId); if (root) { ensureEditorFocus(root); @@ -1075,12 +1097,18 @@ export function EditorContent(props: EditorContentProps) { return true; } + if (!blockSelection.enabled) { + return false; + } editor.selectBlock(blockId); skipNextClickRef.current = true; return true; } if (blockPointerIntent === "select-block") { + if (!blockSelection.enabled) { + return false; + } editor.selectBlock(blockId); fieldEditor.deactivate(); if (root) { @@ -1142,6 +1170,7 @@ export function EditorContent(props: EditorContentProps) { } if ( + blockSelection.enabled && moved && gesture.blockId !== blockId && getEditorBlockSelectionRole(editor, gesture.blockId) !== @@ -1212,7 +1241,7 @@ export function EditorContent(props: EditorContentProps) { } clearRegionSelectionState(); }; - }, [editor, fieldEditor, readonly, regionSelectionStore]); + }, [blockSelection.enabled, editor, fieldEditor, readonly, regionSelectionStore]); const blockElements: React.ReactElement[] = []; const previewBlocks = visibleSuggestion?.previewBlocks ?? []; diff --git a/packages/rendering/react/src/primitives/editor/index.ts b/packages/rendering/react/src/primitives/editor/index.ts index a5eec31..4b97466 100644 --- a/packages/rendering/react/src/primitives/editor/index.ts +++ b/packages/rendering/react/src/primitives/editor/index.ts @@ -6,7 +6,9 @@ export { EditorContent, type EditorContentProps } from "./content"; export { EditorBlock, type EditorBlockProps } from "./block"; export { InlineContent, type InlineContentProps } from "./inlineContent"; export { + CARET, EditorCaretOverlay, + type EditorCaretVariant, type EditorCaretOverlayProps, type EditorCaretRenderProps, } from "./caretOverlay"; diff --git a/packages/rendering/react/src/primitives/editor/root.tsx b/packages/rendering/react/src/primitives/editor/root.tsx index 72c4566..dda3fe2 100644 --- a/packages/rendering/react/src/primitives/editor/root.tsx +++ b/packages/rendering/react/src/primitives/editor/root.tsx @@ -1,26 +1,30 @@ import React, { useRef, useEffect, useState } from "react"; import { FIELD_EDITOR_SLOT_KEY as CORE_FIELD_EDITOR_SLOT_KEY } from "@pen/types"; -import { generateId } from "@pen/types"; import type { AssetProvider, Editor, EditorViewMode, InteractionModel, } from "@pen/types"; -import { usesInlineTextSelection } from "@pen/types"; import { EditorContext, type BlockControlsRenderer, type BlockDragAndDropOptions, + type BlockSelectionOptions, type ResolvedBlockDragAndDropOptions, - type ResolvedInteractionModel, type PasteImporters, type RendererOverrides, + resolveBlockSelection, resolveInteractionModel, } from "../../context/editorContext"; import { FieldEditorContext } from "../../context/fieldEditorContext"; import { FIELD_EDITOR_SLOT_KEY } from "../../constants/fieldEditor"; -import { FieldEditorImpl, type FieldEditorSession } from "@pen/dom"; +import { + FieldEditorImpl, + handleEditorDocumentKeyDown, + shouldHandleEditorKeyboardEvent as shouldHandlePenEditorKeyboardEvent, + type FieldEditorSession, +} from "@pen/dom"; import { useDocumentEmptyState } from "../../hooks/useDocumentEmptyState"; import { domSelectionToEditor } from "../../field-editor/selectionBridge"; import { @@ -30,25 +34,11 @@ import { import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { composeRefs } from "../../utils/composeRefs"; import { DATA_ATTRS } from "../../utils/dataAttributes"; -import { handleEscapeSelectionTransition } from "../../utils/escapeSelection"; -import { - handleSelectAllShortcut, - handleHistoryShortcut, -} from "../../field-editor/keyHandling"; -import { getAdjacentVisibleBlockId } from "../../utils/parentIdTree"; -import { handleTableCellSelectionKeyDown } from "../../utils/tableCellNavigation"; import { BlockDragSessionProvider } from "./blockDragSession"; -const DATABASE_ROW_SELECTION_SLOT = "database:row-selection"; - -type DatabaseRowSelectionController = { - deleteSelectedRows: (blockId: string) => boolean; -}; - export interface EditorRootProps extends AsChildProps { editor: Editor; readonly?: boolean; - inputBackend?: "contenteditable" | "edit-context"; importers?: PasteImporters; assets?: AssetProvider; renderers?: RendererOverrides; @@ -56,6 +46,7 @@ export interface EditorRootProps extends AsChildProps { editorViewMode?: EditorViewMode; interactionModel?: InteractionModel; blockDragAndDrop?: BlockDragAndDropOptions; + blockSelection?: BlockSelectionOptions; ref?: React.Ref; } @@ -63,7 +54,6 @@ export function EditorRoot(props: EditorRootProps) { const { editor, readonly = false, - inputBackend = "edit-context", importers, assets, renderers, @@ -71,6 +61,7 @@ export function EditorRoot(props: EditorRootProps) { editorViewMode = editor.editorViewMode, interactionModel, blockDragAndDrop, + blockSelection, ref, ...rest } = props; @@ -82,21 +73,23 @@ export function EditorRoot(props: EditorRootProps) { editorViewMode, interactionModel, ); + const resolvedBlockSelection = resolveBlockSelection(blockSelection); const [focused, setFocused] = useState(false); const [rootElement, setRootElement] = useState(null); const isEmpty = useDocumentEmptyState(editor); const fieldEditorRef = useRef(null); const regionSelectionStoreRef = useRef(null); const rootRef = useRef(null); - const interactionModelRef = useRef(resolvedInteractionModel); - interactionModelRef.current = resolvedInteractionModel; const resolvedAssets = assets ?? importers?.assets; if (!fieldEditorRef.current) { - fieldEditorRef.current = new FieldEditorImpl(editor, { + const fieldEditorOptions = { selectAllBehavior: resolvedInteractionModel.selectAllBehavior, - inputBackend, - }); + }; + fieldEditorRef.current = new FieldEditorImpl( + editor, + fieldEditorOptions, + ); } if (!regionSelectionStoreRef.current) { regionSelectionStoreRef.current = new RegionSelectionStore(); @@ -178,42 +171,24 @@ export function EditorRoot(props: EditorRootProps) { } const handleDocumentKeyDown = (event: KeyboardEvent) => { - const shouldHandle = shouldHandleEditorKeyboardEvent( + const shouldHandle = shouldHandlePenEditorKeyboardEvent({ root, - editor, event, - ); + selection: editor.selection, + hasMappedDomSelection: () => + domSelectionToEditor(root) !== null, + }); if (!shouldHandle) { return; } if ( - handleEscapeSelectionTransition({ - event, - editor, - fieldEditor, - root, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleDeleteSelectionShortcut(event, editor, fieldEditor, root) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleTableCellSelectionKeyDown({ + handleEditorDocumentKeyDown({ event, editor, fieldEditor, + interactionModel: resolvedInteractionModel.model, root, }) ) { @@ -221,41 +196,6 @@ export function EditorRoot(props: EditorRootProps) { event.stopImmediatePropagation(); return; } - - if ( - handleSelectAllShortcut(editor, event, fieldEditor, { - rootElement: root, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleBlockSelectionEnter( - event, - editor, - fieldEditor, - interactionModelRef.current, - ) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if (handleBlockSelectionArrow(event, editor, fieldEditor)) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if (handleHistoryShortcut(editor, event)) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } }; root.ownerDocument?.addEventListener( @@ -270,7 +210,7 @@ export function EditorRoot(props: EditorRootProps) { true, ); }; - }, [editor]); + }, [editor, resolvedInteractionModel.model]); const primitiveProps: Record = { [DATA_ATTRS.editorRoot]: "", @@ -290,6 +230,7 @@ export function EditorRoot(props: EditorRootProps) { editorViewMode, interactionModel: resolvedInteractionModel, blockDragAndDrop: resolvedBlockDragAndDrop, + blockSelection: resolvedBlockSelection, blockControls, importers, assets: resolvedAssets, @@ -332,378 +273,3 @@ function resolveBlockDragAndDrop( enabled: editorViewMode !== "flow", }; } - -function shouldHandleEditorKeyboardEvent( - root: HTMLElement, - editor: Editor, - event: KeyboardEvent, -): boolean { - const targetRoot = getClosestEditorRoot(event.target); - if (targetRoot && targetRoot !== root) { - return false; - } - - if (isTextEntryTarget(event.target)) { - const target = event.target; - if (!(target instanceof Node) || !root.contains(target)) { - return false; - } - } - - const ownerDocument = root.ownerDocument; - const activeElement = ownerDocument?.activeElement; - const activeRoot = getClosestEditorRoot(activeElement); - if (activeRoot && activeRoot !== root) { - return false; - } - - if (activeElement instanceof Node && root.contains(activeElement)) { - if (isTextEntryTarget(activeElement)) { - if (!isFieldEditorTextEntryTarget(activeElement)) { - return false; - } - const selection = editor.selection; - if ( - selection?.type === "block" || - selection?.type === "cell" || - (selection?.type === "text" && !selection.isCollapsed) - ) { - return true; - } - return shouldHandleCollapsedFieldEditorSelectAll( - event, - activeElement, - ); - } - return true; - } - - if (domSelectionToEditor(root) !== null) { - return true; - } - - const selection = editor.selection; - if (selection?.type === "text" && !selection.isCollapsed) { - return true; - } - - if (selection?.type === "block" && selection.blockIds.length > 0) { - return true; - } - - if (selection?.type === "cell") { - return true; - } - - return false; -} - -function getClosestEditorRoot(target: EventTarget | null): HTMLElement | null { - if (!(target instanceof Node)) { - return null; - } - const element = - target instanceof HTMLElement ? target : target.parentElement; - return element?.closest("[data-pen-editor-root]") as HTMLElement | null; -} - -function isTextEntryTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - if (target instanceof HTMLInputElement) { - return isTextEntryInput(target); - } - - return ( - target.isContentEditable || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement - ); -} - -function isTextEntryInput(input: HTMLInputElement): boolean { - return !( - input.type === "checkbox" || - input.type === "radio" || - input.type === "button" || - input.type === "submit" || - input.type === "reset" || - input.type === "range" || - input.type === "color" || - input.type === "file" - ); -} - -function shouldHandleCollapsedFieldEditorSelectAll( - event: KeyboardEvent, - target: EventTarget | null, -): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - return ( - isSelectAllShortcut(event) && - target.closest(`[${DATA_ATTRS.fieldEditorSurface}]`) !== null - ); -} - -function isFieldEditorTextEntryTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - return target.closest(`[${DATA_ATTRS.fieldEditorSurface}]`) !== null; -} - -function isSelectAllShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "a" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -function handleBlockSelectionArrow( - event: KeyboardEvent, - editor: Editor, - fieldEditor: FieldEditorSession, -): boolean { - if ( - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing - ) { - return false; - } - - const isUp = event.key === "ArrowUp" || event.key === "ArrowLeft"; - const isDown = event.key === "ArrowDown" || event.key === "ArrowRight"; - if (!isUp && !isDown) return false; - - const selection = editor.selection; - if (selection?.type !== "block" || selection.blockIds.length === 0) { - return false; - } - - const blockId = isUp - ? selection.blockIds[0]! - : selection.blockIds[selection.blockIds.length - 1]!; - const direction = isUp ? "previous" : "next"; - - const adjacentId = getAdjacentVisibleBlockId(editor, blockId, direction); - if (!adjacentId) return false; - - const adjacentBlock = editor.getBlock(adjacentId); - if (!adjacentBlock) return false; - - const schema = editor.schema.resolve(adjacentBlock.type); - if (usesInlineTextSelection(schema)) { - const offset = isUp ? adjacentBlock.length() : 0; - fieldEditor.activateTextSelection(adjacentId, offset, offset); - return true; - } - - editor.selectBlock(adjacentId); - return true; -} - -function handleBlockSelectionEnter( - event: KeyboardEvent, - editor: Editor, - fieldEditor: FieldEditorSession, - interactionModelResolved: ResolvedInteractionModel, -): boolean { - if ( - event.key !== "Enter" || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing - ) { - return false; - } - - const selection = editor.selection; - if (selection?.type !== "block" || selection.blockIds.length === 0) { - return false; - } - - const anchorBlockId = selection.blockIds[selection.blockIds.length - 1]!; - const anchorBlock = editor.getBlock(anchorBlockId); - if (!anchorBlock) { - return false; - } - - if ( - interactionModelResolved.model === "block-first" && - selection.blockIds.length === 1 - ) { - const schema = editor.schema.resolve(anchorBlock.type); - if (usesInlineTextSelection(schema)) { - const offset = anchorBlock.length(); - fieldEditor.activateTextSelection(anchorBlockId, offset, offset); - return true; - } - } - - const anchorSchema = editor.schema.resolve(anchorBlock.type); - const newBlockId = generateId(); - - editor.apply( - [ - { - type: "insert-block", - blockId: newBlockId, - blockType: "paragraph", - props: {}, - position: { after: anchorBlockId }, - }, - ], - { origin: "user" }, - ); - - fieldEditor.activateTextSelection(newBlockId, 0, 0); - return true; -} - -function handleDeleteSelectionShortcut( - event: KeyboardEvent, - editor: Editor, - fieldEditor: FieldEditorSession, - root: HTMLElement, -): boolean { - if ( - (event.key !== "Backspace" && event.key !== "Delete") || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing || - fieldEditor.isComposing - ) { - return false; - } - - const selection = editor.selection; - if (tryDeleteSelectedDatabaseRows(root, editor)) { - fieldEditor.deactivate(); - return true; - } - if (!selection) { - return false; - } - - if (selection.type === "text" && !selection.isCollapsed) { - if ( - !selection.isMultiBlock && - !shouldUseDocumentTextDeletionFallback(root, fieldEditor) - ) { - return false; - } - if (selection.isMultiBlock) { - fieldEditor.deactivate(); - } - editor.deleteSelection(); - const nextSelection = editor.selection; - if (nextSelection?.type === "text") { - fieldEditor.activateTextSelection( - nextSelection.focus.blockId, - nextSelection.focus.offset, - nextSelection.focus.offset, - ); - } else { - fieldEditor.deactivate(); - } - return true; - } - - if (selection.type === "block" && selection.blockIds.length > 0) { - editor.deleteSelection(); - fieldEditor.deactivate(); - const firstBlock = editor.firstBlock(); - if (firstBlock) { - const schema = editor.schema.resolve(firstBlock.type); - if (usesInlineTextSelection(schema)) { - fieldEditor.activateTextSelection(firstBlock.id, 0, 0); - } - } - return true; - } - - if (selection.type === "cell") { - editor.deleteSelection(); - return true; - } - - return false; -} - -function tryDeleteSelectedDatabaseRows( - root: HTMLElement, - editor: Editor, -): boolean { - const controller = editor.internals.getSlot(DATABASE_ROW_SELECTION_SLOT) as - | DatabaseRowSelectionController - | undefined; - if (!controller) { - return false; - } - - const activeElement = root.ownerDocument?.activeElement; - if ( - !(activeElement instanceof HTMLElement) || - !root.contains(activeElement) - ) { - return false; - } - - const blockElement = activeElement.closest("[data-block-id]"); - const blockId = blockElement?.getAttribute("data-block-id"); - if (!blockId) { - return false; - } - - const block = editor.getBlock(blockId); - if (!block || block.type !== "database") { - return false; - } - - return controller.deleteSelectedRows(blockId); -} - -function shouldUseDocumentTextDeletionFallback( - root: HTMLElement, - fieldEditor: FieldEditorSession, -): boolean { - if (!fieldEditor.isEditing) { - return true; - } - - const activeElement = root.ownerDocument?.activeElement; - if ( - !(activeElement instanceof HTMLElement) || - !root.contains(activeElement) - ) { - return true; - } - - if (activeElement === root) { - return true; - } - - const activeInlineSurface = activeElement.closest( - `[${DATA_ATTRS.inlineContent}]`, - ); - if (activeInlineSurface === null) { - return true; - } - - return false; -} diff --git a/packages/rendering/react/src/primitives/editor/selectionRect.tsx b/packages/rendering/react/src/primitives/editor/selectionRect.tsx index 0e1b78c..d8b5ed1 100644 --- a/packages/rendering/react/src/primitives/editor/selectionRect.tsx +++ b/packages/rendering/react/src/primitives/editor/selectionRect.tsx @@ -16,7 +16,7 @@ export interface SelectionRectProps extends AsChildProps { } export function EditorSelectionRect(props: SelectionRectProps) { - const { editor } = useEditorContext(); + const { blockSelection, editor } = useEditorContext(); const { rootElement, store } = useEditorRegionSelectionContext(); const selection = useSelection(editor); const [rect, setRect] = useState(null); @@ -40,11 +40,17 @@ export function EditorSelectionRect(props: SelectionRectProps) { const blockCount = isBlockSelection ? selection.blockIds.length : 0; const announcement = useMemo(() => { + if (!blockSelection.enabled) return ""; if (!isBlockSelection || blockCount === 0) return ""; return `${blockCount} block${blockCount === 1 ? "" : "s"} selected`; - }, [isBlockSelection, blockCount]); + }, [blockSelection.enabled, isBlockSelection, blockCount]); useEffect(() => { + if (!blockSelection.enabled) { + setRect(null); + return; + } + if (liveRect) { setRect(new DOMRect(liveRect.left, liveRect.top, liveRect.width, liveRect.height)); return; @@ -105,7 +111,7 @@ export function EditorSelectionRect(props: SelectionRectProps) { rafRef.current = requestAnimationFrame(computeRect); return () => cancelAnimationFrame(rafRef.current); - }, [selection, isBlockSelection, liveRect, regionConfig, rootElement]); + }, [blockSelection.enabled, selection, isBlockSelection, liveRect, regionConfig, rootElement]); if (!rect) { return announcement ? ( diff --git a/packages/rendering/react/src/primitives/index.ts b/packages/rendering/react/src/primitives/index.ts index 4b75bb8..dbc515d 100644 --- a/packages/rendering/react/src/primitives/index.ts +++ b/packages/rendering/react/src/primitives/index.ts @@ -1,4 +1,5 @@ export { + CARET, EditorRoot, EditorContent, EditorBlock, @@ -92,6 +93,7 @@ export { // ── Pen.* namespace for compound component API ────────────── import { + CARET, EditorRoot, EditorContent, EditorBlock, @@ -189,6 +191,7 @@ export const Pen = { Block: EditorBlock, InlineContent, CaretOverlay: EditorCaretOverlay, + CARET, BlockHandle: EditorBlockHandle, DragOverlay: EditorDragOverlay, RegionSelector: EditorRegionSelector, diff --git a/packages/rendering/react/src/primitives/toolbar/root.tsx b/packages/rendering/react/src/primitives/toolbar/root.tsx index f1fd6c7..95df825 100644 --- a/packages/rendering/react/src/primitives/toolbar/root.tsx +++ b/packages/rendering/react/src/primitives/toolbar/root.tsx @@ -39,6 +39,9 @@ export function ToolbarRoot(props: ToolbarRootProps) { blockDragAndDrop: editorContext?.blockDragAndDrop ?? { enabled: false, }, + blockSelection: editorContext?.blockSelection ?? { + enabled: true, + }, blockControls: editorContext?.blockControls, importers: editorContext?.importers, assets: editorContext?.assets, diff --git a/packages/rendering/vue/src/__tests__/mount.test.ts b/packages/rendering/vue/src/__tests__/mount.test.ts index bcc587d..6c073a3 100644 --- a/packages/rendering/vue/src/__tests__/mount.test.ts +++ b/packages/rendering/vue/src/__tests__/mount.test.ts @@ -3,7 +3,7 @@ import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; import { createTestEditor } from "@pen/test"; import { mount } from "@vue/test-utils"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { h, nextTick } from "vue"; import { PenEditor } from "../components/PenEditor"; @@ -146,6 +146,32 @@ describe("@pen/vue", () => { editor.destroy(); }); + it("routes document delete shortcuts through the shared DOM handler", async () => { + const editor = createParagraphEditor(); + const deleteSelection = vi.spyOn(editor, "deleteSelection").mockImplementation(() => undefined); + + const wrapper = mount(PenEditor, { + attachTo: document.body, + props: { editor }, + }); + await nextTick(); + + editor.selectBlock("paragraph-1"); + document.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Backspace", + }), + ); + + expect(deleteSelection).toHaveBeenCalledWith({ origin: "user" }); + + deleteSelection.mockRestore(); + wrapper.unmount(); + editor.destroy(); + }); + it("activates the inline field editor on click", async () => { const editor = createTestEditor({ blocks: [ @@ -349,8 +375,9 @@ describe("@pen/vue", () => { const wrapper = mount(PenEditor, { attachTo: document.body, - props: { editor }, + props: { editor, interactionModel: "block-first" }, }); + await nextTick(); document.dispatchEvent( new KeyboardEvent("keydown", { @@ -378,6 +405,7 @@ describe("@pen/vue", () => { attachTo: document.body, props: { editor }, }); + await nextTick(); document.dispatchEvent( new KeyboardEvent("keydown", { @@ -404,6 +432,7 @@ describe("@pen/vue", () => { attachTo: document.body, props: { editor }, }); + await nextTick(); const firstInline = wrapper.findAll("[data-pen-inline-content]")[0]!; await firstInline.trigger("mousedown"); diff --git a/packages/rendering/vue/src/components/PenEditor.ts b/packages/rendering/vue/src/components/PenEditor.ts index 47e1271..5262eb0 100644 --- a/packages/rendering/vue/src/components/PenEditor.ts +++ b/packages/rendering/vue/src/components/PenEditor.ts @@ -1,778 +1,221 @@ -import { FieldEditorImpl } from "@pen/dom"; import { - handleHistoryShortcut, - handleSelectAllShortcut, -} from "@pen/dom/field-editor/keyHandling"; -import { getAdjacentVisibleBlockId } from "@pen/dom/utils/parentIdTree"; + FieldEditorImpl, + handleEditorDocumentKeyDown, + resolveSelectAllBehavior, + shouldHandleEditorKeyboardEvent as shouldHandlePenEditorKeyboardEvent, +} from "@pen/dom"; +import { domSelectionToEditor } from "@pen/dom/field-editor/selectionBridge"; import { DATA_ATTRS } from "@pen/dom/utils/dataAttributes"; -import { - delegatesToGridEditing, - usesInlineTextSelection, +import type { + AssetProvider, + Editor, + InteractionModel, } from "@pen/types"; -import type { AssetProvider, CellSelection, Editor } from "@pen/types"; import { FIELD_EDITOR_SLOT_KEY as CORE_FIELD_EDITOR_SLOT_KEY } from "@pen/types"; import { - defineComponent, - h, - mergeProps, - onBeforeUnmount, - ref, - toRef, - watch, - type ComponentPublicInstance, - type PropType, + defineComponent, + h, + mergeProps, + onBeforeUnmount, + ref, + toRef, + watch, + type ComponentPublicInstance, + type PropType, } from "vue"; import { FIELD_EDITOR_SLOT_KEY } from "../constants/fieldEditor"; import { useDocumentEmptyState } from "../internal/editorState"; import { provideEditorContext } from "../internal/editorContext"; import { - provideFieldEditorContext, - type VueFieldEditor, + provideFieldEditorContext, + type VueFieldEditor, } from "../internal/fieldEditorContext"; import type { PasteImporters, RendererOverrides } from "../types"; import { PenContent } from "./PenContent"; export const PenEditor = defineComponent({ - name: "PenEditor", - props: { - editor: { - type: Object as PropType, - required: true, - }, - readonly: { - type: Boolean, - default: false, - }, - importers: { - type: Object as PropType, - default: undefined, - }, - assets: { - type: Object as PropType, - default: undefined, - }, - emptyPlaceholder: { - type: String, - default: undefined, - }, - renderers: { - type: Object as PropType, - default: undefined, - }, - }, - setup(props, { attrs, slots }) { - const focused = ref(false); - const rootElement = ref(null); - const readonlyRef = toRef(props, "readonly"); - const emptyPlaceholderRef = toRef(props, "emptyPlaceholder"); - const renderersRef = toRef(props, "renderers"); - const fieldEditor = new FieldEditorImpl(props.editor) as VueFieldEditor; - const isDocumentEmpty = useDocumentEmptyState(props.editor); - - provideEditorContext({ - editor: props.editor, - readonly: readonlyRef, - emptyPlaceholder: emptyPlaceholderRef, - renderers: renderersRef, - }); - provideFieldEditorContext(fieldEditor); - - props.editor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - props.editor.internals.setSlot(CORE_FIELD_EDITOR_SLOT_KEY, fieldEditor); - - watch( - rootElement, - (nextElement, _previousElement, onCleanup) => { - fieldEditor.setRootElement(nextElement); - if (!nextElement) { - focused.value = false; - fieldEditor.setFocused(false); - return; - } - - const handleFocusIn = () => { - focused.value = true; - fieldEditor.setFocused(true); - }; - - const handleFocusOut = () => { - const activeElement = nextElement.ownerDocument?.activeElement; - const nextFocused = - activeElement instanceof Node && nextElement.contains(activeElement); - focused.value = nextFocused; - fieldEditor.setFocused(nextFocused); - }; - - nextElement.addEventListener("focusin", handleFocusIn); - nextElement.addEventListener("focusout", handleFocusOut); - - onCleanup(() => { - nextElement.removeEventListener("focusin", handleFocusIn); - nextElement.removeEventListener("focusout", handleFocusOut); - }); - }, - { immediate: true }, - ); - - watch( - rootElement, - (nextElement, _previousElement, onCleanup) => { - if (!nextElement) { - return; - } - - const ownerDocument = nextElement.ownerDocument; - const handleKeyDown = (event: KeyboardEvent) => { - if (!shouldHandleEditorKeyboardEvent(nextElement, props.editor, event)) { - return; - } - - if ( - handleEscapeSelectionTransition({ - event, - editor: props.editor, - fieldEditor, - root: nextElement, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleDeleteSelectionShortcut({ - event, - editor: props.editor, - fieldEditor, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleTableCellSelectionKeyDown({ - event, - editor: props.editor, - fieldEditor, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleSelectAllShortcut(props.editor, event, fieldEditor, { - rootElement: nextElement, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleBlockSelectionEnter({ - event, - editor: props.editor, - fieldEditor, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if (handleHistoryShortcut(props.editor, event)) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - - if ( - handleBlockSelectionArrow({ - event, - editor: props.editor, - fieldEditor, - }) - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - }; - - ownerDocument?.addEventListener("keydown", handleKeyDown, true); - onCleanup(() => { - ownerDocument?.removeEventListener("keydown", handleKeyDown, true); - }); - }, - { immediate: true }, - ); - - watch( - () => [props.importers, props.assets] as const, - ([importers, assets]) => { - props.editor.internals.setSlot("paste:importers", importers); - props.editor.internals.setSlot( - "paste:assetProvider", - assets ?? importers?.assets, - ); - }, - { immediate: true }, - ); - - onBeforeUnmount(() => { - props.editor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, undefined); - props.editor.internals.setSlot(CORE_FIELD_EDITOR_SLOT_KEY, undefined); - props.editor.internals.setSlot("paste:importers", undefined); - props.editor.internals.setSlot("paste:assetProvider", undefined); - fieldEditor.setRootElement(null); - fieldEditor.destroy(); - }); - - return () => { - const children = slots.default ? slots.default() : [h(PenContent)]; - - return h( - "div", - mergeProps(attrs, { - ref: (element: Element | ComponentPublicInstance | null) => { - rootElement.value = - element instanceof HTMLElement ? element : null; - }, - [DATA_ATTRS.editorRoot]: "", - [DATA_ATTRS.viewId]: props.editor.internals.viewId, - [DATA_ATTRS.focused]: focused.value || undefined, - [DATA_ATTRS.readonly]: props.readonly || undefined, - [DATA_ATTRS.empty]: isDocumentEmpty.value || undefined, - role: "textbox", - tabIndex: -1, - "aria-multiline": "true", - "aria-readonly": props.readonly, - }), - children, - ); - }; - }, + name: "PenEditor", + props: { + editor: { + type: Object as PropType, + required: true, + }, + readonly: { + type: Boolean, + default: false, + }, + interactionModel: { + type: String as PropType, + default: undefined, + }, + importers: { + type: Object as PropType, + default: undefined, + }, + assets: { + type: Object as PropType, + default: undefined, + }, + emptyPlaceholder: { + type: String, + default: undefined, + }, + renderers: { + type: Object as PropType, + default: undefined, + }, + }, + setup(props, { attrs, slots }) { + const focused = ref(false); + const rootElement = ref(null); + const readonlyRef = toRef(props, "readonly"); + const emptyPlaceholderRef = toRef(props, "emptyPlaceholder"); + const renderersRef = toRef(props, "renderers"); + const fieldEditor = new FieldEditorImpl(props.editor, { + selectAllBehavior: resolveSelectAllBehavior( + props.interactionModel ?? "content-first", + ), + }) as VueFieldEditor; + const isDocumentEmpty = useDocumentEmptyState(props.editor); + + provideEditorContext({ + editor: props.editor, + readonly: readonlyRef, + emptyPlaceholder: emptyPlaceholderRef, + renderers: renderersRef, + }); + provideFieldEditorContext(fieldEditor); + + props.editor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + props.editor.internals.setSlot(CORE_FIELD_EDITOR_SLOT_KEY, fieldEditor); + + watch( + () => props.interactionModel, + (interactionModel) => { + fieldEditor.setSelectAllBehavior( + resolveSelectAllBehavior(interactionModel ?? "content-first"), + ); + }, + ); + + watch( + rootElement, + (nextElement, _previousElement, onCleanup) => { + fieldEditor.setRootElement(nextElement); + if (!nextElement) { + focused.value = false; + fieldEditor.setFocused(false); + return; + } + + const ownerDocument = nextElement.ownerDocument; + const handleFocusIn = () => { + focused.value = true; + fieldEditor.setFocused(true); + }; + + const handleFocusOut = () => { + const activeElement = + nextElement.ownerDocument?.activeElement; + const nextFocused = + activeElement instanceof Node && + nextElement.contains(activeElement); + focused.value = nextFocused; + fieldEditor.setFocused(nextFocused); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !shouldHandlePenEditorKeyboardEvent({ + root: nextElement, + event, + selection: props.editor.selection, + hasMappedDomSelection: () => + domSelectionToEditor(nextElement) !== null, + }) + ) { + return; + } + + if ( + handleEditorDocumentKeyDown({ + event, + editor: props.editor, + fieldEditor, + interactionModel: + props.interactionModel ?? "content-first", + root: nextElement, + }) + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + }; + + nextElement.addEventListener("focusin", handleFocusIn); + nextElement.addEventListener("focusout", handleFocusOut); + ownerDocument?.addEventListener("keydown", handleKeyDown, true); + onCleanup(() => { + nextElement.removeEventListener("focusin", handleFocusIn); + nextElement.removeEventListener("focusout", handleFocusOut); + ownerDocument?.removeEventListener( + "keydown", + handleKeyDown, + true, + ); + }); + }, + { immediate: true }, + ); + + watch( + () => [props.importers, props.assets] as const, + ([importers, assets]) => { + props.editor.internals.setSlot("paste:importers", importers); + props.editor.internals.setSlot( + "paste:assetProvider", + assets ?? importers?.assets, + ); + }, + { immediate: true }, + ); + + onBeforeUnmount(() => { + props.editor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, undefined); + props.editor.internals.setSlot( + CORE_FIELD_EDITOR_SLOT_KEY, + undefined, + ); + props.editor.internals.setSlot("paste:importers", undefined); + props.editor.internals.setSlot("paste:assetProvider", undefined); + fieldEditor.setRootElement(null); + fieldEditor.destroy(); + }); + + return () => { + const children = slots.default ? slots.default() : [h(PenContent)]; + + return h( + "div", + mergeProps(attrs, { + ref: ( + element: Element | ComponentPublicInstance | null, + ) => { + rootElement.value = + element instanceof HTMLElement ? element : null; + }, + [DATA_ATTRS.editorRoot]: "", + [DATA_ATTRS.viewId]: props.editor.internals.viewId, + [DATA_ATTRS.focused]: focused.value || undefined, + [DATA_ATTRS.readonly]: props.readonly || undefined, + [DATA_ATTRS.empty]: isDocumentEmpty.value || undefined, + tabIndex: -1, + }), + children, + ); + }; + }, }); export type PenEditorProps = InstanceType["$props"]; - -function shouldHandleEditorKeyboardEvent( - root: HTMLElement, - editor: Editor, - event: KeyboardEvent, -): boolean { - const targetRoot = getClosestEditorRoot(event.target); - if (targetRoot && targetRoot !== root) { - return false; - } - - const activeElement = root.ownerDocument?.activeElement; - const activeRoot = getClosestEditorRoot(activeElement); - if (activeRoot && activeRoot !== root) { - return false; - } - - if ( - activeElement instanceof Node && - root.contains(activeElement) && - isTextEntryTarget(activeElement) && - !isFieldEditorTextEntryTarget(activeElement) - ) { - return false; - } - - return ( - editor.selection?.type === "cell" || - editor.selection?.type === "block" || - editor.selection?.type === "text" - ); -} - -function getClosestEditorRoot(target: EventTarget | null): HTMLElement | null { - if (!(target instanceof Node)) { - return null; - } - - const element = - target instanceof HTMLElement ? target : target.parentElement; - return element?.closest("[data-pen-editor-root]") as HTMLElement | null; -} - -function isTextEntryTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - if (target instanceof HTMLInputElement) { - return !( - target.type === "checkbox" || - target.type === "radio" || - target.type === "button" || - target.type === "submit" || - target.type === "reset" || - target.type === "range" || - target.type === "color" || - target.type === "file" - ); - } - - return ( - target.isContentEditable || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement - ); -} - -function isFieldEditorTextEntryTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - return target.closest(`[${DATA_ATTRS.fieldEditorSurface}]`) !== null; -} - -function handleEscapeSelectionTransition(options: { - event: KeyboardEvent; - editor: Editor; - fieldEditor: VueFieldEditor; - root: HTMLElement; -}): boolean { - const { event, editor, fieldEditor, root } = options; - - if ( - event.defaultPrevented || - event.key !== "Escape" || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing || - fieldEditor.isComposing - ) { - return false; - } - - const selection = editor.selection; - - if (fieldEditor.activeCellCoord && fieldEditor.isEditing) { - const coord = fieldEditor.activeCellCoord; - fieldEditor.deactivate(); - editor.selectCell(coord.blockId, coord.row, coord.col); - focusBlockContainer(root, coord.blockId); - return true; - } - - if (selection?.type === "text" && !selection.isCollapsed) { - fieldEditor.collapseSelectionToFocus(); - return true; - } - - if (selection?.type === "text") { - const blockId = selection.focus.blockId; - fieldEditor.deactivate(); - editor.selectBlock(blockId); - focusBlockContainer(root, blockId); - return true; - } - - if (selection?.type === "cell") { - const isMultiCell = - selection.anchor.row !== selection.head.row || - selection.anchor.col !== selection.head.col; - - if (isMultiCell) { - editor.selectCell(selection.blockId, selection.anchor.row, selection.anchor.col); - return true; - } - - editor.selectBlock(selection.blockId); - focusBlockContainer(root, selection.blockId); - return true; - } - - if (selection?.type === "block" && selection.blockIds.length > 0) { - const focusedBlockId = selection.blockIds[0] ?? fieldEditor.focusBlockId; - editor.setSelection(null); - focusBlockContainer(root, focusedBlockId); - return true; - } - - return false; -} - -function focusBlockContainer(root: HTMLElement, blockId: string | null): void { - if (blockId) { - const blockElement = root.querySelector(`[data-block-id="${blockId}"]`); - if (blockElement instanceof HTMLElement) { - blockElement.focus({ preventScroll: true }); - return; - } - } - - root.focus({ preventScroll: true }); -} - -function handleTableCellSelectionKeyDown(options: { - event: KeyboardEvent; - editor: Editor; - fieldEditor: VueFieldEditor; -}): boolean { - const { event, editor, fieldEditor } = options; - const selection = editor.selection; - - if (selection?.type !== "cell") { - return false; - } - if (event.defaultPrevented || event.isComposing || fieldEditor.isEditing) { - return false; - } - - const block = editor.getBlock(selection.blockId); - if (!block) { - return false; - } - - const rowCount = selection.rowIds?.length ?? block.tableRowCount(); - const colCount = selection.columnIds?.length ?? block.tableColumnCount(); - - if (isArrowKey(event.key) && !event.metaKey && !event.ctrlKey && !event.altKey) { - const delta = arrowDelta(event.key); - if (event.shiftKey) { - const nextHead = clampCoord( - { - row: selection.head.row + delta.row, - col: selection.head.col + delta.col, - }, - rowCount, - colCount, - ); - setCellSelection(editor, selection, selection.anchor, nextHead); - return true; - } - - const exitsGrid = - (event.key === "ArrowUp" && selection.head.row === 0) || - (event.key === "ArrowLeft" && selection.head.col === 0) || - (event.key === "ArrowDown" && selection.head.row === rowCount - 1) || - (event.key === "ArrowRight" && selection.head.col === colCount - 1); - - if (exitsGrid) { - moveSelectionToAdjacentBlock(editor, fieldEditor, selection.blockId, event.key); - return true; - } - - setCellSelection( - editor, - selection, - clampCoord( - { - row: selection.head.row + delta.row, - col: selection.head.col + delta.col, - }, - rowCount, - colCount, - ), - ); - return true; - } - - if (event.key === "Tab" && !event.metaKey && !event.ctrlKey && !event.altKey) { - const direction = event.shiftKey ? -1 : 1; - const linearIndex = - selection.head.row * colCount + selection.head.col + direction; - const totalCells = rowCount * colCount; - const clamped = Math.max(0, Math.min(totalCells - 1, linearIndex)); - const nextRow = Math.floor(clamped / colCount); - const nextCol = clamped % colCount; - setCellSelection(editor, selection, { row: nextRow, col: nextCol }); - return true; - } - - if ( - (event.key === "Enter" || event.key === "F2") && - !event.shiftKey && - !event.metaKey && - !event.ctrlKey && - !event.altKey - ) { - fieldEditor.activateCell?.( - selection.blockId, - selection.head.row, - selection.head.col, - ); - return true; - } - - return false; -} - -function handleBlockSelectionArrow(options: { - event: KeyboardEvent; - editor: Editor; - fieldEditor: VueFieldEditor; -}): boolean { - const { event, editor, fieldEditor } = options; - - if ( - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing - ) { - return false; - } - - const isUp = event.key === "ArrowUp" || event.key === "ArrowLeft"; - const isDown = event.key === "ArrowDown" || event.key === "ArrowRight"; - if (!isUp && !isDown) { - return false; - } - - const selection = editor.selection; - if (selection?.type !== "block" || selection.blockIds.length === 0) { - return false; - } - - const blockId = isUp - ? selection.blockIds[0]! - : selection.blockIds[selection.blockIds.length - 1]!; - const direction = isUp ? "previous" : "next"; - const adjacentId = getAdjacentVisibleBlockId(editor, blockId, direction); - if (!adjacentId) { - return false; - } - - const adjacentBlock = editor.getBlock(adjacentId); - if (!adjacentBlock) { - return false; - } - - const schema = editor.schema.resolve(adjacentBlock.type); - if (usesInlineTextSelection(schema)) { - const offset = isUp ? adjacentBlock.length() : 0; - fieldEditor.activateTextSelection(adjacentId, offset, offset); - return true; - } - - editor.selectBlock(adjacentId); - return true; -} - -function handleBlockSelectionEnter(options: { - event: KeyboardEvent; - editor: Editor; - fieldEditor: VueFieldEditor; -}): boolean { - const { event, editor, fieldEditor } = options; - - if ( - event.key !== "Enter" || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing - ) { - return false; - } - - const selection = editor.selection; - if (selection?.type !== "block" || selection.blockIds.length === 0) { - return false; - } - - const anchorBlockId = selection.blockIds[selection.blockIds.length - 1]!; - const anchorBlock = editor.getBlock(anchorBlockId); - if (!anchorBlock) { - return false; - } - - const schema = editor.schema.resolve(anchorBlock.type); - if (selection.blockIds.length === 1 && usesInlineTextSelection(schema)) { - const offset = anchorBlock.length(); - fieldEditor.activateTextSelection(anchorBlockId, offset, offset); - return true; - } - - const newBlockId = crypto.randomUUID(); - editor.apply( - [ - { - type: "insert-block", - blockId: newBlockId, - blockType: "paragraph", - props: {}, - position: { after: anchorBlockId }, - }, - ], - { origin: "user" }, - ); - fieldEditor.activateTextSelection(newBlockId, 0, 0); - return true; -} - -function handleDeleteSelectionShortcut(options: { - event: KeyboardEvent; - editor: Editor; - fieldEditor: VueFieldEditor; -}): boolean { - const { event, editor, fieldEditor } = options; - - if ( - (event.key !== "Backspace" && event.key !== "Delete") || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.isComposing || - fieldEditor.isComposing - ) { - return false; - } - - const selection = editor.selection; - if (!selection) { - return false; - } - - if (selection.type === "text" && !selection.isCollapsed) { - if (selection.isMultiBlock) { - fieldEditor.deactivate(); - } - editor.deleteSelection(); - const nextSelection = editor.selection; - if (nextSelection?.type === "text") { - fieldEditor.activateTextSelection( - nextSelection.focus.blockId, - nextSelection.focus.offset, - nextSelection.focus.offset, - ); - } else { - fieldEditor.deactivate(); - } - return true; - } - - if (selection.type === "block" && selection.blockIds.length > 0) { - editor.deleteSelection(); - fieldEditor.deactivate(); - const firstBlock = editor.firstBlock(); - if (firstBlock) { - const schema = editor.schema.resolve(firstBlock.type); - if (usesInlineTextSelection(schema)) { - fieldEditor.activateTextSelection(firstBlock.id, 0, 0); - } - } - return true; - } - - if (selection.type === "cell") { - editor.deleteSelection(); - return true; - } - - return false; -} - -function moveSelectionToAdjacentBlock( - editor: Editor, - fieldEditor: VueFieldEditor, - blockId: string, - key: string, -): void { - const direction = - key === "ArrowUp" || key === "ArrowLeft" ? "previous" : "next"; - const adjacentId = getAdjacentVisibleBlockId(editor, blockId, direction); - - if (!adjacentId) { - editor.selectBlock(blockId); - fieldEditor.deactivate(); - return; - } - - const adjacentBlock = editor.getBlock(adjacentId); - if (!adjacentBlock) { - editor.selectBlock(blockId); - fieldEditor.deactivate(); - return; - } - - const schema = editor.schema.resolve(adjacentBlock.type); - if (delegatesToGridEditing(schema)) { - const targetRow = - direction === "previous" - ? Math.max(adjacentBlock.tableRowCount() - 1, 0) - : 0; - const targetCol = - direction === "previous" - ? Math.max(adjacentBlock.tableColumnCount() - 1, 0) - : 0; - editor.selectCell(adjacentId, targetRow, targetCol); - fieldEditor.deactivate(); - return; - } - - if (usesInlineTextSelection(schema)) { - const offset = direction === "previous" ? adjacentBlock.length() : 0; - fieldEditor.activateTextSelection(adjacentId, offset, offset); - return; - } - - editor.selectBlock(adjacentId); - fieldEditor.deactivate(); -} - -function setCellSelection( - editor: Editor, - selection: CellSelection, - anchor: { row: number; col: number }, - head: { row: number; col: number } = anchor, -): void { - if (anchor.row === head.row && anchor.col === head.col) { - editor.selectCell(selection.blockId, anchor.row, anchor.col); - return; - } - - editor.selectCellRange(selection.blockId, anchor, head); -} - -function clampCoord( - coord: { row: number; col: number }, - rowCount: number, - colCount: number, -): { row: number; col: number } { - return { - row: Math.max(0, Math.min(rowCount - 1, coord.row)), - col: Math.max(0, Math.min(colCount - 1, coord.col)), - }; -} - -function isArrowKey(key: string): boolean { - return ( - key === "ArrowUp" || - key === "ArrowDown" || - key === "ArrowLeft" || - key === "ArrowRight" - ); -} - -function arrowDelta(key: string): { row: number; col: number } { - switch (key) { - case "ArrowUp": - return { row: -1, col: 0 }; - case "ArrowDown": - return { row: 1, col: 0 }; - case "ArrowLeft": - return { row: 0, col: -1 }; - case "ArrowRight": - return { row: 0, col: 1 }; - default: - return { row: 0, col: 0 }; - } -} diff --git a/spec/charter/architecture.md b/spec/charter/architecture.md index 00fd24b..9174095 100644 --- a/spec/charter/architecture.md +++ b/spec/charter/architecture.md @@ -14,7 +14,8 @@ Pen is a headless, extension-first editor engine. The document model, mutation p - `@pen/core` owns editor authority, document state, normalization, selection, extensions, and the canonical mutation pipeline. - Schema packages define block and inline surfaces. - Extension packages add optional runtime behavior such as AI, search, undo, multiplayer, input rules, import, and export. -- Rendering packages bind the headless runtime to framework-native component and hook systems. +- `@pen/dom` owns the framework-neutral browser editing engine, including field-editor sessions, DOM selection bridging, clipboard flows, text-entry target detection, and shared document-keyboard behavior. +- Rendering packages bind the headless runtime and DOM editing engine to framework-native component, hook, or composable systems. - Tooling and app packages support development, testing, docs, and examples. ## Rules @@ -22,5 +23,6 @@ Pen is a headless, extension-first editor engine. The document model, mutation p - Runtime writes go through `editor.apply(...)`. - Extensions are the feature composition model. - Renderer packages do not become alternate sources of document truth. +- Shared browser editing behavior belongs in `@pen/dom`; React and Vue should delegate to it instead of carrying framework-local keyboard, selection, or table-editing forks. - Host applications own auth, transport policy, and product-specific UI decisions. - Shared helpers should stay below package boundaries rather than leaking renderer or app assumptions into the core. diff --git a/spec/packages/core.md b/spec/packages/core.md index bb9d46b..8129c30 100644 --- a/spec/packages/core.md +++ b/spec/packages/core.md @@ -54,6 +54,7 @@ Important rules: - `DocumentOp[]` is the mutation currency. - Durable document writes go through `editor.apply(...)`. - Structured operation origins can carry `groupId`, `requestId`, `actorId`, and `source` metadata so hosts can attribute and group mutations without inventing a parallel apply path. +- Default feature composition should flow through presets or explicit extensions; legacy `createEditor({ without })` remains deprecated compatibility rather than the preferred way to remove default features. - Extensions can prepare work, observe editor events, and register slots, but they do not bypass the core mutation boundary. - Renderer packages read `DocumentState`, `BlockHandle`, selection, and decorations from the editor; they do not become alternate document authorities. @@ -68,6 +69,7 @@ Headless editors default to the core apply pipeline only. Hosts can opt into def - Path in workspace: `packages/core` - Spec path mirrors workspace path: `packages/core.md` - Typical adoption starts with `createEditor()` plus `@pen/schema-default` and `@pen/preset-default` +- Use `createEditor({ preset: defaultPreset(...) })` or explicit `extensions` for feature composition instead of the deprecated `without` option. - Server/workflow adoption starts with `createHeadlessEditor()` plus a wrapped CRDT document. - Schema composition happens here through the registry/merge APIs, not in renderer packages - Serialization packages and tool packages should treat the editor as the authority boundary, even when they export convenience helpers diff --git a/spec/packages/presets/default.md b/spec/packages/presets/default.md index b42009e..e382e28 100644 --- a/spec/packages/presets/default.md +++ b/spec/packages/presets/default.md @@ -11,6 +11,7 @@ Package the standard runtime stack for most adopters so they can start from a co ## Key Exports / Entrypoints - Export map: `.` +- Root export: `defaultPreset()` - Workspace scripts: `build`, `clean`, `test`, `typecheck` ## Dependencies And Boundaries @@ -23,11 +24,14 @@ Package the standard runtime stack for most adopters so they can start from a co Preset composition packages in Pen should stay package-first and explicit about ownership. Use `defaultPreset()` when the standard Pen runtime is the right baseline. +The default preset composes document tools, delta stream, undo, and rich-text shortcuts. Hosts can turn individual defaults off or pass typed options to the composed extension packages; hosts that need full control should skip the preset and register extensions explicitly through `createEditor({ extensions: [...] })`. + ## Integration Notes - Path in workspace: `packages/presets/default` - Spec path mirrors workspace path: `packages/presets/default.md` - This package is part of the current package surface and should stay aligned with the headless runtime architecture. +- Prefer `createEditor({ preset: defaultPreset(...) })` over the deprecated `createEditor({ without })` shape when customizing default feature composition. ## Current Maturity / Intended Usage diff --git a/spec/packages/rendering/dom.md b/spec/packages/rendering/dom.md index a38daa2..2f47828 100644 --- a/spec/packages/rendering/dom.md +++ b/spec/packages/rendering/dom.md @@ -2,17 +2,17 @@ ## Purpose -`@pen/dom` provides the shared DOM field-editor engine and low-level DOM reconciliation helpers used by Pen renderers. It is the package that turns editor state into browser editing behavior without tying that behavior to React or Vue. +`@pen/dom` provides the shared DOM field-editor engine, document-keyboard routing, and low-level DOM reconciliation helpers used by Pen renderers. It is the package that turns editor state into browser editing behavior without tying that behavior to React or Vue. ## Public Role -This package sits between `@pen/core` and renderer packages. It owns DOM-specific editing concerns like reconciliation, selection bridging, clipboard handling, and select-all behavior, while leaving component structure and framework lifecycle to the renderer layer. +This package sits between `@pen/core` and renderer packages. It owns DOM-specific editing concerns like reconciliation, selection bridging, clipboard handling, text-entry target detection, document shortcuts, table-cell navigation, and select-all behavior, while leaving component structure and framework lifecycle to the renderer layer. ## Key Exports / Entrypoints - Export map: `.`, `./field-editor`, `./field-editor/*`, `./constants/selectAll`, `./types/paste`, `./utils/dataAttributes`, `./utils/inlineDecorations`, `./utils/parentIdTree` -- Root exports such as `FieldEditorImpl`, `FieldEditorSession`, `resolveSelectAllBehavior()`, and `PasteImporters` -- Field-editor exports such as `fullReconcileToDOM()`, `applyDeltaToDOM()`, selection bridge helpers, cross-block selection helpers, and clipboard handlers +- Root exports such as `FieldEditorImpl`, `FieldEditorSession`, `handleEditorDocumentKeyDown()`, `handleEscapeSelectionTransition()`, `handleTableCellSelectionKeyDown()`, `resolveSelectAllBehavior()`, text-entry routing helpers, and `PasteImporters` +- Field-editor exports such as `fullReconcileToDOM()`, `applyDeltaToDOM()`, selection bridge helpers, cross-block selection helpers, clipboard handlers, and field-editor store types - DOM utility subpaths for renderer packages that need shared data-attribute or decoration helpers - Workspace scripts: `build`, `clean`, `test`, `typecheck` @@ -32,23 +32,30 @@ flowchart TD Dom["@pen/dom"] Reconcile[DOMReconciliation] Selection[SelectionBridge] + Keyboard[DocumentKeyboardRouting] + TextEntry[TextEntryTargetModel] Clipboard[ClipboardAndPaste] Core["@pen/core"] Renderer --> Dom Dom --> Reconcile Dom --> Selection + Dom --> Keyboard + Keyboard --> TextEntry Dom --> Clipboard Reconcile --> Core Selection --> Core + Keyboard --> Core Clipboard --> Core ``` Important rules: - DOM selection is a view-layer representation and must stay synchronized with editor selection. +- Renderer roots should route captured document keydown events through `shouldHandleEditorKeyboardEvent()` before calling `handleEditorDocumentKeyDown()` so native inputs and other editor roots keep their own keyboard ownership. - Clipboard and typing flows resolve back into editor mutations instead of mutating the document model directly. -- Shared keyboard or select-all behavior belongs here when it is DOM-engine behavior, not framework-specific UI behavior. +- Shared Escape, select-all, block-selection, history shortcut, deletion, and table-cell navigation behavior belongs here when it is DOM-engine behavior, not framework-specific UI behavior. +- Table cell copy, cut, paste, printable-key entry, and navigation must preserve structured cell selection metadata and apply document changes through `editor.apply(...)`. ## Integration Notes @@ -56,6 +63,7 @@ Important rules: - Spec path mirrors workspace path: `packages/rendering/dom.md` - Renderer packages should depend on this package instead of each reimplementing selection bridging or reconciliation - The `./field-editor` subpath is the main surface for renderer authors who need lower-level control +- React and Vue roots both install `FieldEditorImpl`, register the shared field-editor slots, wire paste importers/assets, and delegate document-level keyboard handling back to this package. - This package should stay small in conceptual scope even if its internals are complex, because it is a boundary package rather than a product surface ## Current Maturity / Intended Usage diff --git a/spec/packages/rendering/react.md b/spec/packages/rendering/react.md index 7819bfe..ed049c1 100644 --- a/spec/packages/rendering/react.md +++ b/spec/packages/rendering/react.md @@ -10,18 +10,18 @@ This package is where most adopters start when embedding Pen in a React applicat ## Key Exports / Entrypoints -- Export map: `.` +- Export map: `.`, `./ai`, `./ai-suggestions`, `./history`, `./multiplayer`, `./search` - Convenience editor entrypoint: `PenEditor` - Compound namespace: `Pen` -- Editor primitives such as `EditorRoot`, `EditorContent`, `EditorBlock`, overlays, selection rects, and field-editor wrappers -- Toolbar, slash-menu, selection-toolbar, search, AI, and multiplayer primitives +- Editor primitives such as `EditorRoot`, `EditorContent`, `EditorBlock`, `EditorCaretOverlay`, `CARET`, selection rects, and field-editor wrappers +- Toolbar, slash-menu, selection-toolbar, search, AI, AI suggestions, history, and multiplayer primitives - Hooks such as `useEditor`, `useSelection`, `useDecorations`, `useBlockList`, `useSearch`, `useAI`, and related state hooks - Advanced contexts and renderer options for custom composition - Workspace scripts: `build`, `clean`, `test`, `typecheck` ## Dependencies And Boundaries -- Runtime dependencies: `@pen/ai`, `@pen/core`, `@pen/dom`, `@pen/history`, `@pen/multiplayer`, `@pen/schema-default`, `@pen/search`, `@pen/shortcuts`, `@pen/types` +- Runtime dependencies: `@pen/ai`, `@pen/ai-suggestions`, `@pen/core`, `@pen/dom`, `@pen/history`, `@pen/multiplayer`, `@pen/schema-default`, `@pen/search`, `@pen/shortcuts`, `@pen/types` - Peer dependencies: `@pen/import-html`, `@pen/import-markdown`, `react`, `react-dom` - Boundary: `@pen/react` binds the headless runtime to React without taking ownership of document truth. @@ -50,7 +50,8 @@ Important responsibilities: - Mount editor roots and block rendering surfaces - Subscribe React state to editor state through hooks and contexts -- Delegate shared DOM editing behavior to `@pen/dom` +- Install the shared field-editor session, paste importer slots, and captured document-keyboard handlers for the active editor root +- Delegate shared DOM editing, selection transition, table-cell navigation, and shortcut routing behavior to `@pen/dom` - Surface extension state through React-friendly primitives rather than reimplementing extension logic locally ## Integration Notes @@ -59,6 +60,8 @@ Important responsibilities: - Spec path mirrors workspace path: `packages/rendering/react.md` - `PenEditor` is the simplest integration path for most apps - The `Pen` namespace exists for lower-level composition when hosts need toolbar, slash-menu, AI, search, or multiplayer surfaces +- Optional subpath entrypoints let hosts import AI, AI suggestions, history, multiplayer, and search surfaces without pulling from the root barrel directly. +- `Pen.Editor.CaretOverlay` renders an optional local caret for collapsed active text selections, exposes `CARET` variants, and hides the native caret while the overlay is visible. - Optional importer peer dependencies stay peer-level because not every React integration needs HTML or Markdown paste/import support ## Current Maturity / Intended Usage @@ -69,4 +72,4 @@ Workspace package at version `0.0.0`; intended usage is current-state but still - Do not push core runtime, transport, or auth concerns into the React layer. - Do not let React component state become a second document authority. -- Do not move shared DOM editing behavior out of `@pen/dom` just because React is the primary renderer. +- Do not reimplement shared document keyboard, selection transition, table-cell, or DOM editing behavior locally just because React is the primary renderer. diff --git a/spec/packages/rendering/vue.md b/spec/packages/rendering/vue.md index 79651d6..9465dc5 100644 --- a/spec/packages/rendering/vue.md +++ b/spec/packages/rendering/vue.md @@ -6,12 +6,12 @@ ## Public Role -This package gives Vue applications a lean but real renderer surface: core editor components, composables for editor-derived state, and a simple plugin for global component registration. Its strategic role is broader than its API size, because it validates the cross-framework architecture. +This package gives Vue applications a lean but real renderer surface: core editor components, composables for editor-derived state, shared DOM field-editor integration, and a simple plugin for global component registration. Its strategic role is broader than its API size, because it validates the cross-framework architecture. ## Key Exports / Entrypoints - Export map: `.`, `./plugin` -- Root exports such as `PenEditor`, `PenContent`, `PenBlock`, `PenInlineContent`, `PenFieldEditor` +- Root exports such as `PenEditor`, `PenContent`, `PenBlock`, `PenInlineContent`, `PenFieldEditor`, and `PenEditorProps` - Composables such as `useEditor`, `useSelection`, `useBlockList`, and `useDecorations` - Plugin export: `PenVuePlugin` - Public renderer and paste-importer types such as `RendererOverrides` and `PasteImporters` @@ -46,14 +46,15 @@ Important responsibilities: - Mount the editor and shared field-editor engine in a Vue host - Expose key editor-derived state through composables instead of duplicating state inside components +- Register the shared field-editor slots, paste importer/assets slots, focused/read-only/empty root attributes, and captured document-keyboard handling from `@pen/dom` - Support renderer overrides so host apps can customize block rendering without forking the runtime -- Validate that keyboard, selection, clipboard, and table-editing behavior stay portable across frameworks +- Validate that keyboard routing, Escape selection transitions, select-all behavior, clipboard, and table-editing behavior stay portable across frameworks ## Integration Notes - Path in workspace: `packages/rendering/vue` - Spec path mirrors workspace path: `packages/rendering/vue.md` -- `PenEditor` is the main integration entrypoint; `PenVuePlugin` is optional convenience for global registration +- `PenEditor` is the main integration entrypoint; it renders default `PenContent` when no default slot is provided, and `PenVuePlugin` is optional convenience for global registration - The package intentionally exposes fewer primitives than `@pen/react`; that is a design choice, not necessarily a gap - Use this package when a Vue host needs Pen without rebuilding the editing engine From 852a9cf9573e631d988885381112dcbe144bf832 Mon Sep 17 00:00:00 2001 From: krijn Date: Thu, 14 May 2026 00:23:15 +0200 Subject: [PATCH 11/20] Refactor editor and inline handling for improved clarity and functionality - Updated function signatures for better readability and consistency across the codebase. - Enhanced inline node handling in the editor, allowing for more flexible text insertion and deletion. - Improved handling of text deltas to support both string and inline node types. - Refactored selection and offset calculations to streamline editor interactions. - Added new data attributes for inline atom management to facilitate future enhancements. --- packages/core/src/schema/handles.ts | 94 ++++-- .../dom/src/field-editor/commands.ts | 108 ++++++- .../dom/src/field-editor/fieldEditorImpl.ts | 2 +- .../dom/src/field-editor/inlineAtomDom.ts | 280 ++++++++++++++++++ .../dom/src/field-editor/reconciler.ts | 104 ++++--- .../dom/src/field-editor/selectionBridge.ts | 171 ++++------- .../rendering/dom/src/utils/dataAttributes.ts | 2 + .../dom/src/utils/inlineDecorations.ts | 28 +- .../src/__tests__/fieldEditorCommands.test.ts | 201 +++++++++++-- .../src/__tests__/inlineAtomEditing.test.tsx | 136 +++++++++ .../react/src/hooks/useBlockTextSnapshot.ts | 23 +- .../react/src/primitives/editor/content.tsx | 4 +- .../src/primitives/editor/inlineContent.tsx | 41 ++- .../react/src/utils/dataAttributes.ts | 2 + .../react/src/utils/inlineDecorations.ts | 28 +- 15 files changed, 987 insertions(+), 237 deletions(-) create mode 100644 packages/rendering/dom/src/field-editor/inlineAtomDom.ts create mode 100644 packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx diff --git a/packages/core/src/schema/handles.ts b/packages/core/src/schema/handles.ts index ab65193..ccf204c 100644 --- a/packages/core/src/schema/handles.ts +++ b/packages/core/src/schema/handles.ts @@ -61,11 +61,15 @@ type TextDelta = { attributes?: Record; }; -function getMapEntries(map: CRDTUnknownMap | null): Iterable<[string, unknown]> { +function getMapEntries( + map: CRDTUnknownMap | null, +): Iterable<[string, unknown]> { return map?.entries?.() ?? []; } -function getChildrenArray(blockMap: CRDTUnknownMap): CRDTUnknownArray | null { +function getChildrenArray( + blockMap: CRDTUnknownMap, +): CRDTUnknownArray | null { return getArrayProp(blockMap, "children"); } @@ -110,14 +114,17 @@ function toInlineDeltas(content: CRDTTextLike | null): InlineDelta[] { } function arrayValues(array: CRDTUnknownArray): T[] { - return array.toArray?.() ?? Array.from({ length: array.length }, (_, index) => array.get(index)); + return ( + array.toArray?.() ?? + Array.from({ length: array.length }, (_, index) => array.get(index)) + ); } class TableRowHandleImpl implements TableRowHandle { constructor( readonly id: string, readonly index: number, - ) { } + ) {} } class BlockHandleImpl implements BlockHandle { @@ -126,7 +133,7 @@ class BlockHandleImpl implements BlockHandle { private readonly _doc: PenDocument, private readonly _crdtDoc: CRDTDocument, private readonly _registry: SchemaRegistry, - ) { } + ) {} get id(): string { return this._id; @@ -181,8 +188,9 @@ class BlockHandleImpl implements BlockHandle { } get parent(): BlockHandle | null { - const parentId = (this.props as Record) - .parentId as string | undefined; + const parentId = (this.props as Record).parentId as + | string + | undefined; if (parentId && this._doc.blocks.has(parentId)) { return new BlockHandleImpl( parentId, @@ -333,8 +341,14 @@ class BlockHandleImpl implements BlockHandle { const result: AppHandle[] = []; for (const [appId, rawAppMap] of this._doc.apps.entries()) { if (!isCRDTMap(rawAppMap)) continue; - const placement = rawAppMap.get("placement") as AppPlacement | undefined; - if (placement && "blockId" in placement && placement.blockId === this._id) { + const placement = rawAppMap.get("placement") as + | AppPlacement + | undefined; + if ( + placement && + "blockId" in placement && + placement.blockId === this._id + ) { result.push( new AppHandleImpl( appId, @@ -379,7 +393,15 @@ class BlockHandleImpl implements BlockHandle { } length(): number { - return this.textContent().length; + const content = getTextProp(this.blockMap, "content"); + if (!content) { + return 0; + } + const text = content.toString(); + if (!text || text === "\u200B") { + return 0; + } + return content.length; } // ── Metadata ────────────────────────────────────────── @@ -485,7 +507,9 @@ class BlockHandleImpl implements BlockHandle { if (!primaryViewId) { return views[0] ?? null; } - return views.find((view) => view.id === primaryViewId) ?? views[0] ?? null; + return ( + views.find((view) => view.id === primaryViewId) ?? views[0] ?? null + ); } // ── Internal ────────────────────────────────────────── @@ -517,7 +541,11 @@ class BlockHandleImpl implements BlockHandle { const id = mapLike.get?.("id"); const title = mapLike.get?.("title"); const type = mapLike.get?.("type"); - if (typeof id !== "string" || typeof title !== "string" || typeof type !== "string") { + if ( + typeof id !== "string" || + typeof title !== "string" || + typeof type !== "string" + ) { return null; } const options = this.toPlainArray(mapLike.get?.("options")); @@ -529,7 +557,8 @@ class BlockHandleImpl implements BlockHandle { hidden: this.toBoolean(mapLike.get?.("hidden")), pinned: this.toPinned(mapLike.get?.("pinned")), options, - format: (this.toPlainObject(mapLike.get?.("format")) ?? undefined) as TableColumnSchema["format"], + format: (this.toPlainObject(mapLike.get?.("format")) ?? + undefined) as TableColumnSchema["format"], readonly: this.toBoolean(mapLike.get?.("readonly")), }; } @@ -551,9 +580,13 @@ class BlockHandleImpl implements BlockHandle { id, title: this.toString(mapLike.get?.("title")), type: type as DatabaseViewState["type"], - visibleColumnIds: this.toStringArray(mapLike.get?.("visibleColumnIds")), + visibleColumnIds: this.toStringArray( + mapLike.get?.("visibleColumnIds"), + ), columnOrder: this.toStringArray(mapLike.get?.("columnOrder")), - sort: this.toPlainArray(mapLike.get?.("sort")) as DatabaseViewState["sort"], + sort: this.toPlainArray( + mapLike.get?.("sort"), + ) as DatabaseViewState["sort"], filter: (filterValue as DatabaseViewState["filter"] | null) ?? null, groupBy: this.toNullableString(mapLike.get?.("groupBy")), rowPinning: this.toDatabaseRowPinning(mapLike.get?.("rowPinning")), @@ -562,7 +595,9 @@ class BlockHandleImpl implements BlockHandle { }; } - private toDatabaseRowPinning(value: unknown): DatabaseViewState["rowPinning"] { + private toDatabaseRowPinning( + value: unknown, + ): DatabaseViewState["rowPinning"] { if (!value || typeof value !== "object") { return undefined; } @@ -572,7 +607,8 @@ class BlockHandleImpl implements BlockHandle { const topValues = this.toStringArray(mapLike.get?.("top")); const bottomValues = this.toStringArray(mapLike.get?.("bottom")); const top = topValues && topValues.length > 0 ? topValues : undefined; - const bottom = bottomValues && bottomValues.length > 0 ? bottomValues : undefined; + const bottom = + bottomValues && bottomValues.length > 0 ? bottomValues : undefined; if (!top && !bottom) { return undefined; } @@ -583,14 +619,23 @@ class BlockHandleImpl implements BlockHandle { } private toPlainArray(value: unknown): TableColumnSchema["options"] { - if (!value || typeof (value as { toArray?: () => unknown[] }).toArray !== "function") { + if ( + !value || + typeof (value as { toArray?: () => unknown[] }).toArray !== + "function" + ) { return undefined; } const items = (value as { toArray: () => unknown[] }).toArray(); return items .map((item) => this.toPlainValue(item)) .filter((item): item is Record => item !== null) - .map((item) => item as unknown as NonNullable[number]); + .map( + (item) => + item as unknown as NonNullable< + TableColumnSchema["options"] + >[number], + ); } private toPlainObject(value: unknown): Record | null { @@ -615,7 +660,11 @@ class BlockHandleImpl implements BlockHandle { } private toStringArray(value: unknown): string[] | undefined { - if (!value || typeof (value as { toArray?: () => unknown[] }).toArray !== "function") { + if ( + !value || + typeof (value as { toArray?: () => unknown[] }).toArray !== + "function" + ) { return undefined; } return (value as { toArray: () => unknown[] }) @@ -646,7 +695,7 @@ class AppHandleImpl implements AppHandle { private readonly _doc: PenDocument, private readonly _crdtDoc: CRDTDocument, private readonly _registry: SchemaRegistry, - ) { } + ) {} get id(): string { return this._id; @@ -691,7 +740,7 @@ class TableCellHandleImpl implements TableCellHandle { private readonly _cellMap: TableCellMap, private readonly _row: number, private readonly _col: number, - ) { } + ) {} get id(): string { return getStringProp(this._cellMap, "id") ?? ""; @@ -743,4 +792,3 @@ class TableCellHandleImpl implements TableCellHandle { })); } } - diff --git a/packages/rendering/dom/src/field-editor/commands.ts b/packages/rendering/dom/src/field-editor/commands.ts index 8987f05..d92d978 100644 --- a/packages/rendering/dom/src/field-editor/commands.ts +++ b/packages/rendering/dom/src/field-editor/commands.ts @@ -1,7 +1,4 @@ -import { - INPUT_RULES_ENGINE_SLOT_KEY, - generateId, -} from "@pen/types"; +import { INPUT_RULES_ENGINE_SLOT_KEY, generateId } from "@pen/types"; import type { DocumentOp, Editor } from "@pen/types"; import { toggleInlineMark as toggleInlineMarkCommand, @@ -12,7 +9,10 @@ import { getAdjacentVisibleBlockId, isInsideParentIdContainer, } from "../utils/parentIdTree"; -import { getEditorFlowCapability, isContinuousTextFlowCapability } from "../utils/flowCapabilities"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; const ZERO_WIDTH_SPACE = "\u200B"; @@ -140,6 +140,56 @@ function isCollapsedRange(range: SelectionRange | null): boolean { return !range || range.start === range.end; } +function getInlineNodeSelectionTarget( + editor: Editor, + options: { + blockId: string; + offset: number; + direction: DeleteDirection; + }, +): SelectionTarget | null { + const block = editor.getBlock(options.blockId); + if (!block) { + return null; + } + + let currentOffset = 0; + for (const delta of block.inlineDeltas()) { + const length = + typeof delta.insert === "string" ? delta.insert.length : 1; + const nextOffset = currentOffset + length; + const isInlineNode = typeof delta.insert !== "string"; + + if ( + isInlineNode && + options.direction === "backward" && + options.offset === nextOffset + ) { + return { + blockId: options.blockId, + anchorOffset: currentOffset, + focusOffset: nextOffset, + }; + } + + if ( + isInlineNode && + options.direction === "forward" && + options.offset === currentOffset + ) { + return { + blockId: options.blockId, + anchorOffset: currentOffset, + focusOffset: nextOffset, + }; + } + + currentOffset = nextOffset; + } + + return null; +} + function getListIndent( block: NonNullable>, ): number { @@ -222,14 +272,18 @@ export function applyListTabBehavior( if (shiftKey) { nextIndent = Math.max(0, currentIndent - 1); } else { - const previousBlockId = getAdjacentVisibleBlockId(editor, blockId, "previous"); + const previousBlockId = getAdjacentVisibleBlockId( + editor, + blockId, + "previous", + ); const previousBlock = previousBlockId ? editor.getBlock(previousBlockId) : null; const sharesParent = previousBlockId !== null && editor.documentState.parentOf(previousBlockId) === - editor.documentState.parentOf(blockId); + editor.documentState.parentOf(blockId); if ( isListBlock(previousBlock) && @@ -271,7 +325,9 @@ export function resolveBackspaceAction( if (!isCollapsedRange(range)) return null; if ((range?.start ?? 0) !== 0) return null; if ( - !isContinuousTextFlowCapability(getEditorFlowCapability(editor, blockId)) + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, blockId), + ) ) { return null; } @@ -279,8 +335,16 @@ export function resolveBackspaceAction( const block = editor.getBlock(blockId); if (!block) return null; - if (isBlockEmpty(ytext) && block.type === "toggle" && block.children.length === 0) { - const previousBlock = getAdjacentEditableBlock(editor, blockId, "previous"); + if ( + isBlockEmpty(ytext) && + block.type === "toggle" && + block.children.length === 0 + ) { + const previousBlock = getAdjacentEditableBlock( + editor, + blockId, + "previous", + ); if (previousBlock) { return { action: "delete", @@ -294,7 +358,11 @@ export function resolveBackspaceAction( return { action: "convert", newType: "paragraph" }; } - const immediateBlockId = getAdjacentVisibleBlockId(editor, blockId, "previous"); + const immediateBlockId = getAdjacentVisibleBlockId( + editor, + blockId, + "previous", + ); if ( immediateBlockId && !isContinuousTextFlowCapability( @@ -372,7 +440,9 @@ export function applyBackspaceBehavior( }; } -function getCollapsedTextSelectionTarget(editor: Editor): SelectionTarget | null { +function getCollapsedTextSelectionTarget( + editor: Editor, +): SelectionTarget | null { const selection = editor.selection; if (!selection || selection.type !== "text") { return null; @@ -410,6 +480,15 @@ export function applyDeleteBehavior( ); } + const inlineNodeTarget = getInlineNodeSelectionTarget(editor, { + blockId, + offset: range.start, + direction, + }); + if (inlineNodeTarget) { + return inlineNodeTarget; + } + if (direction === "backward") { return applyBackspaceBehavior(editor, { blockId, @@ -630,8 +709,9 @@ export function applyListInputRule( } const inputRuleEngine = - editor.internals.getSlot(INPUT_RULES_ENGINE_SLOT_KEY) ?? - null; + editor.internals.getSlot( + INPUT_RULES_ENGINE_SLOT_KEY, + ) ?? null; if (inputRuleEngine) { const ops = inputRuleEngine.tryMatch(editor, blockId, text, { offset: range.start, diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index c444e2f..d57dee7 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -830,7 +830,7 @@ export class FieldEditorImpl implements FieldEditorSession { const targetOffset = targetIdx >= activeIdx - ? (this._editor.getBlock(blockId)?.textContent().length ?? 0) + ? (this._editor.getBlock(blockId)?.length() ?? 0) : 0; this._editor.selectTextRange(anchor, { diff --git a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts new file mode 100644 index 0000000..a4391b6 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts @@ -0,0 +1,280 @@ +import type { SchemaRegistry } from "@pen/types"; +import { DATA_ATTRS } from "../utils/dataAttributes"; + +export const INLINE_ATOM_REPLACEMENT_TEXT = "\uFFFC"; + +interface InlineAtomInsert { + type: string; + props: Record; +} + +export function resolveInlineAtomInsert( + insert: unknown, +): InlineAtomInsert | null { + if (!insert || typeof insert !== "object") { + return null; + } + + const record = insert as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (!type) { + return null; + } + + if (record.props && typeof record.props === "object") { + return { + type, + props: record.props as Record, + }; + } + + const props: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key !== "type") { + props[key] = value; + } + } + + return { type, props }; +} + +export function createInlineAtomElement( + insert: unknown, + registry: SchemaRegistry, +): HTMLElement { + const atom = resolveInlineAtomInsert(insert); + const element = document.createElement("span"); + element.setAttribute(DATA_ATTRS.inlineAtom, ""); + element.contentEditable = "false"; + + if (!atom) { + element.textContent = INLINE_ATOM_REPLACEMENT_TEXT; + return element; + } + + element.setAttribute(DATA_ATTRS.inlineAtomType, atom.type); + element.setAttribute("aria-label", getInlineAtomText(atom, registry)); + element.textContent = getInlineAtomText(atom, registry); + return element; +} + +export function isInlineAtomNode(node: Node | null): node is HTMLElement { + return ( + node instanceof HTMLElement && node.hasAttribute(DATA_ATTRS.inlineAtom) + ); +} + +export function getLogicalNodeLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length ?? 0; + } + + if (isInlineAtomNode(node)) { + return 1; + } + + let length = 0; + for (const child of Array.from(node.childNodes)) { + length += getLogicalNodeLength(child); + } + return length; +} + +export function getLogicalTextContent(root: HTMLElement): string { + let text = ""; + for (const child of Array.from(root.childNodes)) { + text += getLogicalNodeText(child); + } + return text; +} + +export function domPointToLogicalOffset( + container: HTMLElement, + targetNode: Node, + targetOffset: number, +): number { + const atomAncestor = findInlineAtomAncestor(targetNode, container); + if (atomAncestor && atomAncestor !== targetNode) { + return getOffsetBeforeNode(container, atomAncestor); + } + + const resolved = resolveLogicalOffset(container, targetNode, targetOffset); + return resolved ?? getLogicalNodeLength(container); +} + +export function findLogicalDOMPoint( + container: HTMLElement, + offset: number, +): { node: Node; offset: number } { + return findLogicalDOMPointInElement(container, Math.max(0, offset)); +} + +function getInlineAtomText( + atom: InlineAtomInsert, + registry: SchemaRegistry, +): string { + const schemaText = registry + .resolveInline(atom.type) + ?.serialize.toMarkdown?.("", atom.props); + if (schemaText) { + return schemaText; + } + + const label = atom.props.label; + if (typeof label === "string" && label.length > 0) { + return label; + } + + const name = atom.props.name; + if (typeof name === "string" && name.length > 0) { + return name; + } + + const id = atom.props.id; + if (typeof id === "string" && id.length > 0) { + return id; + } + + return atom.type; +} + +function getLogicalNodeText(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ""; + } + + if (isInlineAtomNode(node)) { + return INLINE_ATOM_REPLACEMENT_TEXT; + } + + let text = ""; + for (const child of Array.from(node.childNodes)) { + text += getLogicalNodeText(child); + } + return text; +} + +function findInlineAtomAncestor( + node: Node, + container: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== container) { + if (isInlineAtomNode(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function getOffsetBeforeNode(container: HTMLElement, target: Node): number { + let offset = 0; + let found = false; + + const visit = (node: Node) => { + if (found) { + return; + } + if (node === target) { + found = true; + return; + } + if (node !== container) { + offset += getLogicalNodeLength(node); + return; + } + for (const child of Array.from(node.childNodes)) { + visit(child); + if (found) { + return; + } + } + }; + + visit(container); + return offset; +} + +function resolveLogicalOffset( + current: Node, + targetNode: Node, + targetOffset: number, +): number | null { + if (current === targetNode) { + if (current.nodeType === Node.TEXT_NODE) { + return Math.min(targetOffset, current.textContent?.length ?? 0); + } + + let offset = 0; + const children = Array.from(current.childNodes); + for ( + let index = 0; + index < targetOffset && index < children.length; + index += 1 + ) { + offset += getLogicalNodeLength(children[index]); + } + return offset; + } + + if (current.nodeType === Node.TEXT_NODE || isInlineAtomNode(current)) { + return null; + } + + let offset = 0; + for (const child of Array.from(current.childNodes)) { + const childOffset = resolveLogicalOffset( + child, + targetNode, + targetOffset, + ); + if (childOffset !== null) { + return offset + childOffset; + } + offset += getLogicalNodeLength(child); + } + + return null; +} + +function findLogicalDOMPointInElement( + element: HTMLElement, + offset: number, +): { node: Node; offset: number } { + let remaining = offset; + const children = Array.from(element.childNodes); + + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + const length = getLogicalNodeLength(child); + + if (remaining === 0) { + return { node: element, offset: index }; + } + + if (child.nodeType === Node.TEXT_NODE) { + if (remaining <= length) { + return { node: child, offset: remaining }; + } + remaining -= length; + continue; + } + + if (isInlineAtomNode(child)) { + if (remaining <= 1) { + return { node: element, offset: index + 1 }; + } + remaining -= 1; + continue; + } + + if (remaining <= length && child instanceof HTMLElement) { + return findLogicalDOMPointInElement(child, remaining); + } + + remaining -= length; + } + + return { node: element, offset: children.length }; +} diff --git a/packages/rendering/dom/src/field-editor/reconciler.ts b/packages/rendering/dom/src/field-editor/reconciler.ts index 75b3080..59030d0 100644 --- a/packages/rendering/dom/src/field-editor/reconciler.ts +++ b/packages/rendering/dom/src/field-editor/reconciler.ts @@ -5,6 +5,11 @@ import { applyInlineDecorationsToDeltas, INLINE_DECORATION_ATTRIBUTE_KEY, } from "../utils/inlineDecorations"; +import { + createInlineAtomElement, + domPointToLogicalOffset, + findLogicalDOMPoint, +} from "./inlineAtomDom"; // ── Fast path: event-driven delta application ────────────── @@ -46,14 +51,18 @@ export function applyDeltaToDOM( if (span && span.nodeType === Node.TEXT_NODE) { const existing = span.textContent ?? ""; span.textContent = - existing.slice(0, textOffset) + text + existing.slice(textOffset); + existing.slice(0, textOffset) + + text + + existing.slice(textOffset); textOffset += text.length; } else if (span && span.nodeType === Node.ELEMENT_NODE) { const leaf = deepLeafText(span); if (!leaf) return false; const existing = leaf.textContent ?? ""; leaf.textContent = - existing.slice(0, textOffset) + text + existing.slice(textOffset); + existing.slice(0, textOffset) + + text + + existing.slice(textOffset); textOffset += text.length; } else { element.appendChild(document.createTextNode(text)); @@ -62,7 +71,11 @@ export function applyDeltaToDOM( } } else { if (textOffset === 0) { - const node = createMarkedNode(text, entry.attributes, _registry); + const node = createMarkedNode( + text, + entry.attributes, + _registry, + ); const ref = element.childNodes[childIndex] ?? null; element.insertBefore(node, ref); childIndex++; @@ -70,12 +83,16 @@ export function applyDeltaToDOM( return false; } } + } else if (entry.insert != null) { + return false; } else if (entry.delete != null) { let remaining = entry.delete; while (remaining > 0 && childIndex < element.childNodes.length) { const span = element.childNodes[childIndex]; const leaf = - span.nodeType === Node.TEXT_NODE ? span : deepLeafText(span); + span.nodeType === Node.TEXT_NODE + ? span + : deepLeafText(span); if (!leaf) return false; const existing = leaf.textContent ?? ""; const available = existing.length - textOffset; @@ -122,15 +139,19 @@ export function fullReconcileToDOM( inlineDecorations?: readonly InlineDecoration[]; }, ): void { - const textDeltas = ytext - .toDelta() - .filter( - (delta): delta is FieldEditorDelta & { insert: string } => - typeof delta.insert === "string", - ); + const textDeltas = ytext.toDelta().filter( + ( + delta, + ): delta is FieldEditorDelta & { + insert: string | Record; + } => delta.insert != null, + ); const renderedDeltas = options?.inlineDecorations && options.inlineDecorations.length > 0 - ? applyInlineDecorationsToDeltas(textDeltas, options.inlineDecorations) + ? applyInlineDecorationsToDeltas( + textDeltas, + options.inlineDecorations, + ) : textDeltas; fullReconcileDeltasToDOM(renderedDeltas, element, registry, options); } @@ -143,7 +164,10 @@ export function fullReconcileDeltasToDOM( ): void { const orderedDeltas = deltas.map((d) => { if (!d.attributes || Object.keys(d.attributes).length < 2) return d; - return { ...d, attributes: sortDeltaAttributes(d.attributes, registry) }; + return { + ...d, + attributes: sortDeltaAttributes(d.attributes, registry), + }; }); const preserveSelection = options?.preserveSelection ?? true; @@ -151,8 +175,11 @@ export function fullReconcileDeltasToDOM( const fragment = document.createDocumentFragment(); for (const delta of orderedDeltas) { - if (typeof delta.insert !== "string") continue; - let node: Node = document.createTextNode(delta.insert); + if (delta.insert == null) continue; + let node: Node = + typeof delta.insert === "string" + ? document.createTextNode(delta.insert) + : createInlineAtomElement(delta.insert, registry); if (delta.attributes) { node = wrapWithMarks(node, delta.attributes, registry); } @@ -173,10 +200,11 @@ function wrapWithMarks( registry: SchemaRegistry, ): Node { let wrapped = node; - const decorationAttributes = - isDecorationAttributesValue(attributes[INLINE_DECORATION_ATTRIBUTE_KEY]) - ? attributes[INLINE_DECORATION_ATTRIBUTE_KEY] - : null; + const decorationAttributes = isDecorationAttributesValue( + attributes[INLINE_DECORATION_ATTRIBUTE_KEY], + ) + ? attributes[INLINE_DECORATION_ATTRIBUTE_KEY] + : null; const entries = Object.entries(attributes) .filter(([key]) => key !== INLINE_DECORATION_ATTRIBUTE_KEY) @@ -371,7 +399,10 @@ function nodesStructurallyEqual(a: Node, b: Node): boolean { } function updateTextContent(target: Node, source: Node): void { - if (target.nodeType === Node.TEXT_NODE && source.nodeType === Node.TEXT_NODE) { + if ( + target.nodeType === Node.TEXT_NODE && + source.nodeType === Node.TEXT_NODE + ) { if (target.textContent !== source.textContent) { target.textContent = source.textContent; } @@ -398,8 +429,16 @@ export function saveSelection(element: HTMLElement): SavedSelection | null { const sel = typeof window !== "undefined" ? window.getSelection() : null; if (!sel || sel.rangeCount === 0) return null; - const anchorOffset = computeCharacterOffset(element, sel.anchorNode, sel.anchorOffset); - const focusOffset = computeCharacterOffset(element, sel.focusNode, sel.focusOffset); + const anchorOffset = computeCharacterOffset( + element, + sel.anchorNode, + sel.anchorOffset, + ); + const focusOffset = computeCharacterOffset( + element, + sel.focusNode, + sel.focusOffset, + ); return { anchorOffset, focusOffset }; } @@ -434,31 +473,12 @@ function computeCharacterOffset( offset: number, ): number { if (!node) return 0; - let charOffset = 0; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - if (current === node) { - return charOffset + offset; - } - charOffset += (current.textContent ?? "").length; - } - return charOffset + offset; + return domPointToLogicalOffset(root, node, offset); } function findPositionInDOM( root: HTMLElement, charOffset: number, ): { node: Node; offset: number } | null { - let remaining = charOffset; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - const len = (current.textContent ?? "").length; - if (remaining <= len) { - return { node: current, offset: remaining }; - } - remaining -= len; - } - return null; + return findLogicalDOMPoint(root, charOffset); } diff --git a/packages/rendering/dom/src/field-editor/selectionBridge.ts b/packages/rendering/dom/src/field-editor/selectionBridge.ts index d090e8d..ac226f7 100644 --- a/packages/rendering/dom/src/field-editor/selectionBridge.ts +++ b/packages/rendering/dom/src/field-editor/selectionBridge.ts @@ -8,6 +8,12 @@ import { getBlockSelectionRoleFromType, getSelectionLengthForRole, } from "../utils/blockSelectionSemantics"; +import { + domPointToLogicalOffset, + findLogicalDOMPoint, + getLogicalNodeLength, + getLogicalTextContent, +} from "./inlineAtomDom"; /** * Safely query a block element by ID, escaping special characters to prevent @@ -86,7 +92,7 @@ export function computeTextDiff( } export function extractTextFromDOM(element: HTMLElement): string { - return element.textContent ?? ""; + return getLogicalTextContent(element); } export interface SelectionPoint { @@ -126,35 +132,7 @@ function fallbackCharacterOffset( targetNode: Node, targetOffset: number, ): number { - let charOffset = 0; - - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - ); - - let textNode: Text | null; - while ((textNode = walker.nextNode() as Text | null)) { - if (textNode === targetNode) { - return charOffset + Math.min(targetOffset, textNode.length); - } - charOffset += textNode.textContent?.length ?? 0; - } - - if (targetNode === container) { - let counted = 0; - for ( - let i = 0; - i < targetOffset && i < container.childNodes.length; - i++ - ) { - counted += container.childNodes[i].textContent?.length ?? 0; - } - return counted; - } - - return charOffset; + return domPointToLogicalOffset(container, targetNode, targetOffset); } /** @@ -171,14 +149,7 @@ export function domPointToOffset( return fallbackCharacterOffset(container, targetNode, targetOffset); } - try { - const range = container.ownerDocument.createRange(); - range.setStart(container, 0); - range.setEnd(targetNode, targetOffset); - return range.toString().length; - } catch { - return fallbackCharacterOffset(container, targetNode, targetOffset); - } + return domPointToLogicalOffset(container, targetNode, targetOffset); } /** @@ -230,32 +201,22 @@ function getCharacterRectAtOffset( container: HTMLElement, charOffset: number, ): DOMRect | null { - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = charOffset; - let textNode: Text | null; - - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining < len) { - const range = document.createRange(); - range.setStart(textNode, remaining); - range.setEnd(textNode, remaining + 1); - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter === "function") { - const rect = rangeRectGetter.call(range); - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - return null; + const domPoint = findLogicalDOMPoint(container, charOffset); + const range = document.createRange(); + try { + range.setStart(domPoint.node, domPoint.offset); + range.setEnd(domPoint.node, domPoint.offset); + } catch { + return null; + } + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter === "function") { + const rect = rangeRectGetter.call(range); + if (rect.width > 0 || rect.height > 0) { + return rect; } - remaining -= len; } return null; @@ -265,7 +226,7 @@ function getInlineCaretRectFromOffset( inlineEl: HTMLElement, offset: number, ): DOMRect { - const textLength = inlineEl.textContent?.length ?? 0; + const textLength = getLogicalNodeLength(inlineEl); const inlineRect = inlineEl.getBoundingClientRect(); if (textLength <= 0) { return { @@ -326,17 +287,13 @@ function getInlineCaretRectFromOffset( const previousRect = getCharacterRectAtOffset(inlineEl, offset - 1); const nextRect = getCharacterRectAtOffset(inlineEl, offset); const useNextRect = - previousRect && - nextRect && - nextRect.top > previousRect.top + 1; + previousRect && nextRect && nextRect.top > previousRect.top + 1; const sourceRect = useNextRect ? nextRect : (previousRect ?? nextRect ?? inlineRect); const left = useNextRect ? (nextRect?.left ?? inlineRect.left) - : (previousRect?.right ?? - nextRect?.left ?? - inlineRect.left); + : (previousRect?.right ?? nextRect?.left ?? inlineRect.left); return { x: left, @@ -384,13 +341,26 @@ function stabilizeWrappedLineOffset( } const previousRect = getInlineCaretRectFromOffset(inlineEl, previousOffset); - const candidateRect = getInlineCaretRectFromOffset(inlineEl, candidateOffset); - if (Math.abs(previousRect.top - candidateRect.top) <= WRAPPED_LINE_DELTA_PX) { + const candidateRect = getInlineCaretRectFromOffset( + inlineEl, + candidateOffset, + ); + if ( + Math.abs(previousRect.top - candidateRect.top) <= WRAPPED_LINE_DELTA_PX + ) { return candidateOffset; } - const previousMetrics = getCaretDistanceMetrics(previousRect, clientX, clientY); - const candidateMetrics = getCaretDistanceMetrics(candidateRect, clientX, clientY); + const previousMetrics = getCaretDistanceMetrics( + previousRect, + clientX, + clientY, + ); + const candidateMetrics = getCaretDistanceMetrics( + candidateRect, + clientX, + clientY, + ); const isNearWrappedBoundary = previousMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX && candidateMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX; @@ -411,7 +381,7 @@ function approximateInlineOffsetFromPoint( clientY: number, previousOffset?: number | null, ): number { - const textLength = inlineEl.textContent?.length ?? 0; + const textLength = getLogicalNodeLength(inlineEl); if (textLength <= 0) return 0; let bestOffset = 0; @@ -452,7 +422,7 @@ function getBlockSurfaceRole( function getBlockTextLength(blockEl: HTMLElement): number { const inlineEl = findInlineContentElement(blockEl); if (inlineEl) { - return inlineEl.textContent?.length ?? 0; + return getLogicalNodeLength(inlineEl); } return blockEl.textContent?.length ?? 0; } @@ -522,9 +492,7 @@ export function getClosestBlockElementFromPoint( return hitBlockEl; } - const blockElements = root.querySelectorAll( - `[${DATA_ATTRS.editorBlock}]`, - ); + const blockElements = root.querySelectorAll(`[${DATA_ATTRS.editorBlock}]`); let closestBlockEl: HTMLElement | null = null; let bestScore = Number.POSITIVE_INFINITY; @@ -664,7 +632,11 @@ export function pointToEditorSelectionPoint( if (resolved) return resolved; } - const hoveredBlockEl = getClosestBlockElementFromPoint(root, clientX, clientY); + const hoveredBlockEl = getClosestBlockElementFromPoint( + root, + clientX, + clientY, + ); if (!hoveredBlockEl) return null; return getSelectionPointForBlockAtPointer( hoveredBlockEl, @@ -821,43 +793,7 @@ function findDOMPoint( ) as HTMLElement | null; if (!inlineEl) return null; - const walker = document.createTreeWalker( - inlineEl, - NodeFilter.SHOW_TEXT, - null, - ); - - let remaining = charOffset; - let textNode: Text | null; - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining <= len) { - return { node: textNode, offset: remaining }; - } - remaining -= len; - } - - // Past end — position at end of the last text node. Using the last child - // element here is incorrect because element offsets are child indices, not - // character positions. - const lastText = getLastTextNode(inlineEl); - if (lastText) { - return { - node: lastText, - offset: lastText.textContent?.length ?? 0, - }; - } - return { node: inlineEl, offset: 0 }; -} - -function getLastTextNode(root: HTMLElement): Text | null { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); - let lastText: Text | null = null; - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - lastText = current; - } - return lastText; + return findLogicalDOMPoint(inlineEl, charOffset); } /** @@ -988,4 +924,3 @@ function compareDOMPoints( return leftRange.compareBoundaryPoints(Range.START_TO_START, rightRange); } - diff --git a/packages/rendering/dom/src/utils/dataAttributes.ts b/packages/rendering/dom/src/utils/dataAttributes.ts index 846a553..da4ad39 100644 --- a/packages/rendering/dom/src/utils/dataAttributes.ts +++ b/packages/rendering/dom/src/utils/dataAttributes.ts @@ -21,6 +21,8 @@ export const DATA_ATTRS = { viewId: "data-pen-view-id", editorBlock: "data-pen-editor-block", inlineContent: "data-pen-inline-content", + inlineAtom: "data-pen-inline-atom", + inlineAtomType: "data-pen-inline-atom-type", fieldEditorSurface: "data-pen-field-editor-surface", fieldEditorActiveSurface: "data-pen-field-editor-active-surface", fieldEditor: "data-pen-field-editor", diff --git a/packages/rendering/dom/src/utils/inlineDecorations.ts b/packages/rendering/dom/src/utils/inlineDecorations.ts index 5c3fa2c..b5bfa3b 100644 --- a/packages/rendering/dom/src/utils/inlineDecorations.ts +++ b/packages/rendering/dom/src/utils/inlineDecorations.ts @@ -3,7 +3,7 @@ import type { InlineDecoration } from "@pen/types"; const INLINE_DECORATION_ATTRIBUTE_KEY = "__penInlineDecoration"; interface TextDelta { - insert: string; + insert: string | Record; attributes?: Readonly>; } @@ -18,7 +18,9 @@ export function applyInlineDecorationsToDeltas( const normalizedDecorations = decorations .filter((decoration) => decoration.to > decoration.from) .sort((left, right) => - left.from === right.from ? left.to - right.to : left.from - right.from, + left.from === right.from + ? left.to - right.to + : left.from - right.from, ); if (normalizedDecorations.length === 0) { return [...deltas]; @@ -28,6 +30,12 @@ export function applyInlineDecorationsToDeltas( let offset = 0; for (const delta of deltas) { + if (typeof delta.insert !== "string") { + result.push({ ...delta }); + offset += 1; + continue; + } + const text = delta.insert; const textLength = text.length; if (textLength === 0) { @@ -39,14 +47,19 @@ export function applyInlineDecorationsToDeltas( const boundaries = new Set([segmentStart, segmentEnd]); for (const decoration of normalizedDecorations) { - if (decoration.to <= segmentStart || decoration.from >= segmentEnd) { + if ( + decoration.to <= segmentStart || + decoration.from >= segmentEnd + ) { continue; } boundaries.add(Math.max(decoration.from, segmentStart)); boundaries.add(Math.min(decoration.to, segmentEnd)); } - const sortedBoundaries = [...boundaries].sort((left, right) => left - right); + const sortedBoundaries = [...boundaries].sort( + (left, right) => left - right, + ); for (let index = 0; index < sortedBoundaries.length - 1; index += 1) { const from = sortedBoundaries[index]; const to = sortedBoundaries[index + 1]; @@ -64,7 +77,10 @@ export function applyInlineDecorationsToDeltas( from, to, ); - const attributes = mergeDeltaAttributes(delta.attributes, decorationAttributes); + const attributes = mergeDeltaAttributes( + delta.attributes, + decorationAttributes, + ); appendDelta(result, { insert: slice, ...(attributes ? { attributes } : {}), @@ -120,6 +136,8 @@ function appendDelta(target: TextDelta[], nextDelta: TextDelta): void { const previousDelta = target[target.length - 1]; if ( previousDelta && + typeof previousDelta.insert === "string" && + typeof nextDelta.insert === "string" && attributesEqual(previousDelta.attributes, nextDelta.attributes) ) { previousDelta.insert += nextDelta.insert; diff --git a/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts b/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts index 45e46ba..97fb8e8 100644 --- a/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts +++ b/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - createEditor, - getNumberedListItemValue, -} from "@pen/core"; +import { createEditor, getNumberedListItemValue } from "@pen/core"; import { FIELD_EDITOR_SLOT_KEY as CORE_FIELD_EDITOR_SLOT_KEY, INPUT_RULES_ENGINE_SLOT_KEY, @@ -45,9 +42,10 @@ function getYText( const adapter = editor.internals.adapter; const doc = editor.internals.crdtDoc; const ydoc = adapter.raw(doc); - const ytext = ydoc.getMap("blocks").get(blockId)?.get("content") as - | FieldEditorTextLike - | null; + const ytext = ydoc + .getMap("blocks") + .get(blockId) + ?.get("content") as FieldEditorTextLike | null; if (!ytext) { throw new Error(`Missing test Y.Text for block ${blockId}`); } @@ -272,7 +270,9 @@ describe("@pen/react field-editor commands", () => { editor.selectText(blockId, 0, 4); expect(toggleInlineMark(editor, "bold")).toBe(false); - expect(editor.getBlock(blockId)!.textDeltas()).toEqual([{ insert: "code" }]); + expect(editor.getBlock(blockId)!.textDeltas()).toEqual([ + { insert: "code" }, + ]); editor.destroy(); }); @@ -615,7 +615,9 @@ describe("resolveBackspaceAction – schema-aware Backspace", () => { const editor = createEditor(editorOpts()); const blockId = editor.firstBlock()!.id; - editor.apply([{ type: "convert-block", blockId, newType: "bulletListItem" }]); + editor.apply([ + { type: "convert-block", blockId, newType: "bulletListItem" }, + ]); const action = resolveBackspaceAction(editor, { blockId, @@ -632,7 +634,9 @@ describe("resolveBackspaceAction – schema-aware Backspace", () => { const editor = createEditor(editorOpts()); const blockId = editor.firstBlock()!.id; - editor.apply([{ type: "convert-block", blockId, newType: "blockquote" }]); + editor.apply([ + { type: "convert-block", blockId, newType: "blockquote" }, + ]); const action = resolveBackspaceAction(editor, { blockId, @@ -651,7 +655,12 @@ describe("resolveBackspaceAction – schema-aware Backspace", () => { const secondBlockId = crypto.randomUUID(); editor.apply([ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, { type: "insert-block", blockId: secondBlockId, @@ -681,7 +690,12 @@ describe("resolveBackspaceAction – schema-aware Backspace", () => { const toggleBlockId = crypto.randomUUID(); editor.apply([ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, { type: "insert-block", blockId: toggleBlockId, @@ -712,7 +726,12 @@ describe("resolveBackspaceAction – schema-aware Backspace", () => { const childBlockId = crypto.randomUUID(); editor.apply([ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, { type: "insert-block", blockId: toggleBlockId, @@ -780,6 +799,115 @@ describe("applyDeleteBehavior", () => { editor.destroy(); }); + + it("selects the previous inline node before deleting it with Backspace", () => { + const editor = createEditor(editorOpts()); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "A" }, + { + type: "insert-inline-node", + blockId, + offset: 1, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + { type: "insert-text", blockId, offset: 2, text: "B" }, + ]); + + const target = applyDeleteBehavior(editor, { + blockId, + ytext: getYText(editor, blockId), + range: { start: 2, end: 2 }, + direction: "backward", + }); + + expect(target).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 2, + }); + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ]); + + editor.destroy(); + }); + + it("selects the next inline node before deleting it with Delete", () => { + const editor = createEditor(editorOpts()); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "A" }, + { + type: "insert-inline-node", + blockId, + offset: 1, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + { type: "insert-text", blockId, offset: 2, text: "B" }, + ]); + + const target = applyDeleteBehavior(editor, { + blockId, + ytext: getYText(editor, blockId), + range: { start: 1, end: 1 }, + direction: "forward", + }); + + expect(target).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 2, + }); + + editor.destroy(); + }); + + it("deletes a selected inline node range", () => { + const editor = createEditor(editorOpts()); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "A" }, + { + type: "insert-inline-node", + blockId, + offset: 1, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + { type: "insert-text", blockId, offset: 2, text: "B" }, + ]); + + const target = applyDeleteBehavior(editor, { + blockId, + ytext: getYText(editor, blockId), + range: { start: 1, end: 2 }, + direction: "backward", + }); + + expect(target).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 1, + }); + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "AB" }, + ]); + + editor.destroy(); + }); }); describe("resolveEnterAction – schema-aware Enter", () => { @@ -1039,7 +1167,11 @@ describe("resolveEnterAction – schema-aware Enter", () => { const childBlockId = crypto.randomUUID(); editor.apply([ - { type: "convert-block", blockId: toggleBlockId, newType: "toggle" }, + { + type: "convert-block", + blockId: toggleBlockId, + newType: "toggle", + }, { type: "insert-block", blockId: childBlockId, @@ -1071,7 +1203,9 @@ describe("applyBackspaceBehavior – integration", () => { const editor = createEditor(editorOpts()); const blockId = editor.firstBlock()!.id; - editor.apply([{ type: "convert-block", blockId, newType: "bulletListItem" }]); + editor.apply([ + { type: "convert-block", blockId, newType: "bulletListItem" }, + ]); const target = applyBackspaceBehavior(editor, { blockId, @@ -1090,7 +1224,9 @@ describe("applyBackspaceBehavior – integration", () => { const editor = createEditor(editorOpts()); const blockId = editor.firstBlock()!.id; - editor.apply([{ type: "convert-block", blockId, newType: "blockquote" }]); + editor.apply([ + { type: "convert-block", blockId, newType: "blockquote" }, + ]); const target = applyBackspaceBehavior(editor, { blockId, @@ -1111,7 +1247,12 @@ describe("applyBackspaceBehavior – integration", () => { const toggleBlockId = crypto.randomUUID(); editor.apply([ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, { type: "insert-block", blockId: toggleBlockId, @@ -1143,7 +1284,11 @@ describe("applyListTabBehavior", () => { const secondBlockId = crypto.randomUUID(); editor.apply([ - { type: "convert-block", blockId: firstBlockId, newType: "bulletListItem" }, + { + type: "convert-block", + blockId: firstBlockId, + newType: "bulletListItem", + }, { type: "insert-block", blockId: secondBlockId, @@ -1151,7 +1296,12 @@ describe("applyListTabBehavior", () => { props: { indent: 0 }, position: { after: firstBlockId }, }, - { type: "insert-text", blockId: secondBlockId, offset: 0, text: "child" }, + { + type: "insert-text", + blockId: secondBlockId, + offset: 0, + text: "child", + }, ]); const target = applyListTabBehavior(editor, { @@ -1221,7 +1371,11 @@ describe("applyListTabBehavior", () => { const secondBlockId = crypto.randomUUID(); editor.apply([ - { type: "convert-block", blockId: firstBlockId, newType: "bulletListItem" }, + { + type: "convert-block", + blockId: firstBlockId, + newType: "bulletListItem", + }, { type: "insert-block", blockId: secondBlockId, @@ -1229,7 +1383,12 @@ describe("applyListTabBehavior", () => { props: { indent: 1 }, position: { after: firstBlockId }, }, - { type: "insert-text", blockId: secondBlockId, offset: 0, text: "child" }, + { + type: "insert-text", + blockId: secondBlockId, + offset: 0, + text: "child", + }, ]); const target = applyListTabBehavior(editor, { diff --git a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx new file mode 100644 index 0000000..e4d0b80 --- /dev/null +++ b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { + domPointToOffset, + domSelectionToEditor, + editorSelectionToDOM, +} from "../field-editor/selectionBridge"; +import { Pen } from "../primitives/index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +function createPresetEditor() { + return createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +function seedInlineAtomDocument(editor: ReturnType) { + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "A" }, + { + type: "insert-inline-node", + blockId, + offset: 1, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + { type: "insert-text", blockId, offset: 2, text: "B" }, + ]); + return blockId; +} + +describe("Pen inline atom editing", () => { + it("renders inline nodes as logical atom elements", async () => { + const editor = createPresetEditor(); + seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + await flushAnimationFrames(2); + }); + + const atom = container.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + + expect(atom).not.toBeNull(); + expect(atom?.getAttribute(DATA_ATTRS.inlineAtomType)).toBe( + "mention", + ); + expect(atom?.textContent).toBe("@Ada"); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("round-trips DOM selection offsets around inline atoms", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + await flushAnimationFrames(2); + }); + + const rootElement = container.querySelector( + `[${DATA_ATTRS.editorRoot}]`, + ) as HTMLElement | null; + const inlineElement = container.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + expect(domPointToOffset(inlineElement!, inlineElement!, 1)).toBe(1); + expect(domPointToOffset(inlineElement!, inlineElement!, 2)).toBe(2); + + editorSelectionToDOM( + rootElement!, + { blockId, offset: 2 }, + { blockId, offset: 2 }, + ); + + expect(domSelectionToEditor(rootElement!)).toEqual({ + anchor: { blockId, offset: 2 }, + focus: { blockId, offset: 2 }, + }); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); +}); diff --git a/packages/rendering/react/src/hooks/useBlockTextSnapshot.ts b/packages/rendering/react/src/hooks/useBlockTextSnapshot.ts index 83c8612..001254a 100644 --- a/packages/rendering/react/src/hooks/useBlockTextSnapshot.ts +++ b/packages/rendering/react/src/hooks/useBlockTextSnapshot.ts @@ -2,7 +2,7 @@ import { useRef, useSyncExternalStore } from "react"; import type { Editor } from "@pen/types"; interface BlockTextDelta { - insert: string; + insert: string | Record; attributes?: Readonly>; } @@ -57,7 +57,13 @@ function getBlockTextSnapshot( return { exists: true, text: block.textContent(), - deltas: block.textDeltas(), + deltas: block.inlineDeltas().map((delta) => ({ + insert: + typeof delta.insert === "string" + ? delta.insert + : { type: delta.insert.type, ...delta.insert.props }, + ...(delta.attributes ? { attributes: delta.attributes } : {}), + })), }; } @@ -91,12 +97,23 @@ function blockTextDeltaEqual( left: BlockTextDelta, right: BlockTextDelta, ): boolean { - if (left.insert !== right.insert) { + if (!blockTextDeltaInsertEqual(left.insert, right.insert)) { return false; } return shallowEqualAttributes(left.attributes, right.attributes); } +function blockTextDeltaInsertEqual( + left: string | Record, + right: string | Record, +): boolean { + if (typeof left === "string" || typeof right === "string") { + return left === right; + } + + return shallowEqualAttributes(left, right); +} + function shallowEqualAttributes( left: Readonly> | undefined, right: Readonly> | undefined, diff --git a/packages/rendering/react/src/primitives/editor/content.tsx b/packages/rendering/react/src/primitives/editor/content.tsx index 07c8710..f60ff9a 100644 --- a/packages/rendering/react/src/primitives/editor/content.tsx +++ b/packages/rendering/react/src/primitives/editor/content.tsx @@ -941,7 +941,7 @@ export function EditorContent(props: EditorContentProps) { const startedFromFallbackBlock = getEditorBlockSelectionRole(editor, gesture.blockId) !== - "editable-inline" && + "editable-inline" && shouldFallbackMixedSelectionToBlock( editor.documentProfile, getEditorFlowCapability(editor, gesture.blockId), @@ -1174,7 +1174,7 @@ export function EditorContent(props: EditorContentProps) { moved && gesture.blockId !== blockId && getEditorBlockSelectionRole(editor, gesture.blockId) !== - "editable-inline" && + "editable-inline" && shouldFallbackMixedSelectionToBlock( editor.documentProfile, getEditorFlowCapability(editor, gesture.blockId), diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 2f6ac46..36e813d 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -200,14 +200,49 @@ function resolveSchemaPlaceholder( return editor.schema.resolve(block.type)?.placeholder; } -function getDeltaText(deltas: readonly { insert: string }[]): string { - return deltas.map((delta) => delta.insert).join(""); +function getDeltaText( + deltas: readonly { insert: string | Record }[], +): string { + return deltas + .map((delta) => + typeof delta.insert === "string" + ? delta.insert + : getInlineNodeText(delta.insert), + ) + .join(""); } function getDeltaSignature( - deltas: readonly { attributes?: Record; insert: string }[], + deltas: readonly { + attributes?: Record; + insert: string | Record; + }[], ): string { return JSON.stringify( deltas.map((delta) => [delta.insert, delta.attributes ?? null]), ); } + +function getInlineNodeText(insert: Record): string { + const props = + insert.props && typeof insert.props === "object" + ? (insert.props as Record) + : insert; + + const label = props.label; + if (typeof label === "string" && label.length > 0) { + return label; + } + + const name = props.name; + if (typeof name === "string" && name.length > 0) { + return name; + } + + const id = props.id; + if (typeof id === "string" && id.length > 0) { + return id; + } + + return typeof insert.type === "string" ? insert.type : ""; +} diff --git a/packages/rendering/react/src/utils/dataAttributes.ts b/packages/rendering/react/src/utils/dataAttributes.ts index 846a553..da4ad39 100644 --- a/packages/rendering/react/src/utils/dataAttributes.ts +++ b/packages/rendering/react/src/utils/dataAttributes.ts @@ -21,6 +21,8 @@ export const DATA_ATTRS = { viewId: "data-pen-view-id", editorBlock: "data-pen-editor-block", inlineContent: "data-pen-inline-content", + inlineAtom: "data-pen-inline-atom", + inlineAtomType: "data-pen-inline-atom-type", fieldEditorSurface: "data-pen-field-editor-surface", fieldEditorActiveSurface: "data-pen-field-editor-active-surface", fieldEditor: "data-pen-field-editor", diff --git a/packages/rendering/react/src/utils/inlineDecorations.ts b/packages/rendering/react/src/utils/inlineDecorations.ts index 5c3fa2c..b5bfa3b 100644 --- a/packages/rendering/react/src/utils/inlineDecorations.ts +++ b/packages/rendering/react/src/utils/inlineDecorations.ts @@ -3,7 +3,7 @@ import type { InlineDecoration } from "@pen/types"; const INLINE_DECORATION_ATTRIBUTE_KEY = "__penInlineDecoration"; interface TextDelta { - insert: string; + insert: string | Record; attributes?: Readonly>; } @@ -18,7 +18,9 @@ export function applyInlineDecorationsToDeltas( const normalizedDecorations = decorations .filter((decoration) => decoration.to > decoration.from) .sort((left, right) => - left.from === right.from ? left.to - right.to : left.from - right.from, + left.from === right.from + ? left.to - right.to + : left.from - right.from, ); if (normalizedDecorations.length === 0) { return [...deltas]; @@ -28,6 +30,12 @@ export function applyInlineDecorationsToDeltas( let offset = 0; for (const delta of deltas) { + if (typeof delta.insert !== "string") { + result.push({ ...delta }); + offset += 1; + continue; + } + const text = delta.insert; const textLength = text.length; if (textLength === 0) { @@ -39,14 +47,19 @@ export function applyInlineDecorationsToDeltas( const boundaries = new Set([segmentStart, segmentEnd]); for (const decoration of normalizedDecorations) { - if (decoration.to <= segmentStart || decoration.from >= segmentEnd) { + if ( + decoration.to <= segmentStart || + decoration.from >= segmentEnd + ) { continue; } boundaries.add(Math.max(decoration.from, segmentStart)); boundaries.add(Math.min(decoration.to, segmentEnd)); } - const sortedBoundaries = [...boundaries].sort((left, right) => left - right); + const sortedBoundaries = [...boundaries].sort( + (left, right) => left - right, + ); for (let index = 0; index < sortedBoundaries.length - 1; index += 1) { const from = sortedBoundaries[index]; const to = sortedBoundaries[index + 1]; @@ -64,7 +77,10 @@ export function applyInlineDecorationsToDeltas( from, to, ); - const attributes = mergeDeltaAttributes(delta.attributes, decorationAttributes); + const attributes = mergeDeltaAttributes( + delta.attributes, + decorationAttributes, + ); appendDelta(result, { insert: slice, ...(attributes ? { attributes } : {}), @@ -120,6 +136,8 @@ function appendDelta(target: TextDelta[], nextDelta: TextDelta): void { const previousDelta = target[target.length - 1]; if ( previousDelta && + typeof previousDelta.insert === "string" && + typeof nextDelta.insert === "string" && attributesEqual(previousDelta.attributes, nextDelta.attributes) ) { previousDelta.insert += nextDelta.insert; From e33d5ff1f87788a9bb173e84d277da95b93f6162 Mon Sep 17 00:00:00 2001 From: krijn Date: Thu, 14 May 2026 20:44:17 +0200 Subject: [PATCH 12/20] Enhance Slash Menu and Suggestion Menu Integration - Introduced new Suggestion Menu components and hooks for improved user interaction. - Refactored Slash Menu to support better keyboard navigation and selection handling. - Updated clipboard serialization to ensure consistent text handling. - Improved code readability and maintainability through various refactorings across components and hooks. --- .../dom/src/field-editor/commands.ts | 2 +- .../dom/src/field-editor/transferBlocks.ts | 64 +- .../dom/src/utils/clipboardSerialization.ts | 2 +- .../react/src/__tests__/slashMenu.test.tsx | 175 +++++- .../src/__tests__/suggestionMenu.test.tsx | 547 ++++++++++++++++++ packages/rendering/react/src/hooks/index.ts | 31 +- .../rendering/react/src/hooks/useSlashMenu.ts | 124 ++-- .../react/src/hooks/useSuggestionMenu.ts | 410 +++++++++++++ packages/rendering/react/src/index.ts | 31 + .../rendering/react/src/primitives/index.ts | 24 + .../src/primitives/slash-menu/content.tsx | 185 ++---- .../react/src/primitives/slash-menu/root.tsx | 54 +- .../primitives/suggestion-menu/content.tsx | 176 ++++++ .../src/primitives/suggestion-menu/empty.tsx | 19 + .../src/primitives/suggestion-menu/group.tsx | 30 + .../src/primitives/suggestion-menu/index.ts | 14 + .../src/primitives/suggestion-menu/item.tsx | 47 ++ .../src/primitives/suggestion-menu/list.tsx | 13 + .../src/primitives/suggestion-menu/root.tsx | 236 ++++++++ .../react/src/utils/clipboardSerialization.ts | 2 +- .../rendering/react/src/utils/menuPosition.ts | 183 ++++++ 21 files changed, 2127 insertions(+), 242 deletions(-) create mode 100644 packages/rendering/react/src/__tests__/suggestionMenu.test.tsx create mode 100644 packages/rendering/react/src/hooks/useSuggestionMenu.ts create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/content.tsx create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/empty.tsx create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/group.tsx create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/index.ts create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/item.tsx create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/list.tsx create mode 100644 packages/rendering/react/src/primitives/suggestion-menu/root.tsx create mode 100644 packages/rendering/react/src/utils/menuPosition.ts diff --git a/packages/rendering/dom/src/field-editor/commands.ts b/packages/rendering/dom/src/field-editor/commands.ts index d92d978..f59f57e 100644 --- a/packages/rendering/dom/src/field-editor/commands.ts +++ b/packages/rendering/dom/src/field-editor/commands.ts @@ -283,7 +283,7 @@ export function applyListTabBehavior( const sharesParent = previousBlockId !== null && editor.documentState.parentOf(previousBlockId) === - editor.documentState.parentOf(blockId); + editor.documentState.parentOf(blockId); if ( isListBlock(previousBlock) && diff --git a/packages/rendering/dom/src/field-editor/transferBlocks.ts b/packages/rendering/dom/src/field-editor/transferBlocks.ts index 31a24b4..db9abcd 100644 --- a/packages/rendering/dom/src/field-editor/transferBlocks.ts +++ b/packages/rendering/dom/src/field-editor/transferBlocks.ts @@ -55,34 +55,34 @@ export function pasteBlocks( const insertBlockOp = previousBlockId ? ({ - type: "insert-block", - blockId, - blockType: block.type!, - props: block.props ?? {}, - position: { after: previousBlockId } as Position, - } as DocumentOp) + type: "insert-block", + blockId, + blockType: block.type!, + props: block.props ?? {}, + position: { after: previousBlockId } as Position, + } as DocumentOp) : cursor ? shouldReplaceEmpty ? ({ - type: "insert-block", - blockId, - blockType: block.type!, - props: block.props ?? {}, - position: { before: cursor.blockId } as Position, - } as DocumentOp) - : getInsertSiblingBlockOp(editor, { - siblingBlockId: cursor.blockId, - blockId, - blockType: block.type!, - props: block.props ?? {}, - }) - : ({ type: "insert-block", blockId, blockType: block.type!, props: block.props ?? {}, - position: "last" as Position, - } as DocumentOp); + position: { before: cursor.blockId } as Position, + } as DocumentOp) + : getInsertSiblingBlockOp(editor, { + siblingBlockId: cursor.blockId, + blockId, + blockType: block.type!, + props: block.props ?? {}, + }) + : ({ + type: "insert-block", + blockId, + blockType: block.type!, + props: block.props ?? {}, + position: "last" as Position, + } as DocumentOp); ops.push(insertBlockOp); @@ -179,18 +179,18 @@ export function pasteInlineText( ops.push({ ...(previousBlockId === blockId ? getInsertSiblingBlockOp(editor, { - siblingBlockId: previousBlockId, - blockId: newId, - blockType, - props: {}, - }) + siblingBlockId: previousBlockId, + blockId: newId, + blockType, + props: {}, + }) : { - type: "insert-block", - blockId: newId, - blockType, - props: {}, - position: { after: previousBlockId }, - }), + type: "insert-block", + blockId: newId, + blockType, + props: {}, + position: { after: previousBlockId }, + }), }); if (lineText) { diff --git a/packages/rendering/dom/src/utils/clipboardSerialization.ts b/packages/rendering/dom/src/utils/clipboardSerialization.ts index 8843e63..c9403ca 100644 --- a/packages/rendering/dom/src/utils/clipboardSerialization.ts +++ b/packages/rendering/dom/src/utils/clipboardSerialization.ts @@ -44,7 +44,7 @@ export function writePenClipboard( }), ]) .catch(() => { - navigator.clipboard.writeText(plainText).catch(() => {}); + navigator.clipboard.writeText(plainText).catch(() => { }); }); } diff --git a/packages/rendering/react/src/__tests__/slashMenu.test.tsx b/packages/rendering/react/src/__tests__/slashMenu.test.tsx index 448d3d8..ecae0ee 100644 --- a/packages/rendering/react/src/__tests__/slashMenu.test.tsx +++ b/packages/rendering/react/src/__tests__/slashMenu.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import React, { act } from "react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; import { createEditor } from "@pen/core"; import { defaultPreset } from "@pen/preset-default"; @@ -34,7 +34,144 @@ function createSlashMenuEditor( }); } +function dispatchKey(key: string, target: EventTarget = document) { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }), + ); +} + describe("@pen/react slash menu", () => { + it("handles navigation keys without transform-based placement or downstream propagation", async () => { + const editor = createSlashMenuEditor(); + const blockId = editor.firstBlock()!.id; + editor.selectText(blockId, 0, 0); + const confirm = vi.fn(); + const select = vi.fn(); + + const controller = { + confirm, + dismiss: vi.fn(), + items: [ + { type: "paragraph", display: { title: "Paragraph" } }, + { type: "heading", display: { title: "Heading" } }, + ], + open: true, + query: "", + select, + selectedIndex: 0, + setQuery: vi.fn(), + }; + + function Harness() { + return ( + + + + + + + Paragraph + + + Heading + + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + const slashContent = container.querySelector( + "[data-pen-slash-menu-content]", + ); + expect(slashContent).not.toBeNull(); + expect(slashContent?.style.transform).toBe(""); + + const downstreamKeyDown = vi.fn(); + container.addEventListener("keydown", downstreamKeyDown); + await act(async () => { + dispatchKey("ArrowDown", container); + dispatchKey("Enter", container); + }); + + expect(select).toHaveBeenCalledWith(1); + expect(confirm).toHaveBeenCalledWith(1); + expect(downstreamKeyDown).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("does not notify controlled open changes when confirm has no selected item", async () => { + const editor = createSlashMenuEditor(); + const blockId = editor.firstBlock()!.id; + editor.selectText(blockId, 0, 0); + const onOpenChange = vi.fn(); + + const controller = { + confirm: vi.fn(() => false), + dismiss: vi.fn(), + items: [], + open: true, + query: "", + select: vi.fn(), + selectedIndex: 0, + setQuery: vi.fn(), + target: null, + }; + + function Harness() { + return ( + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await act(async () => { + dispatchKey("Enter", container); + }); + + expect(controller.confirm).toHaveBeenCalledWith(0); + expect(onOpenChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + it("opens after selection sync when slash text commits before a text selection exists", async () => { const editor = createSlashMenuEditor(); const blockId = editor.firstBlock()!.id; @@ -62,7 +199,9 @@ describe("@pen/react slash menu", () => { }); await act(async () => { - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "/" }]); + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "/" }, + ]); }); const slashMenuBeforeSelection = slashMenuRef.current; @@ -122,6 +261,8 @@ describe("@pen/react slash menu", () => { }); expect(editor.getBlock(blockId)?.type).toBe("table"); + expect(slashMenuRef.current?.open).toBe(false); + expect(slashMenuRef.current?.target).toBeNull(); expect(editor.getBlock(blockId)?.props.hasHeaderRow).toBe(true); expect(editor.getBlock(blockId)?.tableRowCount()).toBe(2); expect(editor.getBlock(blockId)?.tableColumnCount()).toBe(2); @@ -174,11 +315,9 @@ describe("@pen/react slash menu", () => { await flushAnimationFrames(); }); - const fieldEditor = getAttachedFieldEditor(editor) as - | { - activateCell(blockId: string, row: number, col: number): void; - } - | null; + const fieldEditor = getAttachedFieldEditor(editor) as { + activateCell(blockId: string, row: number, col: number): void; + } | null; await act(async () => { fieldEditor?.activateCell(blockId, 0, 0); await flushAnimationFrames(); @@ -213,7 +352,9 @@ describe("@pen/react slash menu", () => { await flushAnimationFrames(); }); - expect(editor.getBlock(blockId)?.tableCell(0, 0)?.textContent()).toBe("AB"); + expect(editor.getBlock(blockId)?.tableCell(0, 0)?.textContent()).toBe( + "AB", + ); await act(async () => { root.unmount(); @@ -282,7 +423,12 @@ describe("@pen/react slash menu", () => { newType: "toggle", newProps: { open: true }, }, - { type: "insert-text", blockId: toggleBlockId, offset: 0, text: "Parent" }, + { + type: "insert-text", + blockId: toggleBlockId, + offset: 0, + text: "Parent", + }, { type: "insert-block", blockId: nestedToggleId, @@ -290,7 +436,12 @@ describe("@pen/react slash menu", () => { props: { open: true }, position: { after: toggleBlockId }, }, - { type: "insert-text", blockId: nestedToggleId, offset: 0, text: "Nested" }, + { + type: "insert-text", + blockId: nestedToggleId, + offset: 0, + text: "Nested", + }, { type: "update-block", blockId: nestedToggleId, @@ -355,7 +506,9 @@ describe("@pen/react slash menu", () => { const insertedBlockId = insertedBlockIds[0]!; expect(editor.getBlock(insertedBlockId)?.type).toBe("heading"); - expect(editor.documentState.parentOf(insertedBlockId)).toBe(toggleBlockId); + expect(editor.documentState.parentOf(insertedBlockId)).toBe( + toggleBlockId, + ); expect(editor.documentState.blockOrder).toEqual([ toggleBlockId, nestedToggleId, diff --git a/packages/rendering/react/src/__tests__/suggestionMenu.test.tsx b/packages/rendering/react/src/__tests__/suggestionMenu.test.tsx new file mode 100644 index 0000000..4ad1c12 --- /dev/null +++ b/packages/rendering/react/src/__tests__/suggestionMenu.test.tsx @@ -0,0 +1,547 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { createEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import { + useSuggestionMenu, + type SuggestionMenuController, +} from "../hooks/useSuggestionMenu"; +import { Pen } from "../primitives/index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function createSuggestionMenuEditor() { + return createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +function dispatchKey(key: string, target: EventTarget = document) { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }), + ); +} + +function createRect( + left: number, + top: number, + width: number, + height: number, +): DOMRect { + return { + x: left, + y: top, + left, + top, + right: left + width, + bottom: top + height, + width, + height, + toJSON() { + return {}; + }, + } as DOMRect; +} + +function requireMenu( + menu: SuggestionMenuController | null, +): SuggestionMenuController { + if (!menu) { + throw new Error("Suggestion menu did not initialize"); + } + return menu; +} + +describe("@pen/react suggestion menu", () => { + it("opens from a typed trigger and confirms the selected item", async () => { + const editor = createSuggestionMenuEditor(); + const blockId = editor.firstBlock()!.id; + const selectedItems: string[] = []; + + function Harness() { + const menu = useSuggestionMenu({ + editor, + trigger: { + char: "@", + boundary: "whitespace", + minQueryLength: 1, + }, + getItems({ query }) { + return ["Alex", "Alice"].filter((item) => + item.toLowerCase().startsWith(query.toLowerCase()), + ); + }, + onSelect({ item, target }) { + selectedItems.push(item); + editor.apply([ + { + type: "delete-text", + blockId: target.blockId, + offset: target.startOffset, + length: target.endOffset - target.startOffset, + }, + { + type: "insert-text", + blockId: target.blockId, + offset: target.startOffset, + text: item, + }, + ]); + }, + }); + const menuItems = menu.items.map((item, index) => ( + + {item} + + )); + + return ( + + + + + + {menuItems} + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hi @al" }, + ]); + editor.selectText(blockId, 6, 6); + await waitForCondition( + () => + container.querySelector( + "[data-pen-suggestion-menu-content]", + ) !== null, + ); + }); + + expect( + container.querySelector("[data-pen-suggestion-menu-content]"), + ).not.toBeNull(); + const suggestionContent = container.querySelector( + "[data-pen-suggestion-menu-content]", + ); + expect(suggestionContent?.style.transform).toBe(""); + + const downstreamKeyDown = vi.fn(); + container.addEventListener("keydown", downstreamKeyDown); + await act(async () => { + dispatchKey("ArrowDown", container); + dispatchKey("Enter", container); + }); + + expect(downstreamKeyDown).not.toHaveBeenCalled(); + expect(selectedItems).toEqual(["Alice"]); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hi Alice"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("keeps controlled state open when selection vetoes dismissal", async () => { + const editor = createSuggestionMenuEditor(); + const blockId = editor.firstBlock()!.id; + const onOpenChange = vi.fn(); + const onSelect = vi.fn(() => false); + let menuSnapshot: SuggestionMenuController | null = null; + + function Harness() { + const menu = useSuggestionMenu({ + editor, + trigger: { + char: "@", + boundary: "whitespace", + minQueryLength: 1, + }, + getItems: () => ["Alex"], + onSelect, + }); + menuSnapshot = menu; + + return ( + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "@a" }, + ]); + editor.selectText(blockId, 2, 2); + await waitForCondition( + () => requireMenu(menuSnapshot).items.length === 1, + ); + }); + + await act(async () => { + dispatchKey("Enter", container); + }); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(requireMenu(menuSnapshot).open).toBe(true); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("dismisses an open menu when disabled", async () => { + const editor = createSuggestionMenuEditor(); + const blockId = editor.firstBlock()!.id; + let setEnabled: + | React.Dispatch> + | undefined; + let menuSnapshot: SuggestionMenuController | null = null; + + function Harness() { + const [enabled, setEnabledState] = React.useState(true); + setEnabled = setEnabledState; + const menu = useSuggestionMenu({ + editor, + enabled, + trigger: { + char: "@", + boundary: "whitespace", + minQueryLength: 1, + }, + getItems: () => ["Alex"], + onSelect: vi.fn(), + }); + menuSnapshot = menu; + + return ( + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "@a" }, + ]); + editor.selectText(blockId, 2, 2); + await waitForCondition( + () => requireMenu(menuSnapshot).items.length === 1, + ); + }); + + expect(requireMenu(menuSnapshot).open).toBe(true); + + await act(async () => { + setEnabled?.(false); + await waitForCondition(() => !requireMenu(menuSnapshot).open); + }); + + expect(requireMenu(menuSnapshot).open).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("anchors content to the trigger start instead of the caret", async () => { + const editor = createSuggestionMenuEditor(); + const blockId = editor.firstBlock()!.id; + const originalGetBoundingClientRect = + HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function () { + if (this.hasAttribute("data-pen-inline-content")) { + return createRect(144, 40, 220, 20); + } + if (this.hasAttribute("data-pen-suggestion-menu-content")) { + return createRect(0, 0, 200, 100); + } + return originalGetBoundingClientRect.call(this); + }; + + function Harness() { + const menu = useSuggestionMenu({ + editor, + trigger: { + char: "@", + boundary: "whitespace", + minQueryLength: 1, + }, + getItems: () => ["Alex"], + onSelect: vi.fn(), + }); + + return ( + + + + + + + Alex + + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hi @al" }, + ]); + editor.selectText(blockId, 6, 6); + root.render(); + await waitForCondition( + () => + container.querySelector( + "[data-pen-suggestion-menu-content]", + ) !== null, + ); + }); + + const suggestionContent = container.querySelector( + "[data-pen-suggestion-menu-content]", + ); + expect(suggestionContent?.style.left).toBe("144px"); + expect(suggestionContent?.style.top).toBe("70px"); + } finally { + HTMLElement.prototype.getBoundingClientRect = + originalGetBoundingClientRect; + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("ignores stale async results after the query changes", async () => { + const editor = createSuggestionMenuEditor(); + const blockId = editor.firstBlock()!.id; + const requests: Array<{ + query: string; + resolve: (items: readonly string[]) => void; + }> = []; + let menuSnapshot: SuggestionMenuController | null = null; + + function Harness() { + const menu = useSuggestionMenu({ + editor, + trigger: { + char: ":", + boundary: "whitespace", + closingChar: ":", + minQueryLength: 1, + queryPattern: /^[a-z]+$/, + }, + getItems({ query }) { + return new Promise((resolve) => { + requests.push({ query, resolve }); + }); + }, + onSelect: vi.fn(), + }); + menuSnapshot = menu; + + return ( + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: ":f" }, + ]); + editor.selectText(blockId, 2, 2); + }); + + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 2, text: "i" }, + ]); + editor.selectText(blockId, 3, 3); + }); + + await waitForCondition(() => + requests.some((request) => request.query === "fi"), + ); + const staleRequest = requests.find((request) => request.query === "f"); + const freshRequest = [...requests] + .reverse() + .find((request) => request.query === "fi"); + expect(staleRequest).toBeDefined(); + expect(freshRequest).toBeDefined(); + + await act(async () => { + staleRequest?.resolve(["fire"]); + await Promise.resolve(); + }); + + expect(requireMenu(menuSnapshot).items).toEqual([]); + + await act(async () => { + freshRequest?.resolve(["fire", "first-quarter-moon"]); + await waitForCondition( + () => requireMenu(menuSnapshot).items.length === 2, + ); + }); + + expect(requireMenu(menuSnapshot).query).toBe("fi"); + expect(requireMenu(menuSnapshot).items).toEqual([ + "fire", + "first-quarter-moon", + ]); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("dismisses instead of selecting when the target range is stale", async () => { + const editor = createSuggestionMenuEditor(); + const blockId = editor.firstBlock()!.id; + const onSelect = vi.fn(); + let menuSnapshot: SuggestionMenuController | null = null; + + function Harness() { + const menu = useSuggestionMenu({ + editor, + trigger: { + char: "@", + boundary: "whitespace", + minQueryLength: 1, + }, + getItems: () => ["Alex"], + onSelect, + }); + menuSnapshot = menu; + + return ( + + + + + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + await act(async () => { + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "@a" }, + ]); + editor.selectText(blockId, 2, 2); + await waitForCondition( + () => requireMenu(menuSnapshot).items.length === 1, + ); + }); + + await act(async () => { + editor.selectText(blockId, 0, 0); + }); + + expect(requireMenu(menuSnapshot).confirm()).toBe(false); + expect(onSelect).not.toHaveBeenCalled(); + expect(requireMenu(menuSnapshot).open).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); +}); diff --git a/packages/rendering/react/src/hooks/index.ts b/packages/rendering/react/src/hooks/index.ts index a8e068a..2119f85 100644 --- a/packages/rendering/react/src/hooks/index.ts +++ b/packages/rendering/react/src/hooks/index.ts @@ -53,11 +53,36 @@ export { } from "./useInlineSuggestionControls"; export { useSuggestMode } from "./useSuggestMode"; export { useToolbar } from "./useToolbar"; -export { useSelectionToolbar, type SelectionToolbarState } from "./useSelectionToolbar"; -export { useSlashMenu, type SlashMenuState, type SlashMenuActions } from "./useSlashMenu"; +export { + useSelectionToolbar, + type SelectionToolbarState, +} from "./useSelectionToolbar"; +export { + useSlashMenu, + type SlashMenuState, + type SlashMenuActions, + type SlashMenuTarget, +} from "./useSlashMenu"; +export { + useSuggestionMenu, + resolveSuggestionMenuTarget, + type SuggestionMenuActions, + type SuggestionMenuBoundary, + type SuggestionMenuController, + type SuggestionMenuGetItemsOptions, + type SuggestionMenuSelectOptions, + type SuggestionMenuState, + type SuggestionMenuStatus, + type SuggestionMenuTarget, + type SuggestionMenuTrigger, + type UseSuggestionMenuOptions, +} from "./useSuggestionMenu"; export { useBlockList } from "./useBlockList"; export { useBlockDragHandle, type BlockDragHandleHookResult, } from "./useBlockDragHandle"; -export { useVisualViewport, type VisualViewportState } from "./useVisualViewport"; +export { + useVisualViewport, + type VisualViewportState, +} from "./useVisualViewport"; diff --git a/packages/rendering/react/src/hooks/useSlashMenu.ts b/packages/rendering/react/src/hooks/useSlashMenu.ts index 9b1c3f2..a833bae 100644 --- a/packages/rendering/react/src/hooks/useSlashMenu.ts +++ b/packages/rendering/react/src/hooks/useSlashMenu.ts @@ -4,9 +4,7 @@ import { generateId } from "@pen/types"; import { getAttachedFieldEditor } from "../utils/fieldEditor"; import { getConvertBlockOps } from "../field-editor/commands"; import { getInsertSiblingBlockOp } from "../utils/parentIdTree"; -import { - shouldShowBlockInDefaultMenus, -} from "../utils/flowCapabilities"; +import { shouldShowBlockInDefaultMenus } from "../utils/flowCapabilities"; import { getStarterTableProps, getTableActivationTarget, @@ -18,12 +16,20 @@ export interface SlashMenuState { query: string; items: Array<{ type: string; display: BlockDisplay }>; selectedIndex: number; + target?: SlashMenuTarget | null; +} + +export interface SlashMenuTarget { + blockId: string; + startOffset: number; + endOffset: number; + query: string; } export interface SlashMenuActions { setQuery: (q: string) => void; select: (index: number) => void; - confirm: (index?: number) => void; + confirm: (index?: number) => boolean; dismiss: () => void; } @@ -35,6 +41,7 @@ export function useSlashMenu( query: "", items: [], selectedIndex: 0, + target: null, }); const editorRef = useRef(editor); editorRef.current = editor; @@ -45,11 +52,17 @@ export function useSlashMenu( useEffect(() => { const syncSlashMenu = () => { - const query = getSlashQuery(editorRef.current); - if (query == null) { + const target = getSlashTarget(editorRef.current); + if (!target) { setState((prev) => prev.open - ? { open: false, query: "", items: [], selectedIndex: 0 } + ? { + open: false, + query: "", + items: [], + selectedIndex: 0, + target: null, + } : prev, ); return; @@ -57,17 +70,18 @@ export function useSlashMenu( const items = filterItems( allDisplaysRef.current, - query, + target.query, editorRef.current, ); setState((prev) => ({ open: true, - query, + query: target.query, items, selectedIndex: items.length === 0 ? 0 : Math.min(prev.selectedIndex, items.length - 1), + target, })); }; @@ -87,6 +101,13 @@ export function useSlashMenu( query, items: filtered, selectedIndex: 0, + target: prev.target + ? { + ...prev.target, + query, + endOffset: prev.target.startOffset + 1 + query.length, + } + : prev.target, })); }; @@ -97,10 +118,10 @@ export function useSlashMenu( })); }; - const confirm = (index?: number) => { + const confirm = (index?: number): boolean => { const itemIndex = index ?? state.selectedIndex; const item = state.items[itemIndex]; - if (!item) return; + if (!item) return false; const ed = editorRef.current; const selection = ed.selection; @@ -120,7 +141,9 @@ export function useSlashMenu( const tableActivationTarget = isTableInsert ? getTableActivationTarget(undefined) : null; - const tableProps = isTableInsert ? getStarterTableProps() : undefined; + const tableProps = isTableInsert + ? getStarterTableProps() + : undefined; if (isEmptyOrSlash) { const ops = []; @@ -143,28 +166,40 @@ export function useSlashMenu( insertedOrConvertedBlockId = blockId; } if (ops.length > 0) { - ed.apply(ops, { origin: "user" }); + ed.apply(ops, { origin: "user", undoGroup: true }); } } else { const newBlockId = generateId(); - ed.apply([ - getInsertSiblingBlockOp(ed, { - siblingBlockId: blockId, - blockId: newBlockId, - blockType: item.type, - props: tableProps ?? {}, - }), - ]); + ed.apply( + [ + getInsertSiblingBlockOp(ed, { + siblingBlockId: blockId, + blockId: newBlockId, + blockType: item.type, + props: tableProps ?? {}, + }), + ], + { origin: "user", undoGroup: true }, + ); insertedOrConvertedBlockId = newBlockId; } - if (isTableInsert && insertedOrConvertedBlockId && tableActivationTarget) { + if ( + isTableInsert && + insertedOrConvertedBlockId && + tableActivationTarget + ) { const defaultCols = createDefaultTableColumns(2); - ed.apply([{ - type: "update-table-columns", - blockId: insertedOrConvertedBlockId, - columns: defaultCols, - }]); + ed.apply( + [ + { + type: "update-table-columns", + blockId: insertedOrConvertedBlockId, + columns: defaultCols, + }, + ], + { origin: "user", undoGroup: true }, + ); const fieldEditor = getAttachedFieldEditor(ed); const activateStarterTable = () => { fieldEditor?.activateCell?.( @@ -179,26 +214,21 @@ export function useSlashMenu( activateStarterTable(); } } - } } - setState({ - open: false, - query: "", - items: [], - selectedIndex: itemIndex, - }); + setState(getClosedSlashMenuState()); + return true; }; const dismiss = () => { - setState({ open: false, query: "", items: [], selectedIndex: 0 }); + setState(getClosedSlashMenuState()); }; return { ...state, setQuery, select, confirm, dismiss }; } -function getSlashQuery(editor: Editor): string | null { +function getSlashTarget(editor: Editor): SlashMenuTarget | null { const selection = editor.selection; if (!selection || selection.type !== "text" || !selection.isCollapsed) { return null; @@ -214,7 +244,22 @@ function getSlashQuery(editor: Editor): string | null { return null; } - return text.slice(1); + return { + blockId: selection.anchor.blockId, + startOffset: 0, + endOffset: selection.focus.offset, + query: text.slice(1, selection.focus.offset), + }; +} + +function getClosedSlashMenuState(): SlashMenuState { + return { + open: false, + query: "", + items: [], + selectedIndex: 0, + target: null, + }; } function filterItems( @@ -229,7 +274,10 @@ function filterItems( ); if (!query) { - return visibleDisplays.map((d) => ({ type: d.type, display: d.display })); + return visibleDisplays.map((d) => ({ + type: d.type, + display: d.display, + })); } const lower = query.toLowerCase(); diff --git a/packages/rendering/react/src/hooks/useSuggestionMenu.ts b/packages/rendering/react/src/hooks/useSuggestionMenu.ts new file mode 100644 index 0000000..8480cc2 --- /dev/null +++ b/packages/rendering/react/src/hooks/useSuggestionMenu.ts @@ -0,0 +1,410 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { Editor } from "@pen/types"; + +export type SuggestionMenuStatus = "idle" | "loading" | "ready" | "error"; + +export type SuggestionMenuBoundary = "any" | "whitespace"; + +export interface SuggestionMenuTrigger { + char: string; + minQueryLength?: number; + maxQueryLength?: number; + lookbehind?: number; + allowSpaces?: boolean; + boundary?: SuggestionMenuBoundary; + closingChar?: string; + queryPattern?: RegExp; +} + +export interface SuggestionMenuTarget { + blockId: string; + startOffset: number; + endOffset: number; + query: string; + trigger: string; +} + +export interface SuggestionMenuGetItemsOptions { + editor: Editor; + query: string; + signal: AbortSignal | null; + target: SuggestionMenuTarget; +} + +export interface SuggestionMenuSelectOptions { + editor: Editor; + index: number; + item: TItem; + target: SuggestionMenuTarget; +} + +export interface UseSuggestionMenuOptions { + editor: Editor; + trigger: SuggestionMenuTrigger; + getItems: ( + options: SuggestionMenuGetItemsOptions, + ) => readonly TItem[] | Promise; + onSelect: (options: SuggestionMenuSelectOptions) => boolean | void; + enabled?: boolean; +} + +export interface SuggestionMenuState { + open: boolean; + query: string; + items: readonly TItem[]; + selectedIndex: number; + status: SuggestionMenuStatus; + target: SuggestionMenuTarget | null; + error: unknown; +} + +export interface SuggestionMenuActions { + select: (index: number) => void; + confirm: (index?: number) => boolean; + dismiss: () => void; + refresh: () => void; +} + +export type SuggestionMenuController = SuggestionMenuState & + SuggestionMenuActions; + +const DEFAULT_LOOKBEHIND = 80; + +export function useSuggestionMenu( + options: UseSuggestionMenuOptions, +): SuggestionMenuController { + const { editor } = options; + const trigger = options.trigger; + const triggerQueryPatternKey = trigger.queryPattern + ? `${trigger.queryPattern.source}/${trigger.queryPattern.flags}` + : undefined; + const optionsRef = useRef(options); + optionsRef.current = options; + + const requestRef = useRef<{ + abortController: AbortController | null; + id: number; + }>({ + abortController: null, + id: 0, + }); + const [state, setState] = useState>({ + open: false, + query: "", + items: [], + selectedIndex: 0, + status: "idle", + target: null, + error: null, + }); + const stateRef = useRef(state); + stateRef.current = state; + const didRunConfigEffectRef = useRef(false); + + const dismiss = useCallback(() => { + requestRef.current.id += 1; + requestRef.current.abortController?.abort(); + requestRef.current.abortController = null; + setState((previous) => { + if (!previous.open && previous.status === "idle") { + return previous; + } + return { + open: false, + query: "", + items: [], + selectedIndex: 0, + status: "idle", + target: null, + error: null, + }; + }); + }, []); + + const refresh = useCallback(() => { + const currentOptions = optionsRef.current; + if (currentOptions.enabled === false) { + dismiss(); + return; + } + + const target = resolveSuggestionMenuTarget( + currentOptions.editor, + currentOptions.trigger, + ); + if (!target) { + dismiss(); + return; + } + + const abortController = + typeof AbortController === "undefined" + ? null + : new AbortController(); + const requestId = requestRef.current.id + 1; + requestRef.current.id = requestId; + requestRef.current.abortController?.abort(); + requestRef.current.abortController = abortController; + + setState((previous) => ({ + open: true, + query: target.query, + items: [], + selectedIndex: areSuggestionTargetsEqual(previous.target, target) + ? previous.selectedIndex + : 0, + status: "loading", + target, + error: null, + })); + + void Promise.resolve( + currentOptions.getItems({ + editor: currentOptions.editor, + query: target.query, + signal: abortController?.signal ?? null, + target, + }), + ) + .then((items) => { + if (requestRef.current.id !== requestId) { + return; + } + if (abortController?.signal.aborted) { + return; + } + const currentTarget = resolveSuggestionMenuTarget( + currentOptions.editor, + currentOptions.trigger, + ); + if (!areSuggestionTargetsEqual(currentTarget, target)) { + return; + } + + setState((previous) => ({ + open: true, + query: target.query, + items, + selectedIndex: + items.length === 0 + ? 0 + : Math.min( + previous.selectedIndex, + items.length - 1, + ), + status: "ready", + target, + error: null, + })); + }) + .catch((error: unknown) => { + if (requestRef.current.id !== requestId) { + return; + } + if (isAbortError(error) || abortController?.signal.aborted) { + return; + } + setState({ + open: true, + query: target.query, + items: [], + selectedIndex: 0, + status: "error", + target, + error, + }); + }); + }, [dismiss]); + + useLayoutEffect(() => { + optionsRef.current = options; + }); + + useEffect(() => { + refresh(); + const unsubscribeDocument = editor.onDocumentCommit(refresh); + const unsubscribeSelection = editor.onSelectionChange(refresh); + return () => { + unsubscribeDocument(); + unsubscribeSelection(); + requestRef.current.id += 1; + requestRef.current.abortController?.abort(); + requestRef.current.abortController = null; + }; + }, [editor, refresh]); + + useEffect(() => { + if (!didRunConfigEffectRef.current) { + didRunConfigEffectRef.current = true; + return; + } + if (options.enabled === false) { + dismiss(); + return; + } + refresh(); + }, [ + dismiss, + options.enabled, + trigger.allowSpaces, + trigger.boundary, + trigger.char, + trigger.closingChar, + trigger.lookbehind, + trigger.maxQueryLength, + trigger.minQueryLength, + triggerQueryPatternKey, + refresh, + ]); + + const select = useCallback((index: number) => { + setState((previous) => { + if (previous.items.length === 0) { + return previous; + } + return { + ...previous, + selectedIndex: Math.max( + 0, + Math.min(index, previous.items.length - 1), + ), + }; + }); + }, []); + + const confirm = useCallback( + (index?: number): boolean => { + const currentState = stateRef.current; + const itemIndex = index ?? currentState.selectedIndex; + const item = currentState.items[itemIndex]; + if (!item || !currentState.target) { + return false; + } + + const currentOptions = optionsRef.current; + const currentTarget = resolveSuggestionMenuTarget( + currentOptions.editor, + currentOptions.trigger, + ); + if ( + !areSuggestionTargetsEqual(currentTarget, currentState.target) + ) { + dismiss(); + return false; + } + + const result = currentOptions.onSelect({ + editor: currentOptions.editor, + index: itemIndex, + item, + target: currentState.target, + }); + if (result !== false) { + dismiss(); + return true; + } + return false; + }, + [dismiss], + ); + + return { + ...state, + select, + confirm, + dismiss, + refresh, + }; +} + +export function resolveSuggestionMenuTarget( + editor: Editor, + trigger: SuggestionMenuTrigger, +): SuggestionMenuTarget | null { + if (trigger.char.length === 0) { + return null; + } + + const selection = editor.selection; + if (selection?.type !== "text" || !selection.isCollapsed) { + return null; + } + if (selection.anchor.blockId !== selection.focus.blockId) { + return null; + } + + const block = editor.getBlock(selection.focus.blockId); + if (!block) { + return null; + } + + const offset = selection.focus.offset; + const lookbehind = trigger.lookbehind ?? DEFAULT_LOOKBEHIND; + const prefixStartOffset = Math.max(0, offset - lookbehind); + const textBefore = block.textContent().slice(prefixStartOffset, offset); + const triggerIndex = textBefore.lastIndexOf(trigger.char); + if (triggerIndex < 0) { + return null; + } + + if (trigger.boundary === "whitespace") { + const previousChar = textBefore[triggerIndex - 1]; + if (previousChar && !/\s/.test(previousChar)) { + return null; + } + } + + const query = textBefore.slice(triggerIndex + trigger.char.length); + if (!trigger.allowSpaces && /\s/.test(query)) { + return null; + } + if (trigger.closingChar && query.includes(trigger.closingChar)) { + return null; + } + if (query.length < (trigger.minQueryLength ?? 0)) { + return null; + } + if ( + trigger.maxQueryLength !== undefined && + query.length > trigger.maxQueryLength + ) { + return null; + } + if (trigger.queryPattern) { + trigger.queryPattern.lastIndex = 0; + if (!trigger.queryPattern.test(query)) { + return null; + } + } + + return { + blockId: selection.focus.blockId, + startOffset: prefixStartOffset + triggerIndex, + endOffset: offset, + query, + trigger: trigger.char, + }; +} + +function areSuggestionTargetsEqual( + left: SuggestionMenuTarget | null, + right: SuggestionMenuTarget | null, +): boolean { + return ( + left?.blockId === right?.blockId && + left?.startOffset === right?.startOffset && + left?.endOffset === right?.endOffset && + left?.query === right?.query && + left?.trigger === right?.trigger + ); +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} diff --git a/packages/rendering/react/src/index.ts b/packages/rendering/react/src/index.ts index e96ceba..f8207cd 100644 --- a/packages/rendering/react/src/index.ts +++ b/packages/rendering/react/src/index.ts @@ -78,6 +78,24 @@ export { type SlashMenuEmptyProps, } from "./primitives/slash-menu/index"; +// ── Suggestion menu primitives ─────────────────────────────── +export { + SuggestionMenuRoot, + SuggestionMenuContent, + SuggestionMenuList, + SuggestionMenuGroup, + SuggestionMenuItem, + SuggestionMenuEmpty, + useSuggestionMenuContext, + type SuggestionMenuContextValue, + type SuggestionMenuRootProps, + type SuggestionMenuContentProps, + type SuggestionMenuListProps, + type SuggestionMenuGroupProps, + type SuggestionMenuItemProps, + type SuggestionMenuEmptyProps, +} from "./primitives/suggestion-menu/index"; + // ── Selection toolbar primitives ──────────────────────────── export { SelectionToolbarRoot, @@ -230,6 +248,8 @@ export { useToolbar, useSelectionToolbar, useSlashMenu, + useSuggestionMenu, + resolveSuggestionMenuTarget, useBlockList, useBlockDragHandle, useVisualViewport, @@ -250,6 +270,17 @@ export { type SelectionToolbarState, type SlashMenuState, type SlashMenuActions, + type SlashMenuTarget, + type SuggestionMenuActions, + type SuggestionMenuBoundary, + type SuggestionMenuController, + type SuggestionMenuGetItemsOptions, + type SuggestionMenuSelectOptions, + type SuggestionMenuState, + type SuggestionMenuStatus, + type SuggestionMenuTarget, + type SuggestionMenuTrigger, + type UseSuggestionMenuOptions, type VisualViewportState, } from "./hooks/index"; diff --git a/packages/rendering/react/src/primitives/index.ts b/packages/rendering/react/src/primitives/index.ts index dbc515d..aebc168 100644 --- a/packages/rendering/react/src/primitives/index.ts +++ b/packages/rendering/react/src/primitives/index.ts @@ -30,6 +30,14 @@ export { SlashMenuItem, SlashMenuEmpty, } from "./slash-menu/index"; +export { + SuggestionMenuRoot, + SuggestionMenuContent, + SuggestionMenuList, + SuggestionMenuGroup, + SuggestionMenuItem, + SuggestionMenuEmpty, +} from "./suggestion-menu/index"; export { SelectionToolbarRoot, @@ -124,6 +132,14 @@ import { SlashMenuItem, SlashMenuEmpty, } from "./slash-menu/index"; +import { + SuggestionMenuRoot, + SuggestionMenuContent, + SuggestionMenuList, + SuggestionMenuGroup, + SuggestionMenuItem, + SuggestionMenuEmpty, +} from "./suggestion-menu/index"; import { SelectionToolbarRoot, @@ -215,6 +231,14 @@ export const Pen = { Item: SlashMenuItem, Empty: SlashMenuEmpty, }, + SuggestionMenu: { + Root: SuggestionMenuRoot, + Content: SuggestionMenuContent, + List: SuggestionMenuList, + Group: SuggestionMenuGroup, + Item: SuggestionMenuItem, + Empty: SuggestionMenuEmpty, + }, SelectionToolbar: { Root: SelectionToolbarRoot, Content: SelectionToolbarContent, diff --git a/packages/rendering/react/src/primitives/slash-menu/content.tsx b/packages/rendering/react/src/primitives/slash-menu/content.tsx index 1195ff5..8a53a3e 100644 --- a/packages/rendering/react/src/primitives/slash-menu/content.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/content.tsx @@ -1,19 +1,17 @@ -import React, { useEffect, useRef, useState } from "react"; -import type { Editor } from "@pen/types"; +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { EditorContext } from "../../context/editorContext"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { composeRefs } from "../../utils/composeRefs"; import { isDevelopmentEnvironment } from "../../utils/environment"; +import { + resolveAnchoredMenuPosition, + type AnchoredMenuPosition, + type MenuPlacementSide, +} from "../../utils/menuPosition"; import { useSlashMenuContext } from "./root"; -type Side = "top" | "bottom"; - -interface SlashMenuPosition { - top: number; - left: number; - maxHeight: number; - side: Side; -} +type Side = MenuPlacementSide; +type SlashMenuPosition = AnchoredMenuPosition; export interface SlashMenuContentProps extends AsChildProps { /** @@ -21,7 +19,7 @@ export interface SlashMenuContentProps extends AsChildProps { * @default "bottom" */ side?: Side; - /** Horizontal offset in px from the caret. @default 14 */ + /** Horizontal offset in px from the trigger token. @default 0 */ alignOffset?: number; /** Gap in px between the caret and menu. @default 10 */ sideOffset?: number; @@ -34,7 +32,7 @@ export interface SlashMenuContentProps extends AsChildProps { export function SlashMenuContent(props: SlashMenuContentProps) { const { - alignOffset = 14, + alignOffset = 0, minHeight = 120, ref, side: preferredSide = "bottom", @@ -50,45 +48,48 @@ export function SlashMenuContent(props: SlashMenuContentProps) { open, query, selectedIndex, + target, } = useSlashMenuContext(); const editor = controllerEditor ?? editorContext?.editor; const contentRef = useRef(null); const [position, setPosition] = useState(null); - useEffect(() => { + useLayoutEffect(() => { if (!open || !editor) { setPosition(null); return; } let frame = 0; - const syncPosition = () => { + const updatePosition = () => { + setPosition( + resolveAnchoredMenuPosition({ + alignOffset, + editor, + element: contentRef.current, + minHeight, + preferredSide, + sideOffset, + target: target ?? null, + viewportPadding, + }), + ); + }; + const schedulePosition = () => { window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - setPosition( - resolveMenuPosition({ - alignOffset, - editor, - element: contentRef.current, - minHeight, - preferredSide, - sideOffset, - viewportPadding, - }), - ); - }); + frame = window.requestAnimationFrame(updatePosition); }; - syncPosition(); - window.addEventListener("resize", syncPosition); - window.addEventListener("scroll", syncPosition, true); - document.addEventListener("selectionchange", syncPosition); + updatePosition(); + window.addEventListener("resize", schedulePosition); + window.addEventListener("scroll", schedulePosition, true); + document.addEventListener("selectionchange", schedulePosition); return () => { window.cancelAnimationFrame(frame); - window.removeEventListener("resize", syncPosition); - window.removeEventListener("scroll", syncPosition, true); - document.removeEventListener("selectionchange", syncPosition); + window.removeEventListener("resize", schedulePosition); + window.removeEventListener("scroll", schedulePosition, true); + document.removeEventListener("selectionchange", schedulePosition); }; }, [ alignOffset, @@ -99,6 +100,9 @@ export function SlashMenuContent(props: SlashMenuContentProps) { preferredSide, query, sideOffset, + target?.blockId, + target?.endOffset, + target?.startOffset, viewportPadding, ]); @@ -122,7 +126,9 @@ export function SlashMenuContent(props: SlashMenuContentProps) { contentRef.current?.querySelector( "[data-pen-slash-menu-item][data-selected]", ); - selectedItemElement?.scrollIntoView({ block: "nearest" }); + if (typeof selectedItemElement?.scrollIntoView === "function") { + selectedItemElement.scrollIntoView({ block: "nearest" }); + } }, [open, items.length, selectedIndex]); if (!editor) { @@ -139,17 +145,14 @@ export function SlashMenuContent(props: SlashMenuContentProps) { const primitiveProps: Record = { "data-pen-slash-menu-content": "", "data-side": position?.side ?? preferredSide, + "data-state": "open", style: { position: "fixed" as const, - top: 0, - left: 0, - transform: position - ? `translate3d(${Math.round(position.left)}px, ${Math.round(position.top)}px, 0)` - : undefined, + top: position ? `${Math.round(position.top)}px` : 0, + left: position ? `${Math.round(position.left)}px` : 0, maxHeight: position ? `${Math.round(position.maxHeight)}px` : undefined, - willChange: "transform", zIndex: 60, visibility: position ? ("visible" as const) : ("hidden" as const), }, @@ -161,103 +164,3 @@ export function SlashMenuContent(props: SlashMenuContentProps) { primitiveProps, ); } - -function resolveMenuPosition(options: { - alignOffset: number; - editor: Editor; - element: HTMLElement | null; - minHeight: number; - preferredSide: Side; - sideOffset: number; - viewportPadding: number; -}): SlashMenuPosition | null { - const { - alignOffset, - editor, - element, - minHeight, - preferredSide, - sideOffset, - viewportPadding, - } = options; - - if (typeof window === "undefined") return null; - - const anchorRect = getAnchorRect(editor); - if (!anchorRect) return null; - - const elementRect = element?.getBoundingClientRect(); - const menuWidth = elementRect?.width || 320; - const menuHeight = elementRect?.height || minHeight; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let side = preferredSide; - let top = - side === "top" - ? anchorRect.top - sideOffset - menuHeight - : anchorRect.bottom + sideOffset; - - if ( - side === "bottom" && - top + menuHeight > viewportHeight - viewportPadding - ) { - side = "top"; - top = anchorRect.top - sideOffset - menuHeight; - } - - if (side === "top" && top < viewportPadding) { - side = "bottom"; - top = anchorRect.bottom + sideOffset; - } - - const left = clamp( - anchorRect.left - alignOffset, - viewportPadding, - viewportWidth - menuWidth - viewportPadding, - ); - const availableHeight = - side === "bottom" - ? viewportHeight - top - viewportPadding - : anchorRect.top - sideOffset - viewportPadding; - - return { - top: Math.max(viewportPadding, top), - left, - maxHeight: Math.max(minHeight, availableHeight), - side, - }; -} - -function getAnchorRect(editor: Editor): DOMRect | null { - if (typeof window === "undefined") return null; - - const domSelection = window.getSelection(); - if (domSelection?.rangeCount) { - const range = domSelection.getRangeAt(0).cloneRange(); - range.collapse(false); - const rect = - Array.from(range.getClientRects()).at(-1) ?? - range.getBoundingClientRect(); - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - - const editorSelection = editor.selection; - if (editorSelection?.type !== "text") return null; - - const blockElement = document.querySelector( - `[data-block-id="${escapeCssAttributeValue(editorSelection.anchor.blockId)}"]`, - ); - return blockElement?.getBoundingClientRect() ?? null; -} - -function escapeCssAttributeValue(value: string): string { - return value.replace(/["\\]/g, "\\$&"); -} - -function clamp(value: number, min: number, max: number) { - if (max < min) return min; - return Math.min(Math.max(value, min), max); -} diff --git a/packages/rendering/react/src/primitives/slash-menu/root.tsx b/packages/rendering/react/src/primitives/slash-menu/root.tsx index 138b802..4ff1211 100644 --- a/packages/rendering/react/src/primitives/slash-menu/root.tsx +++ b/packages/rendering/react/src/primitives/slash-menu/root.tsx @@ -109,8 +109,11 @@ function SlashMenuRootContent(props: SlashMenuRootContentProps) { onOpenChange?.(false); }, confirm: (index?: number) => { - controller.confirm(index); - onOpenChange?.(false); + const didConfirm = controller.confirm(index); + if (didConfirm) { + onOpenChange?.(false); + } + return didConfirm; }, }; const wrappedStateRef = useRef(wrappedState); @@ -121,29 +124,52 @@ function SlashMenuRootContent(props: SlashMenuRootContentProps) { const handleKeyDown = (event: KeyboardEvent) => { const currentState = wrappedStateRef.current; + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } switch (event.key) { - case "ArrowDown": + case "ArrowDown": { event.preventDefault(); - currentState.select( - Math.min( - currentState.selectedIndex + 1, - currentState.items.length - 1, - ), - ); + event.stopPropagation(); + const nextIndex = + currentState.items.length === 0 + ? 0 + : (currentState.selectedIndex + 1) % + currentState.items.length; + wrappedStateRef.current = { + ...currentState, + selectedIndex: nextIndex, + }; + currentState.select(nextIndex); break; - case "ArrowUp": + } + case "ArrowUp": { event.preventDefault(); - currentState.select( - Math.max(currentState.selectedIndex - 1, 0), - ); + event.stopPropagation(); + const nextIndex = + currentState.items.length === 0 + ? 0 + : (currentState.selectedIndex - + 1 + + currentState.items.length) % + currentState.items.length; + wrappedStateRef.current = { + ...currentState, + selectedIndex: nextIndex, + }; + currentState.select(nextIndex); break; + } case "Enter": + case "Tab": event.preventDefault(); - currentState.confirm(); + event.stopPropagation(); + currentState.confirm(currentState.selectedIndex); break; case "Escape": event.preventDefault(); + event.stopPropagation(); currentState.dismiss(); break; } diff --git a/packages/rendering/react/src/primitives/suggestion-menu/content.tsx b/packages/rendering/react/src/primitives/suggestion-menu/content.tsx new file mode 100644 index 0000000..f3c2f8b --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/content.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { EditorContext } from "../../context/editorContext"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; +import { composeRefs } from "../../utils/composeRefs"; +import { isDevelopmentEnvironment } from "../../utils/environment"; +import { + resolveAnchoredMenuPosition, + type AnchoredMenuPosition, + type MenuPlacementSide, +} from "../../utils/menuPosition"; +import { useSuggestionMenuContext } from "./root"; + +type Side = MenuPlacementSide; +type SuggestionMenuPosition = AnchoredMenuPosition; + +export interface SuggestionMenuContentProps extends AsChildProps { + side?: Side; + alignOffset?: number; + sideOffset?: number; + minHeight?: number; + viewportPadding?: number; + ref?: React.Ref; +} + +export function SuggestionMenuContent(props: SuggestionMenuContentProps) { + const { + alignOffset = 0, + minHeight = 120, + ref, + side: preferredSide = "bottom", + sideOffset = 10, + viewportPadding = 16, + ...rest + } = props; + const editorContext = React.useContext(EditorContext); + const { + dismiss, + editor: controllerEditor, + items, + open, + query, + selectedIndex, + status, + target, + } = useSuggestionMenuContext(); + const editor = controllerEditor ?? editorContext?.editor; + const contentRef = useRef(null); + const [position, setPosition] = useState( + null, + ); + + useLayoutEffect(() => { + if (!open || !editor) { + setPosition(null); + return; + } + if (typeof window === "undefined") { + return; + } + + let frame = 0; + const updatePosition = () => { + setPosition( + resolveAnchoredMenuPosition({ + alignOffset, + editor, + element: contentRef.current, + minHeight, + preferredSide, + sideOffset, + target, + viewportPadding, + }), + ); + }; + const schedulePosition = () => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(updatePosition); + }; + + updatePosition(); + window.addEventListener("resize", schedulePosition); + window.addEventListener("scroll", schedulePosition, true); + document.addEventListener("selectionchange", schedulePosition); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", schedulePosition); + window.removeEventListener("scroll", schedulePosition, true); + document.removeEventListener("selectionchange", schedulePosition); + }; + }, [ + alignOffset, + editor, + items.length, + minHeight, + open, + preferredSide, + query, + sideOffset, + status, + target?.blockId, + target?.endOffset, + target?.startOffset, + viewportPadding, + ]); + + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (contentRef.current?.contains(event.target as Node)) { + return; + } + dismiss(); + }; + + document.addEventListener("mousedown", handlePointerDown, true); + return () => { + document.removeEventListener("mousedown", handlePointerDown, true); + }; + }, [dismiss, open]); + + useEffect(() => { + if (!open) { + return; + } + + const selectedItemElement = + contentRef.current?.querySelector( + "[data-pen-suggestion-menu-item][data-selected]", + ); + if (typeof selectedItemElement?.scrollIntoView === "function") { + selectedItemElement.scrollIntoView({ block: "nearest" }); + } + }, [open, items.length, selectedIndex]); + + if (!editor) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: must be used within or .", + ); + } + throw new Error("Missing editor for Pen.SuggestionMenu.Content"); + } + + if (!open) { + return null; + } + + const primitiveProps: Record = { + "data-pen-suggestion-menu-content": "", + "data-side": position?.side ?? preferredSide, + "data-state": "open", + "data-status": status, + "data-trigger": target?.trigger, + style: { + position: "fixed" as const, + top: position ? `${Math.round(position.top)}px` : 0, + left: position ? `${Math.round(position.left)}px` : 0, + maxHeight: position + ? `${Math.round(position.maxHeight)}px` + : undefined, + zIndex: 60, + visibility: position ? ("visible" as const) : ("hidden" as const), + }, + }; + + return renderAsChild( + { ...rest, ref: composeRefs(ref, contentRef) }, + "div", + primitiveProps, + ); +} diff --git a/packages/rendering/react/src/primitives/suggestion-menu/empty.tsx b/packages/rendering/react/src/primitives/suggestion-menu/empty.tsx new file mode 100644 index 0000000..4a69dbc --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/empty.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; +import { useSuggestionMenuContext } from "./root"; + +export interface SuggestionMenuEmptyProps extends AsChildProps { + ref?: React.Ref; +} + +export function SuggestionMenuEmpty(props: SuggestionMenuEmptyProps) { + const { items, open, status } = useSuggestionMenuContext(); + if (!open || status === "loading" || items.length > 0) { + return null; + } + + return renderAsChild(props, "div", { + "data-pen-suggestion-menu-empty": "", + role: "presentation", + }); +} diff --git a/packages/rendering/react/src/primitives/suggestion-menu/group.tsx b/packages/rendering/react/src/primitives/suggestion-menu/group.tsx new file mode 100644 index 0000000..04a3bb2 --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/group.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; + +export interface SuggestionMenuGroupProps extends AsChildProps { + heading?: string; + ref?: React.Ref; +} + +export function SuggestionMenuGroup(props: SuggestionMenuGroupProps) { + const { heading, children, ...rest } = props; + const content = ( + <> + {heading && ( +
+ {heading} +
+ )} + {children} + + ); + + return renderAsChild({ ...rest, children: content }, "div", { + "data-pen-suggestion-menu-group": "", + role: "group", + "aria-label": heading, + }); +} diff --git a/packages/rendering/react/src/primitives/suggestion-menu/index.ts b/packages/rendering/react/src/primitives/suggestion-menu/index.ts new file mode 100644 index 0000000..6555809 --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/index.ts @@ -0,0 +1,14 @@ +export { + SuggestionMenuRoot, + useSuggestionMenuContext, + type SuggestionMenuContextValue, + type SuggestionMenuRootProps, +} from "./root"; +export { + SuggestionMenuContent, + type SuggestionMenuContentProps, +} from "./content"; +export { SuggestionMenuList, type SuggestionMenuListProps } from "./list"; +export { SuggestionMenuGroup, type SuggestionMenuGroupProps } from "./group"; +export { SuggestionMenuItem, type SuggestionMenuItemProps } from "./item"; +export { SuggestionMenuEmpty, type SuggestionMenuEmptyProps } from "./empty"; diff --git a/packages/rendering/react/src/primitives/suggestion-menu/item.tsx b/packages/rendering/react/src/primitives/suggestion-menu/item.tsx new file mode 100644 index 0000000..b6402af --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/item.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; +import { useSuggestionMenuContext } from "./root"; + +export interface SuggestionMenuItemProps extends AsChildProps { + index?: number; + onSelect?: () => void; + ref?: React.Ref; + [key: string]: unknown; +} + +export function SuggestionMenuItem(props: SuggestionMenuItemProps) { + const { index, onSelect, ...rest } = props; + const { confirm, select, selectedIndex } = useSuggestionMenuContext(); + const isSelected = index != null && index === selectedIndex; + + const handleClick = () => { + if (onSelect) { + onSelect(); + return; + } + confirm(index); + }; + + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + const handleMouseEnter = () => { + if (index == null) { + return; + } + select(index); + }; + + const primitiveProps: Record = { + "data-pen-suggestion-menu-item": "", + "data-selected": isSelected || undefined, + role: "option", + "aria-selected": isSelected, + onClick: handleClick, + onMouseDown: handleMouseDown, + onMouseEnter: handleMouseEnter, + }; + + return renderAsChild(rest, "div", primitiveProps); +} diff --git a/packages/rendering/react/src/primitives/suggestion-menu/list.tsx b/packages/rendering/react/src/primitives/suggestion-menu/list.tsx new file mode 100644 index 0000000..c4ab205 --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/list.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; + +export interface SuggestionMenuListProps extends AsChildProps { + ref?: React.Ref; +} + +export function SuggestionMenuList(props: SuggestionMenuListProps) { + return renderAsChild(props, "div", { + "data-pen-suggestion-menu-list": "", + role: "listbox", + }); +} diff --git a/packages/rendering/react/src/primitives/suggestion-menu/root.tsx b/packages/rendering/react/src/primitives/suggestion-menu/root.tsx new file mode 100644 index 0000000..b6c8c1e --- /dev/null +++ b/packages/rendering/react/src/primitives/suggestion-menu/root.tsx @@ -0,0 +1,236 @@ +import React, { createContext, useContext, useEffect, useRef } from "react"; +import type { Editor } from "@pen/types"; +import { EditorContext } from "../../context/editorContext"; +import { + useSuggestionMenu, + type SuggestionMenuActions, + type SuggestionMenuController, + type SuggestionMenuState, + type UseSuggestionMenuOptions, +} from "../../hooks/useSuggestionMenu"; +import { renderAsChild, type AsChildProps } from "../../utils/asChild"; +import { isDevelopmentEnvironment } from "../../utils/environment"; + +export type SuggestionMenuContextValue = + SuggestionMenuState & + SuggestionMenuActions & { + editor?: Editor; + }; + +const SuggestionMenuContext = + createContext | null>(null); + +export function useSuggestionMenuContext< + TItem = unknown, +>(): SuggestionMenuContextValue { + const context = useContext(SuggestionMenuContext); + if (!context) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: useSuggestionMenuContext must be used within .", + ); + } + throw new Error("Missing Pen.SuggestionMenu.Root context"); + } + return context as SuggestionMenuContextValue; +} + +export interface SuggestionMenuRootProps extends AsChildProps { + controller?: SuggestionMenuController; + editor?: Editor; + options?: UseSuggestionMenuOptions; + open?: boolean; + onOpenChange?: (open: boolean) => void; + ref?: React.Ref; +} + +export function SuggestionMenuRoot( + props: SuggestionMenuRootProps, +) { + const { controller, editor, options, ...rest } = props; + if (controller) { + return ( + + ); + } + if (options) { + return ( + + ); + } + + if (isDevelopmentEnvironment()) { + console.error( + "Pen: requires either controller or options.", + ); + } + throw new Error("Missing Pen.SuggestionMenu.Root controller"); +} + +type UncontrolledSuggestionMenuRootProps = Omit< + SuggestionMenuRootProps, + "controller" +> & { + options: UseSuggestionMenuOptions; +}; + +function UncontrolledSuggestionMenuRoot( + props: UncontrolledSuggestionMenuRootProps, +) { + const { editor: editorProp, options, ...rest } = props; + const editorContext = useContext(EditorContext); + const editor = editorProp ?? options.editor ?? editorContext?.editor; + + if (!editor) { + if (isDevelopmentEnvironment()) { + console.error( + "Pen: must be used within , receive editor, or receive options.editor.", + ); + } + throw new Error("Missing editor for Pen.SuggestionMenu.Root"); + } + + const controller = useSuggestionMenu({ + ...options, + editor, + }); + + return ( + + ); +} + +type SuggestionMenuRootContentProps = Omit< + SuggestionMenuRootProps, + "controller" | "editor" | "options" +> & { + controller: SuggestionMenuController; + editor?: Editor; +}; + +function SuggestionMenuRootContent( + props: SuggestionMenuRootContentProps, +) { + const { + controller, + editor: editorProp, + open: controlledOpen, + onOpenChange, + ...rest + } = props; + const editorContext = useContext(EditorContext); + const editor = editorProp ?? editorContext?.editor; + const isOpen = controlledOpen ?? controller.open; + + const wrappedState: SuggestionMenuContextValue = { + ...controller, + editor, + open: isOpen, + dismiss: () => { + controller.dismiss(); + onOpenChange?.(false); + }, + confirm: (index?: number) => { + const didConfirm = controller.confirm(index); + if (didConfirm) { + onOpenChange?.(false); + } + return didConfirm; + }, + }; + const wrappedStateRef = useRef(wrappedState); + wrappedStateRef.current = wrappedState; + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + const currentState = wrappedStateRef.current; + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + event.stopPropagation(); + const nextIndex = + currentState.items.length === 0 + ? 0 + : (currentState.selectedIndex + 1) % + currentState.items.length; + wrappedStateRef.current = { + ...currentState, + selectedIndex: nextIndex, + }; + currentState.select(nextIndex); + break; + } + case "ArrowUp": { + event.preventDefault(); + event.stopPropagation(); + const nextIndex = + currentState.items.length === 0 + ? 0 + : (currentState.selectedIndex - + 1 + + currentState.items.length) % + currentState.items.length; + wrappedStateRef.current = { + ...currentState, + selectedIndex: nextIndex, + }; + currentState.select(nextIndex); + break; + } + case "Enter": + case "Tab": + event.preventDefault(); + event.stopPropagation(); + currentState.confirm(currentState.selectedIndex); + break; + case "Escape": + event.preventDefault(); + event.stopPropagation(); + currentState.dismiss(); + break; + } + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isOpen]); + + const primitiveProps: Record = { + role: "dialog", + "data-pen-suggestion-menu": "", + "data-open": isOpen || undefined, + "data-trigger": controller.target?.trigger, + }; + + return ( + } + > + {renderAsChild(rest, "div", primitiveProps)} + + ); +} + +export { SuggestionMenuContext }; diff --git a/packages/rendering/react/src/utils/clipboardSerialization.ts b/packages/rendering/react/src/utils/clipboardSerialization.ts index 8843e63..c9403ca 100644 --- a/packages/rendering/react/src/utils/clipboardSerialization.ts +++ b/packages/rendering/react/src/utils/clipboardSerialization.ts @@ -44,7 +44,7 @@ export function writePenClipboard( }), ]) .catch(() => { - navigator.clipboard.writeText(plainText).catch(() => {}); + navigator.clipboard.writeText(plainText).catch(() => { }); }); } diff --git a/packages/rendering/react/src/utils/menuPosition.ts b/packages/rendering/react/src/utils/menuPosition.ts new file mode 100644 index 0000000..3624334 --- /dev/null +++ b/packages/rendering/react/src/utils/menuPosition.ts @@ -0,0 +1,183 @@ +import type { Editor } from "@pen/types"; +import { getSelectionPointRect } from "../field-editor/selectionBridge"; +import { DATA_ATTRS } from "./dataAttributes"; + +export type MenuPlacementSide = "top" | "bottom"; + +export interface MenuAnchorTarget { + blockId: string; + startOffset: number; + endOffset: number; +} + +export interface AnchoredMenuPosition { + top: number; + left: number; + maxHeight: number; + side: MenuPlacementSide; +} + +export function resolveAnchoredMenuPosition(options: { + alignOffset: number; + editor: Editor; + element: HTMLElement | null; + fallbackWidth?: number; + minHeight: number; + preferredSide: MenuPlacementSide; + sideOffset: number; + target: MenuAnchorTarget | null; + viewportPadding: number; +}): AnchoredMenuPosition | null { + const { + alignOffset, + editor, + element, + fallbackWidth = 320, + minHeight, + preferredSide, + sideOffset, + target, + viewportPadding, + } = options; + + if (typeof window === "undefined") { + return null; + } + + const anchorRect = getAnchorRect(editor, element, target); + if (!anchorRect) { + return null; + } + + const elementRect = element?.getBoundingClientRect(); + const menuWidth = elementRect?.width || fallbackWidth; + const menuHeight = elementRect?.height || minHeight; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let side = preferredSide; + let top = + side === "top" + ? anchorRect.top - sideOffset - menuHeight + : anchorRect.bottom + sideOffset; + + if ( + side === "bottom" && + top + menuHeight > viewportHeight - viewportPadding + ) { + side = "top"; + top = anchorRect.top - sideOffset - menuHeight; + } + + if (side === "top" && top < viewportPadding) { + side = "bottom"; + top = anchorRect.bottom + sideOffset; + } + + const left = clamp( + anchorRect.left - alignOffset, + viewportPadding, + viewportWidth - menuWidth - viewportPadding, + ); + const availableHeight = + side === "bottom" + ? viewportHeight - top - viewportPadding + : anchorRect.top - sideOffset - viewportPadding; + + return { + top: Math.max(viewportPadding, top), + left, + maxHeight: Math.max(minHeight, availableHeight), + side, + }; +} + +function getAnchorRect( + editor: Editor, + element: HTMLElement | null, + target: MenuAnchorTarget | null, +): DOMRect | null { + if (typeof window === "undefined") { + return null; + } + + const rootElement = element?.closest( + `[${DATA_ATTRS.editorRoot}]`, + ) as HTMLElement | null; + if (rootElement && target) { + const startRect = getSelectionPointRect(rootElement, { + blockId: target.blockId, + offset: target.startOffset, + }); + const endRect = getSelectionPointRect(rootElement, { + blockId: target.blockId, + offset: target.endOffset, + }); + if (startRect && endRect) { + return mergeTriggerHorizontalWithCaretLine(startRect, endRect); + } + if (startRect) { + return startRect; + } + } + + const domSelection = window.getSelection(); + if (domSelection?.rangeCount) { + const range = domSelection.getRangeAt(0).cloneRange(); + range.collapse(false); + const rect = + Array.from(range.getClientRects()).at(-1) ?? + range.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + return rect; + } + } + + const editorSelection = editor.selection; + if (editorSelection?.type !== "text") { + return null; + } + + const blockElement = document.querySelector( + `[data-block-id="${escapeCssAttributeValue(editorSelection.anchor.blockId)}"]`, + ); + return blockElement?.getBoundingClientRect() ?? null; +} + +function mergeTriggerHorizontalWithCaretLine( + startRect: DOMRect, + endRect: DOMRect, +): DOMRect { + const verticalRect = isSameVisualLine(startRect, endRect) + ? startRect + : endRect; + return { + x: startRect.left, + y: verticalRect.top, + left: startRect.left, + right: startRect.left, + top: verticalRect.top, + bottom: verticalRect.bottom, + width: 0, + height: verticalRect.height, + toJSON() { + return {}; + }, + } as DOMRect; +} + +function isSameVisualLine(left: DOMRect, right: DOMRect): boolean { + const threshold = Math.max(4, Math.min(left.height, right.height) / 2); + return Math.abs(left.top - right.top) <= threshold; +} + +function escapeCssAttributeValue(value: string): string { + return value.replace(/["\\]/g, "\\$&"); +} + +function clamp(value: number, min: number, max: number): number { + if (max < min) { + return min; + } + return Math.min(Math.max(value, min), max); +} From 507792355bb78fab74274dffab1980d07f4f44eb Mon Sep 17 00:00:00 2001 From: krijn Date: Sat, 16 May 2026 16:59:32 +0200 Subject: [PATCH 13/20] Update debounce timing and enhance input range resolution in editor - Increased DEFAULT_DEBOUNCE_MS from 80 to 100 for improved responsiveness in AI autocomplete. - Added resolveCurrentInputRange method to ContentEditableBackend for better handling of input ranges. - Refactored selection handling in various components to utilize the new input range resolution logic. - Improved code readability by standardizing formatting and structure across multiple files. --- .../ai-autocomplete/src/constants.ts | 2 +- .../ai-autocomplete/src/extension.ts | 282 ++++++++++++------ .../field-editor/contenteditableBackend.ts | 45 ++- .../dom/src/field-editor/controller.ts | 18 ++ .../src/field-editor/editContextBackend.ts | 47 +-- .../dom/src/field-editor/fieldEditorImpl.ts | 125 ++++++++ .../dom/src/field-editor/reconciler.ts | 6 +- .../src/__tests__/escapeKeyHandling.test.tsx | 226 +++++++++++++- playground/src/constants/playgroundAI.ts | 2 +- 9 files changed, 622 insertions(+), 131 deletions(-) diff --git a/packages/extensions/ai-autocomplete/src/constants.ts b/packages/extensions/ai-autocomplete/src/constants.ts index 82a4a8d..68235c3 100644 --- a/packages/extensions/ai-autocomplete/src/constants.ts +++ b/packages/extensions/ai-autocomplete/src/constants.ts @@ -1,4 +1,4 @@ -export const DEFAULT_DEBOUNCE_MS = 80; +export const DEFAULT_DEBOUNCE_MS = 100; export const DEFAULT_MAX_PREFIX_CHARS = 400; export const DEFAULT_MAX_SUFFIX_CHARS = 200; export const DEFAULT_MAX_NEIGHBOR_CHARS = 160; diff --git a/packages/extensions/ai-autocomplete/src/extension.ts b/packages/extensions/ai-autocomplete/src/extension.ts index f921d1b..7b35214 100644 --- a/packages/extensions/ai-autocomplete/src/extension.ts +++ b/packages/extensions/ai-autocomplete/src/extension.ts @@ -59,11 +59,18 @@ const AI_AUTOCOMPLETE_LOG_PREFIX = "[ai-autocomplete]"; const AUTOCOMPLETE_DEBUG_ENABLED = typeof globalThis === "object" && "process" in globalThis && - (globalThis as { - process?: { env?: Record }; - }).process?.env?.PEN_AUTOCOMPLETE_DEBUG === "true"; + ( + globalThis as { + process?: { env?: Record }; + } + ).process?.env?.PEN_AUTOCOMPLETE_DEBUG === "true"; const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; -const PROSE_BLOCK_TYPES = new Set(["paragraph", "heading", "blockquote", "callout"]); +const PROSE_BLOCK_TYPES = new Set([ + "paragraph", + "heading", + "blockquote", + "callout", +]); const MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS = 3; class AutocompleteControllerImpl implements AutocompleteController { @@ -164,10 +171,14 @@ class AutocompleteControllerImpl implements AutocompleteController { deniedBlockTypes: ["database"], ...config.blockPolicy, }; - this._maxPrefixChars = config.maxPrefixChars ?? DEFAULT_MAX_PREFIX_CHARS; - this._maxSuffixChars = config.maxSuffixChars ?? DEFAULT_MAX_SUFFIX_CHARS; - this._maxNeighborChars = config.maxNeighborChars ?? DEFAULT_MAX_NEIGHBOR_CHARS; - this._maxProviderChars = config.maxProviderChars ?? DEFAULT_MAX_PROVIDER_CHARS; + this._maxPrefixChars = + config.maxPrefixChars ?? DEFAULT_MAX_PREFIX_CHARS; + this._maxSuffixChars = + config.maxSuffixChars ?? DEFAULT_MAX_SUFFIX_CHARS; + this._maxNeighborChars = + config.maxNeighborChars ?? DEFAULT_MAX_NEIGHBOR_CHARS; + this._maxProviderChars = + config.maxProviderChars ?? DEFAULT_MAX_PROVIDER_CHARS; this._maxProviderTimeMs = config.maxProviderTimeMs ?? DEFAULT_MAX_PROVIDER_TIME_MS; this._prefetchAfterAccept = @@ -198,7 +209,9 @@ class AutocompleteControllerImpl implements AutocompleteController { return; } if (event.origin !== "user" && event.origin !== "input-rule") { - if (this._shouldDismissForExternalCommit(event.affectedBlocks)) { + if ( + this._shouldDismissForExternalCommit(event.affectedBlocks) + ) { this.dismiss("external-edit"); } return; @@ -310,7 +323,9 @@ class AutocompleteControllerImpl implements AutocompleteController { if (!this._sequence || !this.hasVisibleSuggestion()) { return false; } - const policyFailure = this._resolveCurrentBlockFailure(this._sequence.blockId); + const policyFailure = this._resolveCurrentBlockFailure( + this._sequence.blockId, + ); if (policyFailure) { this._recordPolicyInvalidation(policyFailure, "showing"); return false; @@ -349,13 +364,18 @@ class AutocompleteControllerImpl implements AutocompleteController { inlineLength: candidate.inlineText.length, inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map((block) => block.type), + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), opTypes: acceptanceResult.ops.map((op) => op.type), nextCaretBlockId: acceptanceResult.selection.blockId, nextCaretOffset: acceptanceResult.selection.offset, }); this._isAcceptingSequenceSegment = true; - this._editor.apply(acceptanceResult.ops, { origin: "ai", undoGroup: true }); + this._editor.apply(acceptanceResult.ops, { + origin: "ai", + undoGroup: true, + }); const acceptedBlock = this._editor.getBlock(blockId); const firstNextBlock = acceptedBlock?.next ?? null; const secondNextBlock = firstNextBlock?.next ?? null; @@ -377,7 +397,26 @@ class AutocompleteControllerImpl implements AutocompleteController { nextCaretOffset, ); if (fieldEditor) { - if (typeof fieldEditor.activateTextSelection === "function") { + const programmaticFieldEditor = + fieldEditor as typeof fieldEditor & { + commitProgrammaticTextSelection?: ( + blockId: string, + anchorOffset: number, + focusOffset: number, + ) => void; + }; + if ( + typeof programmaticFieldEditor.commitProgrammaticTextSelection === + "function" + ) { + programmaticFieldEditor.commitProgrammaticTextSelection( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + } else if ( + typeof fieldEditor.activateTextSelection === "function" + ) { fieldEditor.activateTextSelection( nextCaretBlockId, nextCaretOffset, @@ -412,7 +451,9 @@ class AutocompleteControllerImpl implements AutocompleteController { } hasVisibleSuggestion(): boolean { - return this._sequence !== null && this._state.visibleSuggestionId !== null; + return ( + this._sequence !== null && this._state.visibleSuggestionId !== null + ); } registerProvider(provider: AutocompleteContextProvider): () => void { @@ -512,7 +553,8 @@ class AutocompleteControllerImpl implements AutocompleteController { dismiss(reason: AutocompleteDismissReason = "external-edit"): void { this._clearDebounceTimer(); const cancelledRequest = - this._state.status === "scheduled" || this._state.status === "requesting"; + this._state.status === "scheduled" || + this._state.status === "requesting"; this._abortController?.abort(); this._abortController = null; this._prefetchAbortController?.abort(); @@ -527,7 +569,8 @@ class AutocompleteControllerImpl implements AutocompleteController { metrics: { ...this._state.metrics, cancelCount: - this._state.metrics.cancelCount + (cancelledRequest ? 1 : 0), + this._state.metrics.cancelCount + + (cancelledRequest ? 1 : 0), }, diagnostics: { ...this._state.diagnostics, @@ -607,7 +650,8 @@ class AutocompleteControllerImpl implements AutocompleteController { logAutocompleteEvent("request cancelled during stream", { requestId, activeRequestId: this._state.activeRequestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, + lastBlockedReason: + this._state.diagnostics.lastBlockedReason, }); abortController.abort(); return; @@ -616,9 +660,11 @@ class AutocompleteControllerImpl implements AutocompleteController { requestId, type: event.type, }); - if (!handleModelEvent(event, (delta) => { - text += delta; - })) { + if ( + !handleModelEvent(event, (delta) => { + text += delta; + }) + ) { break; } } @@ -713,7 +759,9 @@ class AutocompleteControllerImpl implements AutocompleteController { inlineLength: candidate.inlineText.length, inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map((block) => block.type), + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), previewBlockCount: candidate.previewBlocks.length, }); this._showSequenceSuggestion(); @@ -785,8 +833,14 @@ class AutocompleteControllerImpl implements AutocompleteController { offset, prefixText: tail(blockText.slice(0, offset), this._maxPrefixChars), suffixText: head(blockText.slice(offset), this._maxSuffixChars), - previousBlockText: tail(block.prev?.textContent() ?? "", this._maxNeighborChars), - nextBlockText: head(block.next?.textContent() ?? "", this._maxNeighborChars), + previousBlockText: tail( + block.prev?.textContent() ?? "", + this._maxNeighborChars, + ), + nextBlockText: head( + block.next?.textContent() ?? "", + this._maxNeighborChars, + ), }; } @@ -809,38 +863,48 @@ class AutocompleteControllerImpl implements AutocompleteController { selection.focus.blockId !== context.blockId || selection.focus.offset !== context.offset ) { - logAutocompleteEvent("request continuation blocked: selection changed", { - requestId, - expected: { - blockId: context.blockId, - offset: context.offset, + logAutocompleteEvent( + "request continuation blocked: selection changed", + { + requestId, + expected: { + blockId: context.blockId, + offset: context.offset, + }, + actual: + selection?.type === "text" + ? { + type: selection.type, + blockId: selection.focus.blockId, + offset: selection.focus.offset, + isCollapsed: selection.isCollapsed, + isMultiBlock: selection.isMultiBlock, + } + : selection, }, - actual: - selection?.type === "text" - ? { - type: selection.type, - blockId: selection.focus.blockId, - offset: selection.focus.offset, - isCollapsed: selection.isCollapsed, - isMultiBlock: selection.isMultiBlock, - } - : selection, - }); + ); return false; } const fieldEditor = this._getFieldEditor(); - if (!fieldEditor?.isEditing || !fieldEditor.isFocused || fieldEditor.isComposing) { - logAutocompleteEvent("request continuation blocked: field editor state", { - requestId, - fieldEditor: fieldEditor - ? { - isEditing: fieldEditor.isEditing, - isFocused: fieldEditor.isFocused, - isComposing: fieldEditor.isComposing, - focusBlockId: fieldEditor.focusBlockId, - } - : null, - }); + if ( + !fieldEditor?.isEditing || + !fieldEditor.isFocused || + fieldEditor.isComposing + ) { + logAutocompleteEvent( + "request continuation blocked: field editor state", + { + requestId, + fieldEditor: fieldEditor + ? { + isEditing: fieldEditor.isEditing, + isFocused: fieldEditor.isFocused, + isComposing: fieldEditor.isComposing, + focusBlockId: fieldEditor.focusBlockId, + } + : null, + }, + ); return false; } const block = this._editor.getBlock(context.blockId); @@ -854,18 +918,29 @@ class AutocompleteControllerImpl implements AutocompleteController { return true; } - private _shouldDismissForExternalCommit(affectedBlocks: readonly string[]): boolean { - const visibleSuggestion = this._inlineCompletion.getState().visibleSuggestion; - return !!visibleSuggestion && affectedBlocks.includes(visibleSuggestion.blockId); + private _shouldDismissForExternalCommit( + affectedBlocks: readonly string[], + ): boolean { + const visibleSuggestion = + this._inlineCompletion.getState().visibleSuggestion; + return ( + !!visibleSuggestion && + affectedBlocks.includes(visibleSuggestion.blockId) + ); } private _shouldDismissForSelectionChange(): boolean { - const visibleSuggestion = this._inlineCompletion.getState().visibleSuggestion; + const visibleSuggestion = + this._inlineCompletion.getState().visibleSuggestion; if (!visibleSuggestion || visibleSuggestion.type !== "inline") { return false; } const selection = this._editor.selection; - if (selection?.type !== "text" || !selection.isCollapsed || selection.isMultiBlock) { + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { return true; } return ( @@ -875,7 +950,11 @@ class AutocompleteControllerImpl implements AutocompleteController { } private _getFieldEditor(): FieldEditor | null { - return this._editor.internals.getSlot(FIELD_EDITOR_SLOT_KEY) ?? null; + return ( + this._editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ) ?? null + ); } private _showSequenceSuggestion(): void { @@ -961,9 +1040,11 @@ class AutocompleteControllerImpl implements AutocompleteController { if (abortController.signal.aborted) { return; } - if (!handleModelEvent(event, (delta) => { - text += delta; - })) { + if ( + !handleModelEvent(event, (delta) => { + text += delta; + }) + ) { break; } } @@ -1004,7 +1085,9 @@ class AutocompleteControllerImpl implements AutocompleteController { inlineLength: candidate.inlineText.length, inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map((block) => block.type), + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), previewBlockCount: candidate.previewBlocks.length, }); this._prefetchedContinuation = { @@ -1105,7 +1188,8 @@ class AutocompleteControllerImpl implements AutocompleteController { } private _invalidateForPolicyChange(): void { - const activeBlockId = this._sequence?.blockId ?? this._getActiveSelectionBlockId(); + const activeBlockId = + this._sequence?.blockId ?? this._getActiveSelectionBlockId(); if (!activeBlockId) { return; } @@ -1123,10 +1207,17 @@ class AutocompleteControllerImpl implements AutocompleteController { } private _getPolicyInvalidationStage(): AutocompletePolicyInvalidationStage | null { - if (this._state.status === "scheduled" || this._state.status === "requesting") { + if ( + this._state.status === "scheduled" || + this._state.status === "requesting" + ) { return this._state.status; } - if (this._state.status === "showing" || this._sequence || this._prefetchedContinuation) { + if ( + this._state.status === "showing" || + this._sequence || + this._prefetchedContinuation + ) { return "showing"; } return null; @@ -1187,7 +1278,10 @@ class AutocompleteControllerImpl implements AutocompleteController { ) { return "code-block-disabled"; } - if (blockType === "table" && this._state.blockPolicy.allowInTables !== true) { + if ( + blockType === "table" && + this._state.blockPolicy.allowInTables !== true + ) { return "table-disabled"; } return null; @@ -1246,7 +1340,8 @@ export function autocompleteExtension( name: AI_AUTOCOMPLETE_EXTENSION_NAME, activateClient: async ({ editor }) => { activeEditor = editor; - const inlineCompletionRegistration = ensureInlineCompletionController(editor); + const inlineCompletionRegistration = + ensureInlineCompletionController(editor); inlineCompletion = inlineCompletionRegistration.controller; releaseInlineCompletion = inlineCompletionRegistration.release; controller = new AutocompleteControllerImpl(editor, config, { @@ -1263,18 +1358,21 @@ export function autocompleteExtension( releaseInlineCompletion = null; activeEditor = null; }, - decorations: () => createDecorationSet([ - ...(inlineCompletion?.buildDecorations() ?? []), - ]), + decorations: () => + createDecorationSet([ + ...(inlineCompletion?.buildDecorations() ?? []), + ]), }); } export function getAutocompleteController( editor: Editor, ): AutocompleteController | null { - return editor.internals.getSlot( - AUTOCOMPLETE_CONTROLLER_SLOT, - ) ?? null; + return ( + editor.internals.getSlot( + AUTOCOMPLETE_CONTROLLER_SLOT, + ) ?? null + ); } function handleModelEvent( @@ -1296,11 +1394,16 @@ function normalizeCompletionText( text: string, ): string { const normalized = text.replace(/\r/g, ""); - const withoutFence = normalized.replace(/^```[a-zA-Z0-9_-]*\n?/, "").replace(/```$/, ""); - const withoutWrappedQuotes = stripWrappedCompletionQuotes(context, withoutFence); + const withoutFence = normalized + .replace(/^```[a-zA-Z0-9_-]*\n?/, "") + .replace(/```$/, ""); + const withoutWrappedQuotes = stripWrappedCompletionQuotes( + context, + withoutFence, + ); const trimmedLeading = withoutWrappedQuotes.startsWith("\n\n") || - startsWithStructuredBlockContinuation(withoutWrappedQuotes) + startsWithStructuredBlockContinuation(withoutWrappedQuotes) ? withoutWrappedQuotes : withoutWrappedQuotes.replace(/^\s*\n/, ""); if (!trimmedLeading) { @@ -1330,7 +1433,9 @@ function normalizeCompletionText( } function startsWithStructuredBlockContinuation(text: string): boolean { - return /^\s*\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test(text); + return /^\s*\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test( + text, + ); } function longestCommonPrefix(left: string, right: string): string { @@ -1366,7 +1471,10 @@ function maybeInsertMissingBoundarySpace( } const lastPrefixChar = context.prefixText.slice(-1); const firstCompletionChar = completion[0]; - if (!isWordLikeChar(lastPrefixChar) || !isWordLikeChar(firstCompletionChar)) { + if ( + !isWordLikeChar(lastPrefixChar) || + !isWordLikeChar(firstCompletionChar) + ) { return completion; } if (!hasLikelyWordBoundary(completion)) { @@ -1447,7 +1555,8 @@ function maybeCapitalizeSentenceStart( } return completion.replace( /^(\s*["'([{“‘-]*)([a-z])/u, - (_, prefix: string, character: string) => `${prefix}${character.toUpperCase()}`, + (_, prefix: string, character: string) => + `${prefix}${character.toUpperCase()}`, ); } @@ -1494,7 +1603,9 @@ function unwrapMatchingQuotes(value: string): string | null { ]; for (const [open, close] of quotePairs) { if (value.startsWith(open) && value.endsWith(close)) { - const inner = value.slice(open.length, value.length - close.length).trim(); + const inner = value + .slice(open.length, value.length - close.length) + .trim(); return inner.length > 0 ? inner : null; } } @@ -1633,11 +1744,14 @@ function incrementPolicyInvalidationMetrics( return { ...metrics, policyInvalidationScheduledCount: - metrics.policyInvalidationScheduledCount + (stage === "scheduled" ? 1 : 0), + metrics.policyInvalidationScheduledCount + + (stage === "scheduled" ? 1 : 0), policyInvalidationRequestingCount: - metrics.policyInvalidationRequestingCount + (stage === "requesting" ? 1 : 0), + metrics.policyInvalidationRequestingCount + + (stage === "requesting" ? 1 : 0), policyInvalidationShowingCount: - metrics.policyInvalidationShowingCount + (stage === "showing" ? 1 : 0), + metrics.policyInvalidationShowingCount + + (stage === "showing" ? 1 : 0), }; } @@ -1653,5 +1767,7 @@ function logAutocompleteEvent(message: string, details?: unknown): void { } function previewAutocompleteTextForLog(text: string): string { - return JSON.stringify(text.length > 160 ? `${text.slice(0, 160)}...` : text); + return JSON.stringify( + text.length > 160 ? `${text.slice(0, 160)}...` : text, + ); } diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index 435f0a9..9ec589a 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -685,6 +685,21 @@ export class ContentEditableBackend implements InputBackend { } }; + resolveCurrentInputRange(): { + start: number; + end: number; + } | null { + const liveRange = this.element + ? getSelectionOffsets(this.element) + : null; + return ( + this.fieldEditor.resolveProgrammaticInputRange( + this.fieldEditor.focusBlockId, + liveRange, + ) ?? liveRange + ); + } + private handleSelectionChange = (): void => { if (!this.element) return; if ( @@ -727,6 +742,16 @@ export class ContentEditableBackend implements InputBackend { return; } + if ( + this.fieldEditor.shouldIgnoreDomTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ) + ) { + this.restoreDOMSelectionFromEditor(); + return; + } + this.fieldEditor.applyDomTextSelection( normalizedSelection.anchor, normalizedSelection.focus, @@ -775,7 +800,7 @@ const DIRECT_HANDLERS: Record = { } const blockId = fe.focusBlockId; if (!blockId) return; - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range) return; if (backend.applyListInputRule({ blockId, range, text })) { return; @@ -801,7 +826,7 @@ const DIRECT_HANDLERS: Record = { const targetRanges = event.getTargetRanges?.(); const range = targetRanges?.length ? staticRangeToOffsets(targetRanges[0], element) - : getSelectionOffsets(element); + : backend.resolveCurrentInputRange(); if (!range) return; if (backend.applyListInputRule({ blockId, range, text })) { return; @@ -820,7 +845,7 @@ const DIRECT_HANDLERS: Record = { editor.deleteSelection(); return; } - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range) return; const target = applyDeleteBehavior(editor, { @@ -866,7 +891,7 @@ const DIRECT_HANDLERS: Record = { editor.deleteSelection(); return; } - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range) return; const target = applyDeleteBehavior(editor, { @@ -903,7 +928,7 @@ const DIRECT_HANDLERS: Record = { editor.deleteSelection(); return; } - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range || range.start === range.end) return; backend.applyInlineTextEdit({ @@ -914,7 +939,7 @@ const DIRECT_HANDLERS: Record = { }, deleteWordBackward: (_event, editor, ytext, fe, element, backend) => { - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range) return; if (range.start !== range.end) { @@ -940,7 +965,7 @@ const DIRECT_HANDLERS: Record = { }, deleteWordForward: (_event, editor, ytext, fe, element, backend) => { - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range) return; if (range.start !== range.end) { @@ -965,14 +990,14 @@ const DIRECT_HANDLERS: Record = { } }, - insertParagraph: (_event, editor, ytext, fe, element) => { + insertParagraph: (_event, editor, ytext, fe, element, backend) => { const blockId = fe.focusBlockId; if (!blockId) return; const target = applyEnterBehavior(editor, { blockId, inputMode: fe.inputMode, ytext, - range: getSelectionOffsets(element), + range: backend.resolveCurrentInputRange(), }); if (!target) return; @@ -984,7 +1009,7 @@ const DIRECT_HANDLERS: Record = { }, insertLineBreak: (_event, _editor, ytext, fe, element, backend) => { - const range = getSelectionOffsets(element); + const range = backend.resolveCurrentInputRange(); if (!range) return; const blockId = fe.focusBlockId; if (!blockId) return; diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index 8371481..d6635f1 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -26,11 +26,24 @@ export interface FieldEditorRootHandle { anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; } export interface FieldEditorDomController extends FieldEditorSelectionState { setComposing(composing: boolean): void; shouldHandleDomSelectionChange(isApplyingSelection: number): boolean; + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null; + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean; applyDocumentTextSelection( anchor: { blockId: string; offset: number }, focus: { blockId: string; offset: number }, @@ -56,6 +69,11 @@ export interface FieldEditorDomController extends FieldEditorSelectionState { anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; deactivate(): void; } diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index db6693c..a0538ee 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -425,6 +425,11 @@ export class EditContextBackend implements InputBackend { const isCollapsedInsert = input.text.length > 0 && input.updateRangeStart === input.updateRangeEnd; + const programmaticInputRange = + this.fieldEditor.resolveProgrammaticInputRange(input.blockId, { + start: input.updateRangeStart, + end: input.updateRangeEnd, + }); const editContextCaret = collapsedSelectionOffset( this.editContextSelection, input.blockId, @@ -453,20 +458,24 @@ export class EditContextBackend implements InputBackend { input.updateRangeEnd !== editorSelectionRange.end); const shouldClampEmptyRange = isLogicallyEmpty && authoritativeInputCaret == null; - const rangeStart = shouldUseEditorSelectionRange - ? editorSelectionRange.start - : shouldClampEmptyRange - ? 0 - : shouldUseTrustedCaret - ? trustedCaret - : input.updateRangeStart; - const rangeEnd = shouldUseEditorSelectionRange - ? editorSelectionRange.end - : shouldClampEmptyRange - ? 0 - : shouldUseTrustedCaret - ? trustedCaret - : input.updateRangeEnd; + const rangeStart = programmaticInputRange + ? programmaticInputRange.start + : shouldUseEditorSelectionRange + ? editorSelectionRange.start + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeStart; + const rangeEnd = programmaticInputRange + ? programmaticInputRange.end + : shouldUseEditorSelectionRange + ? editorSelectionRange.end + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeEnd; const hasCollapsedEventSelection = typeof input.selectionStart !== "number" || typeof input.selectionEnd !== "number" || @@ -1085,7 +1094,10 @@ export class EditContextBackend implements InputBackend { }; } - if (liveDomOffsets && this.shouldUseLiveDomSelection(blockId, liveDomOffsets)) { + if ( + liveDomOffsets && + this.shouldUseLiveDomSelection(blockId, liveDomOffsets) + ) { return { range: directionalSelectionToRange(liveDomOffsets), nextSelection: { @@ -1427,10 +1439,7 @@ function rangeToSelection( }; } -function rangesEqual( - left: EditContextRange, - right: EditContextRange, -): boolean { +function rangesEqual(left: EditContextRange, right: EditContextRange): boolean { return left.start === right.start && left.end === right.end; } diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index d57dee7..b1c9bd1 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -86,6 +86,16 @@ export class FieldEditorImpl implements FieldEditorSession { scope: "cell" | "block" | "document"; } | null = null; private _preserveSelectAllCycle = false; + private _programmaticTextSelection: { + blockId: string; + anchorOffset: number; + focusOffset: number; + } | null = null; + private _pendingProgrammaticTextSelection: { + blockId: string; + anchorOffset: number; + focusOffset: number; + } | null = null; private _activeCellCoord: ActiveCellCoord | null = null; constructor(editor: Editor, options?: FieldEditorOptions) { @@ -371,6 +381,8 @@ export class FieldEditorImpl implements FieldEditorSession { } beginPointerSelection(): void { + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; this._pointerSelectionDepth += 1; } @@ -403,6 +415,8 @@ export class FieldEditorImpl implements FieldEditorSession { this._isComposing = false; this._historySelectionCoordinator.reset(); this._suppressNextDomSelectionProjection = false; + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; this._pointerSelectionDepth = 0; this._inputMode = "none"; this._mode = "inactive"; @@ -508,6 +522,16 @@ export class FieldEditorImpl implements FieldEditorSession { if (this._focusBlockId !== blockId) return; this.setTextSelection(blockId, anchorOffset, focusOffset); + const pendingProgrammaticSelection = + this._pendingProgrammaticTextSelection; + if ( + pendingProgrammaticSelection && + (pendingProgrammaticSelection.blockId !== blockId || + pendingProgrammaticSelection.anchorOffset !== anchorOffset || + pendingProgrammaticSelection.focusOffset !== focusOffset) + ) { + this._pendingProgrammaticTextSelection = null; + } } applyDocumentTextSelection( @@ -578,10 +602,60 @@ export class FieldEditorImpl implements FieldEditorSession { return ( isApplyingSelection === 0 && this._pointerSelectionDepth === 0 && + this._pendingProgrammaticTextSelection === null && !this._shouldSuppressSelectionSync() ); } + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + const programmaticSelection = + this._getActiveProgrammaticTextSelection(blockId); + if (!programmaticSelection) { + return null; + } + if (!liveRange) { + this._programmaticTextSelection = null; + return { + start: programmaticSelection.anchorOffset, + end: programmaticSelection.focusOffset, + }; + } + if ( + liveRange.start === liveRange.end && + (liveRange.start !== programmaticSelection.anchorOffset || + liveRange.end !== programmaticSelection.focusOffset) + ) { + this._programmaticTextSelection = null; + return { + start: programmaticSelection.anchorOffset, + end: programmaticSelection.focusOffset, + }; + } + return null; + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + const programmaticSelection = this._getActiveProgrammaticTextSelection( + anchor.blockId, + ); + if (!programmaticSelection || anchor.blockId !== focus.blockId) { + return false; + } + if ( + anchor.offset === programmaticSelection.anchorOffset && + focus.offset === programmaticSelection.focusOffset + ) { + return false; + } + return anchor.offset === focus.offset; + } + setTextSelection( blockId: string, anchorOffset: number, @@ -591,6 +665,15 @@ export class FieldEditorImpl implements FieldEditorSession { this._clearPendingMarks(true); } this._editor.selectText(blockId, anchorOffset, focusOffset); + const programmaticSelection = this._programmaticTextSelection; + if ( + programmaticSelection && + (programmaticSelection.blockId !== blockId || + programmaticSelection.anchorOffset !== anchorOffset || + programmaticSelection.focusOffset !== focusOffset) + ) { + this._programmaticTextSelection = null; + } this._emitStateChange(); } @@ -599,9 +682,31 @@ export class FieldEditorImpl implements FieldEditorSession { anchorOffset: number, focusOffset: number, ): void { + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; this._projectTextSelection(blockId, anchorOffset, focusOffset); } + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + this._programmaticTextSelection = { + blockId, + anchorOffset, + focusOffset, + }; + this._pendingProgrammaticTextSelection = { + blockId, + anchorOffset, + focusOffset, + }; + this._projectTextSelection(blockId, anchorOffset, focusOffset, { + syncBackendImmediately: true, + }); + } + collapseSelectionToFocus(): void { const selection = this._editor.selection; if (selection?.type !== "text") return; @@ -1199,6 +1304,9 @@ export class FieldEditorImpl implements FieldEditorSession { blockId: string, anchorOffset: number, focusOffset: number, + options?: { + syncBackendImmediately?: boolean; + }, ): void { this.setTextSelection(blockId, anchorOffset, focusOffset); @@ -1206,6 +1314,9 @@ export class FieldEditorImpl implements FieldEditorSession { this.activate(blockId); } + if (options?.syncBackendImmediately) { + this._backend?.updateSelection(null); + } this._syncDomSelectionOnce(); } @@ -1278,6 +1389,20 @@ export class FieldEditorImpl implements FieldEditorSession { }); } + private _getActiveProgrammaticTextSelection(blockId: string | null): { + blockId: string; + anchorOffset: number; + focusOffset: number; + } | null { + const programmaticSelection = + this._programmaticTextSelection ?? + this._pendingProgrammaticTextSelection; + if (!blockId || programmaticSelection?.blockId !== blockId) { + return null; + } + return programmaticSelection; + } + private _shouldSuppressSelectionSync(): boolean { return ( this._historySelectionCoordinator.shouldSuppressSelectionSync() || diff --git a/packages/rendering/dom/src/field-editor/reconciler.ts b/packages/rendering/dom/src/field-editor/reconciler.ts index 59030d0..2ec28b9 100644 --- a/packages/rendering/dom/src/field-editor/reconciler.ts +++ b/packages/rendering/dom/src/field-editor/reconciler.ts @@ -149,9 +149,9 @@ export function fullReconcileToDOM( const renderedDeltas = options?.inlineDecorations && options.inlineDecorations.length > 0 ? applyInlineDecorationsToDeltas( - textDeltas, - options.inlineDecorations, - ) + textDeltas, + options.inlineDecorations, + ) : textDeltas; fullReconcileDeltasToDOM(renderedDeltas, element, registry, options); } diff --git a/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx b/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx index ddaf26e..7ddd8e4 100644 --- a/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx +++ b/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx @@ -18,9 +18,7 @@ import { FakeEditContext } from "./utils/fakeEditContext"; globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -function createEditor( - options: Parameters[0] = {}, -) { +function createEditor(options: Parameters[0] = {}) { const { without: _without, ...restOptions } = options; return createCoreEditor({ ...restOptions, @@ -1496,9 +1494,9 @@ describe("@pen/react escape key handling", () => { activeBlockIds: [firstBlockId, secondBlockId], mode: "expanded", }); - expect( - blocksHost?.hasAttribute("data-pen-field-editor-surface"), - ).toBe(true); + expect(blocksHost?.hasAttribute("data-pen-field-editor-surface")).toBe( + true, + ); expect( blocksHost?.hasAttribute("data-pen-field-editor-active-surface"), ).toBe(true); @@ -1964,7 +1962,10 @@ describe("@pen/react escape key handling", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = (_x, y) => { const range = document.createRange(); @@ -2015,7 +2016,10 @@ describe("@pen/react escape key handling", () => { } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -2097,7 +2101,10 @@ describe("@pen/react escape key handling", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = (_x, y) => { const range = document.createRange(); @@ -2146,7 +2153,10 @@ describe("@pen/react escape key handling", () => { } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -2225,7 +2235,10 @@ describe("@pen/react escape key handling", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = (_x, y) => { const range = document.createRange(); @@ -2274,7 +2287,10 @@ describe("@pen/react escape key handling", () => { } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -2352,7 +2368,10 @@ describe("@pen/react escape key handling", () => { try { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = (_x, y) => { const range = document.createRange(); @@ -2395,7 +2414,10 @@ describe("@pen/react escape key handling", () => { } finally { ( document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretRangeFromPoint?: ( + x: number, + y: number, + ) => Range | null; } ).caretRangeFromPoint = originalCaretRangeFromPoint; } @@ -2801,6 +2823,182 @@ describe("@pen/react escape key handling", () => { editor.destroy(); }); + it("uses the programmatic post-commit caret when a stale selectionchange arrives before typing", async () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hel" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 3, 3); + fieldEditor.focus(); + fieldEditor.setFocused(true); + await flushAnimationFrames(2); + }); + + await act(async () => { + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 3, + text: "lo world", + }, + ], + { origin: "ai" }, + ); + fieldEditor.commitProgrammaticTextSelection(blockId, 11, 11); + await flushAnimationFrames(2); + }); + + await act(async () => { + setNativeSelectionRange(inlineElement!, 11, inlineElement!, 11); + document.dispatchEvent(new Event("selectionchange")); + setNativeSelectionRange(inlineElement!, 3, inlineElement!, 3); + document.dispatchEvent(new Event("selectionchange")); + inlineElement?.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "!", + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world!"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 12 }, + focus: { blockId, offset: 12 }, + isCollapsed: true, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("uses the programmatic post-commit caret for stale EditContext text updates", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + try { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hel" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as (HTMLElement & { editContext?: FakeEditContext }) | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 3, 3); + await flushAnimationFrames(2); + }); + + await act(async () => { + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 3, + text: "lo world", + }, + ], + { origin: "ai" }, + ); + fieldEditor.commitProgrammaticTextSelection(blockId, 11, 11); + await flushAnimationFrames(2); + }); + + await act(async () => { + inlineElement?.editContext?.emit("textupdate", { + updateRangeStart: 3, + updateRangeEnd: 3, + text: "!", + selectionStart: 4, + selectionEnd: 4, + }); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe( + "Hello world!", + ); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 12 }, + focus: { blockId, offset: 12 }, + isCollapsed: true, + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + it("snaps delegated block drag targets to legal block boundaries", async () => { const editor = createEditor(); const firstBlockId = editor.firstBlock()!.id; diff --git a/playground/src/constants/playgroundAI.ts b/playground/src/constants/playgroundAI.ts index 209bfd6..4353094 100644 --- a/playground/src/constants/playgroundAI.ts +++ b/playground/src/constants/playgroundAI.ts @@ -4,6 +4,6 @@ export const PLAYGROUND_AI_SESSION_SYNC_ENDPOINT = "/api/ai/session/sync"; export const PLAYGROUND_AI_SESSION_DIAGNOSTICS_ENDPOINT = "/api/ai/session/diagnostics"; -export const PLAYGROUND_AI_SYNC_DEBOUNCE_MS = 200; +export const PLAYGROUND_AI_SYNC_DEBOUNCE_MS = 160; export const PLAYGROUND_AI_DIRECT_STREAM_BATCH_INTERVAL_MS = 120; export const PLAYGROUND_AI_SESSION_ID_PREVIEW_LENGTH = 8; From b58c53b53b8feebec3da11c2ce3bd08c48aced14 Mon Sep 17 00:00:00 2001 From: krijn Date: Sat, 16 May 2026 17:58:31 +0200 Subject: [PATCH 14/20] Enhance inline completion handling and selection synchronization - Added a test to verify that accepting an inline completion updates the text selection correctly. - Implemented logic to programmatically commit text selection after accepting inline completions, ensuring accurate caret positioning. - Updated the FieldEditor interface to include a method for committing programmatic text selections. - Refactored key handling to synchronize accepted inline completions with the editor's selection state. - Improved handling of stale EditContext selections to ensure consistent user experience during inline completions. --- .../core/src/__tests__/editorCore.test.ts | 30 ++++++ packages/core/src/editor/inlineCompletion.ts | 7 ++ .../dom/src/field-editor/controller.ts | 5 + .../src/field-editor/editContextBackend.ts | 21 ++++ .../dom/src/field-editor/keyHandling.ts | 29 ++++- .../src/__tests__/escapeKeyHandling.test.tsx | 102 +++++++++++++++++- .../react/src/__tests__/keyHandling.test.ts | 56 ++++++++++ packages/types/src/types/fieldEditor.ts | 5 + 8 files changed, 253 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/editorCore.test.ts b/packages/core/src/__tests__/editorCore.test.ts index cca3e44..0583084 100644 --- a/packages/core/src/__tests__/editorCore.test.ts +++ b/packages/core/src/__tests__/editorCore.test.ts @@ -15,6 +15,7 @@ import { createDocumentSession, createEditor as createCoreEditor, createHeadlessEditor, + ensureInlineCompletionController, } from "../index"; const noDefaultExtensionsPreset = { @@ -674,6 +675,35 @@ describe("@pen/core createEditor", () => { editor.destroy(); }); + it("moves the text selection after accepting an inline completion", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const { controller } = ensureInlineCompletionController(editor); + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + editor.selectText(blockId, 5, 5); + controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 5, + text: " world", + type: "inline", + }); + + expect(controller.acceptSuggestion()).toBe(true); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 11 }, + focus: { blockId, offset: 11 }, + }); + + editor.destroy(); + }); + it("splits and merges inline blocks", () => { const editor = createEditor(); diff --git a/packages/core/src/editor/inlineCompletion.ts b/packages/core/src/editor/inlineCompletion.ts index 32f141c..bf6402f 100644 --- a/packages/core/src/editor/inlineCompletion.ts +++ b/packages/core/src/editor/inlineCompletion.ts @@ -59,6 +59,7 @@ class InlineCompletionControllerImpl implements InlineCompletionController { }; if (suggestion.type === "inline") { + const nextOffset = suggestion.offset + suggestion.text.length; this._editor.apply( [{ type: "insert-text", @@ -68,6 +69,7 @@ class InlineCompletionControllerImpl implements InlineCompletionController { }], { origin: "ai", undoGroup: true }, ); + this._editor.selectText(suggestion.blockId, nextOffset, nextOffset); this._emit(); return true; } @@ -91,6 +93,11 @@ class InlineCompletionControllerImpl implements InlineCompletionController { ], { origin: "ai", undoGroup: true }, ); + this._editor.selectText( + blockId, + suggestion.text.length, + suggestion.text.length, + ); this._emit(); return true; } diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index d6635f1..6090454 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -88,6 +88,11 @@ export interface FieldEditorKeyboardController extends Pick< anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection?( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; deactivate(): void; selectAll(rootElement?: HTMLElement | null): boolean; } diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index a0538ee..f7e7cba 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -529,6 +529,9 @@ export class EditContextBackend implements InputBackend { this.editContextSelection = resolvedSelection; if (options?.source === "text-update") { this.authoritativeTextInputSelection = resolvedSelection; + } else { + // Programmatic/editor selections supersede stale EditContext text-update carets. + this.authoritativeTextInputSelection = null; } this.editContext?.updateSelection( resolvedSelection.anchorOffset, @@ -1068,6 +1071,24 @@ export class EditContextBackend implements InputBackend { } const editorSelectionRange = this.resolveEditorSelectionRange(blockId); + const liveRange = liveDomOffsets + ? directionalSelectionToRange(liveDomOffsets) + : null; + const programmaticInputRange = + isFieldEditorTextEditingKey(event) + ? this.fieldEditor.resolveProgrammaticInputRange( + blockId, + liveRange, + ) + : null; + if (programmaticInputRange) { + return { + range: programmaticInputRange, + nextSelection: rangeToSelection(blockId, programmaticInputRange), + shouldSyncEditContextSelection: true, + }; + } + const trustedKeyRange = this.resolveTrustedKeyDownRange( blockId, event, diff --git a/packages/rendering/dom/src/field-editor/keyHandling.ts b/packages/rendering/dom/src/field-editor/keyHandling.ts index 52ae670..5e0e7c6 100644 --- a/packages/rendering/dom/src/field-editor/keyHandling.ts +++ b/packages/rendering/dom/src/field-editor/keyHandling.ts @@ -152,7 +152,11 @@ export function handleFieldEditorKeyDown(options: { if (autocomplete?.hasVisibleSuggestion()) { return autocomplete.acceptVisibleSuggestion(); } - return inlineCompletion.acceptSuggestion(); + const accepted = inlineCompletion.acceptSuggestion(); + if (accepted) { + syncAcceptedInlineCompletionSelection(editor, fieldEditor); + } + return accepted; } if (!event.shiftKey) { @@ -267,6 +271,29 @@ export function handleFieldEditorKeyDown(options: { return handleEditorKeyBindings(editor, event, { includeSelectAll: false }); } +function syncAcceptedInlineCompletionSelection( + editor: Editor, + fieldEditor: FieldEditorKeyboardController, +): void { + const selection = editor.selection; + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { + return; + } + + const blockId = selection.focus.blockId; + const offset = selection.focus.offset; + if (typeof fieldEditor.commitProgrammaticTextSelection === "function") { + fieldEditor.commitProgrammaticTextSelection(blockId, offset, offset); + return; + } + + fieldEditor.activateTextSelection(blockId, offset, offset); +} + function shouldDismissAutocompleteOnKeyDown( event: KeyboardEvent, autocomplete: ReturnType, diff --git a/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx b/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx index 7ddd8e4..3c2c67f 100644 --- a/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx +++ b/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx @@ -3,7 +3,11 @@ import React, { act } from "react"; import { describe, expect, it } from "vitest"; import { createRoot } from "react-dom/client"; -import { createEditor as createCoreEditor, DocumentRangeImpl } from "@pen/core"; +import { + createEditor as createCoreEditor, + DocumentRangeImpl, + ensureInlineCompletionController, +} from "@pen/core"; import { defaultPreset } from "@pen/preset-default"; import type { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; import { Pen } from "../primitives/index"; @@ -2904,6 +2908,102 @@ describe("@pen/react escape key handling", () => { editor.destroy(); }); + it("uses the accepted inline completion caret for immediate enter with stale EditContext state", async () => { + const originalEditContext = ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext; + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = FakeEditContext; + + try { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const { controller: inlineCompletion } = + ensureInlineCompletionController(editor); + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hel" }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const fieldEditor = getFieldEditor(editor); + const inlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as (HTMLElement & { editContext?: FakeEditContext }) | null; + + expect(inlineElement).not.toBeNull(); + + await act(async () => { + fieldEditor.activateTextSelection(blockId, 3, 3); + await flushAnimationFrames(2); + }); + + await act(async () => { + inlineElement?.editContext?.emit("textupdate", { + updateRangeStart: 3, + updateRangeEnd: 3, + text: "", + selectionStart: 3, + selectionEnd: 3, + }); + inlineCompletion.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 3, + text: "lo world", + type: "inline", + }); + setNativeSelectionRange(inlineElement!, 3, inlineElement!, 3); + inlineElement?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }), + ); + await flushAnimationFrames(2); + setNativeSelectionRange(inlineElement!, 11, inlineElement!, 11); + inlineElement?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } finally { + ( + globalThis as typeof globalThis & { + EditContext?: typeof FakeEditContext; + } + ).EditContext = originalEditContext; + } + }); + it("uses the programmatic post-commit caret for stale EditContext text updates", async () => { const originalEditContext = ( globalThis as typeof globalThis & { diff --git a/packages/rendering/react/src/__tests__/keyHandling.test.ts b/packages/rendering/react/src/__tests__/keyHandling.test.ts index ec43813..4b79c92 100644 --- a/packages/rendering/react/src/__tests__/keyHandling.test.ts +++ b/packages/rendering/react/src/__tests__/keyHandling.test.ts @@ -85,6 +85,11 @@ function createFieldEditorMock(blockId: string) { anchorOffset: number; focusOffset: number; }> = []; + const programmaticSelections: Array<{ + blockId: string; + anchorOffset: number; + focusOffset: number; + }> = []; return { controller: { @@ -103,10 +108,22 @@ function createFieldEditorMock(blockId: string) { focusOffset, }); }, + commitProgrammaticTextSelection: ( + targetBlockId: string, + anchorOffset: number, + focusOffset: number, + ) => { + programmaticSelections.push({ + blockId: targetBlockId, + anchorOffset, + focusOffset, + }); + }, deactivate: () => { }, selectAll: () => false, }, activations, + programmaticSelections, }; } @@ -854,6 +871,45 @@ describe("@pen/react field editor Tab handling", () => { editor.destroy(); }); + it("commits programmatic selection after accepting raw inline completions", () => { + const editor = createPresetEditor({ + preset: { + shortcuts: false, + }, + extensions: [aiExtension()], + }); + const blockId = editor.firstBlock()!.id; + const fieldEditor = createFieldEditorMock(blockId); + const inlineCompletion = getInlineCompletionController(editor); + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + editor.selectText(blockId, 5, 5); + inlineCompletion?.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 5, + text: " world", + type: "inline", + }); + + const handled = handleFieldEditorKeyDown({ + event: createKeyEvent("Tab"), + editor, + fieldEditor: fieldEditor.controller, + ytext: getYText(editor, blockId), + range: { start: 5, end: 5 }, + }); + + expect(handled).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + expect(fieldEditor.programmaticSelections).toEqual([ + { blockId, anchorOffset: 11, focusOffset: 11 }, + ]); + + editor.destroy(); + }); + it("dismisses visible autocomplete on typing without handling the key event", () => { let dismissReason: string | null = null; let activeEditor: ReturnType | null = null; diff --git a/packages/types/src/types/fieldEditor.ts b/packages/types/src/types/fieldEditor.ts index ccbf90a..85680ba 100644 --- a/packages/types/src/types/fieldEditor.ts +++ b/packages/types/src/types/fieldEditor.ts @@ -37,6 +37,11 @@ export interface FieldEditor { anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection?( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; expandTo(blockId: string): void; contractToFocused(): void; From d3aa3cdde116850dbfa80c3528dd93627f1d6921 Mon Sep 17 00:00:00 2001 From: krijn Date: Mon, 18 May 2026 00:04:53 +0200 Subject: [PATCH 15/20] Enhance AI autocomplete functionality and placeholder behavior - Added support for previous and next block text in the prompt builder to improve context awareness during AI completions. - Implemented tests to verify inline completion behavior, ensuring placeholders are hidden when suggestions are visible. - Updated placeholder text in the paragraph block to reflect new command shortcuts for better user guidance. - Refactored inline content rendering to manage visibility of inline completions and placeholders more effectively. --- .../src/__tests__/inlineCompletion.test.ts | 81 ++++++ .../src/__tests__/extension.test.ts | 47 ++++ .../ai-autocomplete/src/promptBuilder.test.ts | 35 +++ .../ai-autocomplete/src/promptBuilder.ts | 6 + .../ai-autocomplete/src/providers/builtins.ts | 19 -- .../__tests__/placeholderBehavior.test.tsx | 258 +++++++++++++++++- .../__tests__/placeholderVisibility.test.ts | 68 +++++ .../src/primitives/editor/inlineContent.tsx | 69 +++-- .../react/src/utils/placeholderVisibility.ts | 51 ++++ .../schema/default/src/blocks/paragraph.ts | 26 +- packages/types/src/constants/decorations.ts | 2 + packages/types/src/index.ts | 3 + packages/types/src/types/index.ts | 3 + 13 files changed, 613 insertions(+), 55 deletions(-) create mode 100644 packages/core/src/__tests__/inlineCompletion.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/promptBuilder.test.ts create mode 100644 packages/rendering/react/src/__tests__/placeholderVisibility.test.ts create mode 100644 packages/rendering/react/src/utils/placeholderVisibility.ts create mode 100644 packages/types/src/constants/decorations.ts diff --git a/packages/core/src/__tests__/inlineCompletion.test.ts b/packages/core/src/__tests__/inlineCompletion.test.ts new file mode 100644 index 0000000..efb35da --- /dev/null +++ b/packages/core/src/__tests__/inlineCompletion.test.ts @@ -0,0 +1,81 @@ +import { + INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE, + type BlockDecoration, + type InlineDecoration, +} from "@pen/types"; +import { describe, expect, it } from "vitest"; +import { createEditor, ensureInlineCompletionController } from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +describe("inline completion decorations", () => { + it("marks the suggestion block while an inline suggestion is visible", () => { + const editor = createEditor({ preset: noDefaultExtensionsPreset }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + + try { + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 5, + text: " there", + type: "inline", + }); + + const decorations = inlineCompletion.controller.buildDecorations(); + const blockDecoration = decorations.find( + (decoration): decoration is BlockDecoration => + decoration.type === "block", + ); + const inlineDecoration = decorations.find( + (decoration): decoration is InlineDecoration => + decoration.type === "inline", + ); + + expect(blockDecoration?.attributes).toMatchObject({ + [INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE]: true, + }); + expect(inlineDecoration?.attributes["data-suggestion-id"]).toBe( + "suggestion-1", + ); + } finally { + inlineCompletion.release(); + editor.destroy(); + } + }); + + it("keeps a block marker for block suggestions without inline anchors", () => { + const editor = createEditor({ preset: noDefaultExtensionsPreset }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + + try { + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 0, + text: "A new paragraph", + type: "block", + }); + + expect(inlineCompletion.controller.buildDecorations()).toEqual([ + { + type: "block", + blockId, + attributes: { + [INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE]: true, + }, + }, + ]); + } finally { + inlineCompletion.release(); + editor.destroy(); + } + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts index b9e362d..ee1cc71 100644 --- a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts @@ -580,6 +580,53 @@ describe("@pen/ai-autocomplete", () => { editor.destroy(); }); + it("does not split a short partial word when normalizing prose suggestions", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "nd timeline" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "a" }]); + editor.selectText(blockId, 1, 1); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === "nd timeline", + ); + + editor.destroy(); + }); + it("rejects tiny single-word prose suggestions", async () => { let activeEditor: ReturnType | null = null; const fieldEditor = { diff --git a/packages/extensions/ai-autocomplete/src/promptBuilder.test.ts b/packages/extensions/ai-autocomplete/src/promptBuilder.test.ts new file mode 100644 index 0000000..8920c15 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/promptBuilder.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import { builtinAutocompleteProviders } from "./providers/builtins"; +import { AutocompleteProviderRegistry } from "./providers/registry"; + +describe("buildAutocompleteMessages", () => { + it("includes neighboring block text for local draft context", async () => { + const { messages } = await buildAutocompleteMessages({ + context: { + editor: {} as never, + blockId: "block-1", + blockType: "paragraph", + offset: 9, + prefixText: "Hey Jason", + suffixText: "", + previousBlockText: "Earlier accepted reply text.", + nextBlockText: "Original quoted message text.", + }, + registry: new AutocompleteProviderRegistry(builtinAutocompleteProviders), + maxProviderChars: 500, + maxProviderTimeMs: 10, + }); + + const userMessage = String(messages[1]?.content ?? ""); + + expect(userMessage).toContain( + 'previous_block="Earlier accepted reply text."', + ); + expect(userMessage).toContain( + 'next_block="Original quoted message text."', + ); + expect(userMessage.match(/previous_block=/g)).toHaveLength(1); + expect(userMessage.match(/next_block=/g)).toHaveLength(1); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/promptBuilder.ts b/packages/extensions/ai-autocomplete/src/promptBuilder.ts index a6cdb62..e91d18f 100644 --- a/packages/extensions/ai-autocomplete/src/promptBuilder.ts +++ b/packages/extensions/ai-autocomplete/src/promptBuilder.ts @@ -153,6 +153,12 @@ function buildPrompt( "cursor_here=true", `suffix=${JSON.stringify(context.suffixText)}`, ]; + if (context.previousBlockText.trim().length > 0) { + sections.push(`previous_block=${JSON.stringify(context.previousBlockText)}`); + } + if (context.nextBlockText.trim().length > 0) { + sections.push(`next_block=${JSON.stringify(context.nextBlockText)}`); + } if (mode === "continuation") { sections.push( diff --git a/packages/extensions/ai-autocomplete/src/providers/builtins.ts b/packages/extensions/ai-autocomplete/src/providers/builtins.ts index 178029e..65f7c2c 100644 --- a/packages/extensions/ai-autocomplete/src/providers/builtins.ts +++ b/packages/extensions/ai-autocomplete/src/providers/builtins.ts @@ -14,23 +14,4 @@ export const builtinAutocompleteProviders: readonly AutocompleteContextProvider[ }), provide: (ctx) => `block_type=${ctx.blockType ?? "unknown"}`, }), - createAutocompleteProvider({ - id: "neighbor-blocks", - priority: 50, - describe: () => ({ - id: "neighbor-blocks", - description: "Adds nearby block text around the cursor block", - kind: "local", - }), - provide: (ctx) => { - const sections: string[] = []; - if (ctx.previousBlockText) { - sections.push(`previous_block=${JSON.stringify(ctx.previousBlockText)}`); - } - if (ctx.nextBlockText) { - sections.push(`next_block=${JSON.stringify(ctx.nextBlockText)}`); - } - return sections.length > 0 ? sections.join("\n") : null; - }, - }), ]; diff --git a/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx b/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx index f0cb294..46ffcd9 100644 --- a/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx +++ b/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx @@ -3,7 +3,7 @@ import React, { act } from "react"; import { afterEach, describe, expect, it } from "vitest"; import { createRoot } from "react-dom/client"; -import { createEditor } from "@pen/core"; +import { createEditor, ensureInlineCompletionController } from "@pen/core"; import type { BlockHandle, BlockRenderContext } from "@pen/types"; import { defaultPreset } from "@pen/preset-default"; import { InlineContent } from "../primitives/editor/inlineContent"; @@ -29,7 +29,7 @@ function PlaceholderParagraphRenderer( >
); @@ -78,6 +78,187 @@ describe("@pen/react placeholder behavior", () => { editor.destroy(); }); + it("hides the document empty placeholder while an inline completion is visible", async () => { + registerRenderer("paragraph", PlaceholderParagraphRenderer); + + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(1); + + await act(async () => { + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 0, + text: "", + type: "inline", + previewBlocks: [ + { + id: "preview-1", + text: "A suggested opening", + blockType: "paragraph", + }, + ], + }); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(0); + + await act(async () => { + inlineCompletion.controller.dismissSuggestion(); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(1); + + await act(async () => { + root.unmount(); + }); + container.remove(); + inlineCompletion.release(); + editor.destroy(); + }); + + it("hides schema placeholders while an inline completion is visible", async () => { + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + editor.selectText(blockId, 0, 0); + }); + + const placeholders = container.querySelectorAll("[data-placeholder-visible]"); + expect(placeholders).toHaveLength(1); + expect(placeholders[0]?.getAttribute("data-placeholder")).toContain( + "/ for commands", + ); + + await act(async () => { + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 0, + text: "", + type: "inline", + previewBlocks: [ + { + id: "preview-1", + text: "A suggested opening", + blockType: "paragraph", + }, + ], + }); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(0); + + await act(async () => { + root.unmount(); + }); + container.remove(); + inlineCompletion.release(); + editor.destroy(); + }); + + it("renders inline completion text on an empty block surface", async () => { + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + editor.selectText(blockId, 0, 0); + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 0, + text: "Thanks for the update.", + type: "inline", + }); + }); + + const suggestionSurface = container.querySelector(".pen-ephemeral-suggestion"); + expect(suggestionSurface?.getAttribute("data-suggestion-id")).toBe( + "suggestion-1", + ); + expect(suggestionSurface?.getAttribute("data-suggestion-text")).toBe( + "Thanks for the update.", + ); + expect(suggestionSurface?.getAttribute("data-suggestion-placement")).toBe( + "after", + ); + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(0); + + await act(async () => { + root.unmount(); + }); + container.remove(); + inlineCompletion.release(); + editor.destroy(); + }); + it("does not treat a single structural block as an empty document", async () => { const editor = createEditor({ preset: defaultPreset({ @@ -213,7 +394,7 @@ describe("@pen/react placeholder behavior", () => { const placeholders = container.querySelectorAll("[data-placeholder-visible]"); expect(placeholders).toHaveLength(1); expect(placeholders[0]?.getAttribute("data-placeholder")).toBe( - "Type / for commands", + "Type ⌘I for AI Agent, or / for commands", ); expect( placeholders[0]?.closest("[data-block-id]")?.getAttribute("data-block-id"), @@ -225,4 +406,75 @@ describe("@pen/react placeholder behavior", () => { container.remove(); editor.destroy(); }); + + it("hides active empty block placeholders while any inline completion is visible", async () => { + registerRenderer("paragraph", PlaceholderParagraphRenderer); + + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + const inlineCompletion = ensureInlineCompletionController(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + editor.apply([ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + ]); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + editor.selectText(secondBlockId, 0, 0); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(1); + + await act(async () => { + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId: firstBlockId, + offset: 5, + text: " there", + type: "inline", + }); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(0); + + await act(async () => { + root.unmount(); + }); + container.remove(); + inlineCompletion.release(); + editor.destroy(); + }); }); diff --git a/packages/rendering/react/src/__tests__/placeholderVisibility.test.ts b/packages/rendering/react/src/__tests__/placeholderVisibility.test.ts new file mode 100644 index 0000000..0a083d6 --- /dev/null +++ b/packages/rendering/react/src/__tests__/placeholderVisibility.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + resolveInlinePlaceholderVisibility, + type InlinePlaceholderVisibilityOptions, +} from "../utils/placeholderVisibility"; + +const baseOptions = { + blockTextEmpty: true, + isDocumentEmpty: false, + isFirstBlock: false, + isFocusedBlock: true, + hasEmptyPlaceholder: true, + hasExplicitPlaceholder: false, + hasSchemaPlaceholder: true, + suppressPlaceholders: false, +} satisfies InlinePlaceholderVisibilityOptions; + +describe("resolveInlinePlaceholderVisibility", () => { + it("suppresses all placeholder variants when requested", () => { + expect( + resolveInlinePlaceholderVisibility({ + ...baseOptions, + isDocumentEmpty: true, + isFirstBlock: true, + hasExplicitPlaceholder: true, + suppressPlaceholders: true, + }), + ).toEqual({ + showDocumentPlaceholder: false, + showExplicitPlaceholder: false, + showBlockPlaceholder: false, + }); + }); + + it("prefers the document placeholder for the first empty document block", () => { + expect( + resolveInlinePlaceholderVisibility({ + ...baseOptions, + isDocumentEmpty: true, + isFirstBlock: true, + hasExplicitPlaceholder: true, + }), + ).toEqual({ + showDocumentPlaceholder: true, + showExplicitPlaceholder: false, + showBlockPlaceholder: false, + }); + }); + + it("shows explicit and schema placeholders only for focused empty blocks", () => { + expect( + resolveInlinePlaceholderVisibility({ + ...baseOptions, + hasExplicitPlaceholder: true, + }), + ).toEqual({ + showDocumentPlaceholder: false, + showExplicitPlaceholder: true, + showBlockPlaceholder: false, + }); + + expect(resolveInlinePlaceholderVisibility(baseOptions)).toEqual({ + showDocumentPlaceholder: false, + showExplicitPlaceholder: false, + showBlockPlaceholder: true, + }); + }); +}); diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 36e813d..7e7b4b1 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -10,19 +10,22 @@ import { useBlockDecorations } from "../../hooks/useBlockDecorations"; import { useSelection } from "../../hooks/useSelection"; import { useBlockTextSnapshot } from "../../hooks/useBlockTextSnapshot"; import { useFieldEditorState } from "../../hooks/useFieldEditorState"; +import { useInlineCompletionState } from "../../hooks/useInlineCompletionState"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { DATA_ATTRS } from "../../utils/dataAttributes"; import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; import { applyInlineDecorationsToDeltas } from "../../utils/inlineDecorations"; +import { resolveInlinePlaceholderVisibility } from "../../utils/placeholderVisibility"; export interface InlineContentProps extends AsChildProps { blockId: string; + className?: string; placeholder?: string; ref?: React.Ref; } export function InlineContent(props: InlineContentProps) { - const { blockId, placeholder: placeholderProp, ...rest } = props; + const { blockId, className, placeholder: placeholderProp, ...rest } = props; const { editor } = useEditorContext(); const { emptyPlaceholder, isEmpty: isDocumentEmpty } = useEditorContentContext(); @@ -33,6 +36,7 @@ export function InlineContent(props: InlineContentProps) { const blockCommit = useBlockCommitState(editor, blockId); const blockDecorations = useBlockDecorations(editor, blockId); const textSnapshot = useBlockTextSnapshot(editor, blockId); + const visibleInlineCompletion = useInlineCompletionState(editor); const elementRef = useRef(null); const previousCommitRevisionRef = useRef(blockCommit.revision); const previousRenderedDeltasSignatureRef = useRef(null); @@ -49,19 +53,27 @@ export function InlineContent(props: InlineContentProps) { selection.focus.blockId === blockId); const blockTextEmpty = !textSnapshot.text || textSnapshot.text === "\u200B"; - const showDocumentPlaceholder = - blockTextEmpty && isFirstBlock && isDocumentEmpty && !!emptyPlaceholder; - const showExplicitPlaceholder = - blockTextEmpty && - isFocusedBlock && - !!placeholderProp && - !showDocumentPlaceholder; - const showBlockPlaceholder = - blockTextEmpty && - isFocusedBlock && - !placeholderProp && - !!schemaPlaceholder && - !showDocumentPlaceholder; + const emptyInlineCompletionText = + visibleInlineCompletion?.type === "inline" && + visibleInlineCompletion.blockId === blockId && + blockTextEmpty && + visibleInlineCompletion.text.length > 0 + ? visibleInlineCompletion.text + : null; + const { + showDocumentPlaceholder, + showExplicitPlaceholder, + showBlockPlaceholder, + } = resolveInlinePlaceholderVisibility({ + blockTextEmpty, + isDocumentEmpty, + isFirstBlock, + isFocusedBlock, + hasEmptyPlaceholder: !!emptyPlaceholder, + hasExplicitPlaceholder: !!placeholderProp, + hasSchemaPlaceholder: !!schemaPlaceholder, + suppressPlaceholders: visibleInlineCompletion !== null, + }); const placeholder = showDocumentPlaceholder ? emptyPlaceholder @@ -77,9 +89,9 @@ export function InlineContent(props: InlineContentProps) { const renderedDeltas = inlineDecorations.length > 0 ? applyInlineDecorationsToDeltas( - textSnapshot.deltas, - inlineDecorations, - ) + textSnapshot.deltas, + inlineDecorations, + ) : textSnapshot.deltas; const renderedDeltasText = getDeltaText(renderedDeltas); const renderedDeltasSignature = getDeltaSignature(renderedDeltas); @@ -120,7 +132,7 @@ export function InlineContent(props: InlineContentProps) { if ( elementRef.current.textContent === renderedDeltasText && previousRenderedDeltasSignatureRef.current === - renderedDeltasSignature + renderedDeltasSignature ) { return; } @@ -179,18 +191,35 @@ export function InlineContent(props: InlineContentProps) { [DATA_ATTRS.inlineContent]: "", [DATA_ATTRS.fieldEditorSurface]: "", ...fieldEditorTextEntryAttrs(isActiveSurface), + className: getInlineContentClassName(className, emptyInlineCompletionText), + "data-suggestion-id": emptyInlineCompletionText + ? visibleInlineCompletion?.id + : undefined, + "data-suggestion-text": emptyInlineCompletionText ?? undefined, + "data-suggestion-type": emptyInlineCompletionText ? "inline" : undefined, + "data-suggestion-placement": emptyInlineCompletionText ? "after" : undefined, [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, "data-placeholder": showPlaceholder ? placeholder : undefined, style: showPlaceholder ? { - position: "relative" as const, - } + position: "relative" as const, + } : undefined, }; return renderAsChild({ ...rest, ref: elementRef }, "span", primitiveProps); } +function getInlineContentClassName( + className: string | undefined, + emptyInlineCompletionText: string | null, +): string | undefined { + if (!emptyInlineCompletionText) { + return className; + } + return [className, "pen-ephemeral-suggestion"].filter(Boolean).join(" "); +} + function resolveSchemaPlaceholder( editor: Pick, blockId: string, diff --git a/packages/rendering/react/src/utils/placeholderVisibility.ts b/packages/rendering/react/src/utils/placeholderVisibility.ts new file mode 100644 index 0000000..60362b6 --- /dev/null +++ b/packages/rendering/react/src/utils/placeholderVisibility.ts @@ -0,0 +1,51 @@ +export interface InlinePlaceholderVisibilityOptions { + blockTextEmpty: boolean; + isDocumentEmpty: boolean; + isFirstBlock: boolean; + isFocusedBlock: boolean; + hasEmptyPlaceholder: boolean; + hasExplicitPlaceholder: boolean; + hasSchemaPlaceholder: boolean; + suppressPlaceholders: boolean; +} + +export interface InlinePlaceholderVisibility { + showDocumentPlaceholder: boolean; + showExplicitPlaceholder: boolean; + showBlockPlaceholder: boolean; +} + +export function resolveInlinePlaceholderVisibility( + options: InlinePlaceholderVisibilityOptions, +): InlinePlaceholderVisibility { + if (options.suppressPlaceholders) { + return { + showDocumentPlaceholder: false, + showExplicitPlaceholder: false, + showBlockPlaceholder: false, + }; + } + + const showDocumentPlaceholder = + options.blockTextEmpty && + options.isFirstBlock && + options.isDocumentEmpty && + options.hasEmptyPlaceholder; + const showExplicitPlaceholder = + options.blockTextEmpty && + options.isFocusedBlock && + options.hasExplicitPlaceholder && + !showDocumentPlaceholder; + const showBlockPlaceholder = + options.blockTextEmpty && + options.isFocusedBlock && + !options.hasExplicitPlaceholder && + options.hasSchemaPlaceholder && + !showDocumentPlaceholder; + + return { + showDocumentPlaceholder, + showExplicitPlaceholder, + showBlockPlaceholder, + }; +} diff --git a/packages/schema/default/src/blocks/paragraph.ts b/packages/schema/default/src/blocks/paragraph.ts index 861eda6..4722653 100644 --- a/packages/schema/default/src/blocks/paragraph.ts +++ b/packages/schema/default/src/blocks/paragraph.ts @@ -1,17 +1,17 @@ import { defineBlock } from "@pen/types"; export const paragraph = defineBlock("paragraph", { - content: "inline", - fieldEditor: "richtext", - placeholder: "Type / for commands", - display: { - title: "Paragraph", - description: "Plain text paragraph", - group: "basic", - aliases: ["p", "text"], - }, - serialize: { - toMarkdown: (block) => block.content ?? "", - toHTML: (block) => `

${block.content ?? ""}

`, - }, + content: "inline", + fieldEditor: "richtext", + placeholder: "Type ⌘I for AI Agent, or / for commands", + display: { + title: "Paragraph", + description: "Plain text paragraph", + group: "basic", + aliases: ["p", "text"], + }, + serialize: { + toMarkdown: (block) => block.content ?? "", + toHTML: (block) => `

${block.content ?? ""}

`, + }, }); diff --git a/packages/types/src/constants/decorations.ts b/packages/types/src/constants/decorations.ts new file mode 100644 index 0000000..7e5dce3 --- /dev/null +++ b/packages/types/src/constants/decorations.ts @@ -0,0 +1,2 @@ +export const INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE = + "data-pen-inline-completion-visible"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ca27531..7e8ca4a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -60,3 +60,6 @@ export { HISTORY_CONTROLLER_SLOT, HISTORY_ORIGIN_TAG, } from "./constants/slots"; +export { + INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE, +} from "./constants/decorations"; diff --git a/packages/types/src/types/index.ts b/packages/types/src/types/index.ts index d4e5bd0..328c6d0 100644 --- a/packages/types/src/types/index.ts +++ b/packages/types/src/types/index.ts @@ -308,6 +308,9 @@ export type { } from "./persistence"; // ── Decorations ───────────────────────────────────────────── +export { + INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE, +} from "../constants/decorations"; export type { Decoration, InlineDecoration, From 4bb7c8b335364ab437fa0c9341fb64c7abbeb6c9 Mon Sep 17 00:00:00 2001 From: krijn Date: Mon, 18 May 2026 14:36:59 +0200 Subject: [PATCH 16/20] Enhance inline atom handling and DOM reconciliation - Added `notifyDomReconciled` method to the `FieldEditorDomController` interface for better synchronization after DOM updates. - Updated `ContentEditableBackend` and `EditContextBackend` to notify the editor of DOM reconciliations, improving selection management. - Introduced inline atom data management functions to streamline the handling of inline elements during reconciliation. - Enhanced key handling to support selection of inline atoms with arrow keys, improving user experience during text navigation. - Refactored various components to improve readability and maintainability, ensuring consistent handling of inline atom data. --- .../field-editor/contenteditableBackend.ts | 19 ++ .../dom/src/field-editor/controller.ts | 1 + .../src/field-editor/editContextBackend.ts | 27 +- .../dom/src/field-editor/fieldEditorImpl.ts | 8 + .../dom/src/field-editor/inlineAtomDom.ts | 89 +++++- .../dom/src/field-editor/keyHandling.ts | 111 ++++++- .../dom/src/field-editor/reconciler.ts | 50 ++- .../dom/src/field-editor/sessionReconciler.ts | 11 +- .../rendering/dom/src/field-editor/store.ts | 2 + .../src/__tests__/inlineAtomEditing.test.tsx | 191 ++++++++++++ .../react/src/__tests__/keyHandling.test.ts | 181 ++++++++--- .../react/src/context/editorContext.ts | 18 +- .../react/src/hooks/useFieldEditorState.ts | 6 +- packages/rendering/react/src/index.ts | 3 + .../react/src/primitives/editor/index.ts | 15 +- .../src/primitives/editor/inlineContent.tsx | 294 +++++++++++++++--- .../react/src/primitives/editor/root.tsx | 4 + 17 files changed, 911 insertions(+), 119 deletions(-) diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index 9ec589a..d2352ee 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -99,6 +99,9 @@ export class ContentEditableBackend implements InputBackend { fullReconcileToDOM(this.ytext, element, this.editor.schema, { inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); this.restoreDOMSelectionFromEditor(); requestAnimationFrame(() => { this.isApplyingSelection--; @@ -454,6 +457,9 @@ export class ContentEditableBackend implements InputBackend { fullReconcileToDOM(this.ytext, this.element!, this.editor.schema, { inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); } this.compositionStartText = null; @@ -493,6 +499,14 @@ export class ContentEditableBackend implements InputBackend { if (!this.element || !this.ytext) return; const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); if (isHistory) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.restoreDOMSelectionFromEditor(); return; } @@ -505,6 +519,7 @@ export class ContentEditableBackend implements InputBackend { preserveSelection: true, inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); if ( this.pendingSelectionOverride != null || event.transaction?.origin === "remote" || @@ -525,6 +540,7 @@ export class ContentEditableBackend implements InputBackend { preserveSelection: true, inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); } if ( @@ -637,6 +653,9 @@ export class ContentEditableBackend implements InputBackend { preserveSelection: true, inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); return true; } diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index 6090454..6e7e59c 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -64,6 +64,7 @@ export interface FieldEditorDomController extends FieldEditorSelectionState { anchorOffset: number, focusOffset: number, ): void; + notifyDomReconciled(blockId?: string): void; activateTextSelection( blockId: string, anchorOffset: number, diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index f7e7cba..3bd919e 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -173,6 +173,9 @@ export class EditContextBackend implements InputBackend { fullReconcileToDOM(this.ytext, element, this.editor.schema, { inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); this.isApplyingSelection++; this.updateSelection(); element.focus({ preventScroll: true }); @@ -883,6 +886,12 @@ export class EditContextBackend implements InputBackend { focusOffset: clampedSelectionEnd, } : null; + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + this.restoreDOMCaret(); return; } @@ -896,6 +905,9 @@ export class EditContextBackend implements InputBackend { preserveSelection: true, inlineDecorations: this.getInlineDecorationsForBlock(), }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); } if ( @@ -1074,17 +1086,16 @@ export class EditContextBackend implements InputBackend { const liveRange = liveDomOffsets ? directionalSelectionToRange(liveDomOffsets) : null; - const programmaticInputRange = - isFieldEditorTextEditingKey(event) - ? this.fieldEditor.resolveProgrammaticInputRange( - blockId, - liveRange, - ) - : null; + const programmaticInputRange = isFieldEditorTextEditingKey(event) + ? this.fieldEditor.resolveProgrammaticInputRange(blockId, liveRange) + : null; if (programmaticInputRange) { return { range: programmaticInputRange, - nextSelection: rangeToSelection(blockId, programmaticInputRange), + nextSelection: rangeToSelection( + blockId, + programmaticInputRange, + ), shouldSyncEditContextSelection: true, }; } diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index b1c9bd1..4539576 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -74,6 +74,7 @@ export class FieldEditorImpl implements FieldEditorSession { private _unsubscribeHistoryApplied: Unsubscribe | null = null; private _pendingMarks: Record = {}; private _syncDomVersion = 0; + private _domSyncVersion = 0; private _suppressNextDomSelectionProjection = false; private _pointerSelectionDepth = 0; private _pendingSelectionProjectionVersion: number | null = null; @@ -146,6 +147,7 @@ export class FieldEditorImpl implements FieldEditorSession { shouldProjectSelection: () => this._shouldProjectSelectionAfterReconcile(), projectSelection: () => this._syncDomSelectionOnce(), + notifyDomReconciled: (blockId) => this.notifyDomReconciled(blockId), }); } @@ -976,12 +978,18 @@ export class FieldEditorImpl implements FieldEditorSession { isEditing: this._isEditing, isFocused: this._isFocused, isComposing: this._isComposing, + domSyncVersion: this._domSyncVersion, inputMode: this._inputMode, mode: this._mode, activeCellCoord: this._activeCellCoord, }; } + notifyDomReconciled(_blockId?: string): void { + this._domSyncVersion += 1; + this._emitStateChange(); + } + subscribe(callback: () => void): Unsubscribe { this._storeListeners.add(callback); return () => this._storeListeners.delete(callback); diff --git a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts index a4391b6..a912202 100644 --- a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts +++ b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts @@ -8,6 +8,12 @@ interface InlineAtomInsert { props: Record; } +export interface InlineAtomElementData extends InlineAtomInsert { + text: string; +} + +const inlineAtomElementData = new WeakMap(); + export function resolveInlineAtomInsert( insert: unknown, ): InlineAtomInsert | null { @@ -53,17 +59,84 @@ export function createInlineAtomElement( } element.setAttribute(DATA_ATTRS.inlineAtomType, atom.type); - element.setAttribute("aria-label", getInlineAtomText(atom, registry)); - element.textContent = getInlineAtomText(atom, registry); + const text = getInlineAtomText(atom, registry); + element.setAttribute("aria-label", text); + element.textContent = text; + inlineAtomElementData.set(element, { + ...atom, + text, + }); return element; } +export function getInlineAtomElementData( + element: Element, +): InlineAtomElementData | null { + return element instanceof HTMLElement + ? (inlineAtomElementData.get(element) ?? null) + : null; +} + +export function copyInlineAtomElementData( + source: Element, + target: Element, +): void { + if (!(target instanceof HTMLElement)) { + return; + } + + const data = getInlineAtomElementData(source); + if (!data) { + return; + } + + inlineAtomElementData.set(target, { + type: data.type, + props: { ...data.props }, + text: data.text, + }); +} + +export function areInlineAtomElementDataEqual( + left: Element, + right: Element, +): boolean { + const leftData = getInlineAtomElementData(left); + const rightData = getInlineAtomElementData(right); + if (!leftData || !rightData) { + return leftData === rightData; + } + + return ( + leftData.type === rightData.type && + leftData.text === rightData.text && + shallowEqualRecords(leftData.props, rightData.props) + ); +} + export function isInlineAtomNode(node: Node | null): node is HTMLElement { return ( node instanceof HTMLElement && node.hasAttribute(DATA_ATTRS.inlineAtom) ); } +function shallowEqualRecords( + left: Record, + right: Record, +): boolean { + if (left === right) { + return true; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => Object.is(left[key], right[key])); +} + export function getLogicalNodeLength(node: Node): number { if (node.nodeType === Node.TEXT_NODE) { return node.textContent?.length ?? 0; @@ -94,8 +167,12 @@ export function domPointToLogicalOffset( targetOffset: number, ): number { const atomAncestor = findInlineAtomAncestor(targetNode, container); - if (atomAncestor && atomAncestor !== targetNode) { - return getOffsetBeforeNode(container, atomAncestor); + if (atomAncestor) { + const atomOffset = getOffsetBeforeNode(container, atomAncestor); + if (atomAncestor === targetNode) { + return targetOffset <= 0 ? atomOffset : atomOffset + 1; + } + return atomOffset + 1; } const resolved = resolveLogicalOffset(container, targetNode, targetOffset); @@ -202,6 +279,10 @@ function resolveLogicalOffset( targetOffset: number, ): number | null { if (current === targetNode) { + if (isInlineAtomNode(current)) { + return targetOffset <= 0 ? 0 : 1; + } + if (current.nodeType === Node.TEXT_NODE) { return Math.min(targetOffset, current.textContent?.length ?? 0); } diff --git a/packages/rendering/dom/src/field-editor/keyHandling.ts b/packages/rendering/dom/src/field-editor/keyHandling.ts index 5e0e7c6..b65be55 100644 --- a/packages/rendering/dom/src/field-editor/keyHandling.ts +++ b/packages/rendering/dom/src/field-editor/keyHandling.ts @@ -1,6 +1,4 @@ -import { - getInlineCompletionController, -} from "@pen/core"; +import { getInlineCompletionController } from "@pen/core"; import type { Editor, KeyBindingContext } from "@pen/types"; import { COLLECT_KEY_BINDINGS_SLOT_KEY, @@ -12,6 +10,7 @@ import { applyEnterBehavior, applyListTabBehavior, moveCaretAcrossBlocks, + normalizeInlineRange, type SelectionRange, } from "./commands"; import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; @@ -24,6 +23,7 @@ export function handleFieldEditorKeyDown(options: { ytext: { length: number; toString(): string; + toDelta(): Array<{ insert?: string | Record }>; insert(offset: number, text: string): void; delete(offset: number, length: number): void; }; @@ -38,10 +38,7 @@ export function handleFieldEditorKeyDown(options: { autocomplete?.dismiss("typing"); } - if ( - !event.defaultPrevented && - handleHistoryShortcut(editor, event) - ) { + if (!event.defaultPrevented && handleHistoryShortcut(editor, event)) { return true; } @@ -219,6 +216,19 @@ export function handleFieldEditorKeyDown(options: { !event.ctrlKey && !event.altKey ) { + if ( + event.key === "ArrowLeft" && + selectInlineAtomWithArrowKey({ + blockId, + event, + fieldEditor, + range, + ytext, + }) + ) { + return true; + } + const target = moveCaretAcrossBlocks(editor, { blockId, ytext, @@ -247,6 +257,19 @@ export function handleFieldEditorKeyDown(options: { !event.ctrlKey && !event.altKey ) { + if ( + event.key === "ArrowRight" && + selectInlineAtomWithArrowKey({ + blockId, + event, + fieldEditor, + range, + ytext, + }) + ) { + return true; + } + const target = moveCaretAcrossBlocks(editor, { blockId, ytext, @@ -271,6 +294,80 @@ export function handleFieldEditorKeyDown(options: { return handleEditorKeyBindings(editor, event, { includeSelectAll: false }); } +function selectInlineAtomWithArrowKey(options: { + blockId: string; + event: KeyboardEvent; + fieldEditor: FieldEditorKeyboardController; + range: SelectionRange | null; + ytext: { + length: number; + toString(): string; + toDelta(): Array<{ insert?: string | Record }>; + }; +}): boolean { + const { blockId, event, fieldEditor, ytext } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!range) { + return false; + } + + const direction = event.key === "ArrowLeft" ? "previous" : "next"; + if (range.start !== range.end) { + if (!isInlineAtomRange(ytext, range.start, range.end)) { + return false; + } + const offset = direction === "previous" ? range.start : range.end; + fieldEditor.activateTextSelection(blockId, offset, offset); + return true; + } + + const atomOffset = direction === "previous" ? range.start - 1 : range.start; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + + fieldEditor.activateTextSelection(blockId, atomRange.start, atomRange.end); + return true; +} + +function isInlineAtomRange( + ytext: { toDelta(): Array<{ insert?: string | Record }> }, + start: number, + end: number, +): boolean { + const atomRange = getInlineAtomRangeAtOffset(ytext, start); + return atomRange?.end === end; +} + +function getInlineAtomRangeAtOffset( + ytext: { toDelta(): Array<{ insert?: string | Record }> }, + targetOffset: number, +): SelectionRange | null { + if (targetOffset < 0) { + return null; + } + + let offset = 0; + for (const delta of ytext.toDelta()) { + if (delta.insert == null) { + continue; + } + + if (typeof delta.insert === "string") { + offset += delta.insert.length; + continue; + } + + if (offset === targetOffset) { + return { start: offset, end: offset + 1 }; + } + offset += 1; + } + + return null; +} + function syncAcceptedInlineCompletionSelection( editor: Editor, fieldEditor: FieldEditorKeyboardController, diff --git a/packages/rendering/dom/src/field-editor/reconciler.ts b/packages/rendering/dom/src/field-editor/reconciler.ts index 2ec28b9..d4aea25 100644 --- a/packages/rendering/dom/src/field-editor/reconciler.ts +++ b/packages/rendering/dom/src/field-editor/reconciler.ts @@ -6,9 +6,13 @@ import { INLINE_DECORATION_ATTRIBUTE_KEY, } from "../utils/inlineDecorations"; import { + areInlineAtomElementDataEqual, + copyInlineAtomElementData, createInlineAtomElement, domPointToLogicalOffset, findLogicalDOMPoint, + getLogicalNodeLength, + isInlineAtomNode, } from "./inlineAtomDom"; // ── Fast path: event-driven delta application ────────────── @@ -26,8 +30,7 @@ export function applyDeltaToDOM( let remaining = entry.retain; while (remaining > 0 && childIndex < element.childNodes.length) { const span = element.childNodes[childIndex]; - const spanText = span.textContent ?? ""; - const available = spanText.length - textOffset; + const available = getLogicalNodeLength(span) - textOffset; if (remaining < available) { textOffset += remaining; @@ -56,6 +59,16 @@ export function applyDeltaToDOM( existing.slice(textOffset); textOffset += text.length; } else if (span && span.nodeType === Node.ELEMENT_NODE) { + if (isInlineAtomNode(span)) { + if (textOffset !== 0) return false; + element.insertBefore( + document.createTextNode(text), + span, + ); + childIndex++; + textOffset = 0; + continue; + } const leaf = deepLeafText(span); if (!leaf) return false; const existing = leaf.textContent ?? ""; @@ -89,13 +102,19 @@ export function applyDeltaToDOM( let remaining = entry.delete; while (remaining > 0 && childIndex < element.childNodes.length) { const span = element.childNodes[childIndex]; + if (isInlineAtomNode(span)) { + if (textOffset !== 0) return false; + element.removeChild(span); + remaining -= 1; + continue; + } const leaf = span.nodeType === Node.TEXT_NODE ? span : deepLeafText(span); if (!leaf) return false; const existing = leaf.textContent ?? ""; - const available = existing.length - textOffset; + const available = getLogicalNodeLength(span) - textOffset; if (remaining < available) { leaf.textContent = @@ -149,9 +168,9 @@ export function fullReconcileToDOM( const renderedDeltas = options?.inlineDecorations && options.inlineDecorations.length > 0 ? applyInlineDecorationsToDeltas( - textDeltas, - options.inlineDecorations, - ) + textDeltas, + options.inlineDecorations, + ) : textDeltas; fullReconcileDeltasToDOM(renderedDeltas, element, registry, options); } @@ -356,17 +375,22 @@ function patchDOM(target: HTMLElement, source: DocumentFragment): void { const targetNode = targetNodes[ti]; if (nodesStructurallyEqual(targetNode, sourceNode)) { + if ( + isInlineAtomNode(targetNode) && + isInlineAtomNode(sourceNode) + ) { + copyInlineAtomElementData(sourceNode, targetNode); + } updateTextContent(targetNode, sourceNode); ti++; si++; } else { - const cloned = sourceNode.cloneNode(true); - target.replaceChild(cloned, targetNode); + target.replaceChild(sourceNode, targetNode); ti++; si++; } } else { - target.appendChild(sourceNode.cloneNode(true)); + target.appendChild(sourceNode); si++; } } @@ -382,6 +406,14 @@ function nodesStructurallyEqual(a: Node, b: Node): boolean { if (a.nodeType === Node.ELEMENT_NODE) { const elA = a as Element; const elB = b as Element; + if (isInlineAtomNode(elA) || isInlineAtomNode(elB)) { + if (!isInlineAtomNode(elA) || !isInlineAtomNode(elB)) { + return false; + } + if (!areInlineAtomElementDataEqual(elA, elB)) { + return false; + } + } if (elA.tagName !== elB.tagName) return false; if (elA.attributes.length !== elB.attributes.length) return false; for (let i = 0; i < elA.attributes.length; i++) { diff --git a/packages/rendering/dom/src/field-editor/sessionReconciler.ts b/packages/rendering/dom/src/field-editor/sessionReconciler.ts index fdb77c4..979531e 100644 --- a/packages/rendering/dom/src/field-editor/sessionReconciler.ts +++ b/packages/rendering/dom/src/field-editor/sessionReconciler.ts @@ -17,6 +17,7 @@ interface SessionReconcilerOptions { shouldPreserveSelection: () => boolean; shouldProjectSelection: () => boolean; projectSelection: () => void; + notifyDomReconciled?: (blockId: string) => void; } export class SessionReconciler { @@ -160,7 +161,10 @@ export class SessionReconciler { } this.reconcileBlock(blockId, preserveSelection); } - if (shouldProjectSelection && this.options.shouldProjectSelection()) { + if ( + shouldProjectSelection && + this.options.shouldProjectSelection() + ) { this.options.projectSelection(); } return; @@ -183,6 +187,7 @@ export class SessionReconciler { preserveSelection, inlineDecorations: this.getInlineDecorations(blockId), }); + this.options.notifyDomReconciled?.(blockId); continue; } this.reconcileBlock(blockId, preserveSelection); @@ -202,6 +207,7 @@ export class SessionReconciler { preserveSelection, inlineDecorations: this.getInlineDecorations(blockId), }); + this.options.notifyDomReconciled?.(blockId); } private getInlineDecorations(blockId: string): readonly InlineDecoration[] { @@ -209,7 +215,8 @@ export class SessionReconciler { .getDecorations() .forBlock(blockId) .filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", + (decoration): decoration is InlineDecoration => + decoration.type === "inline", ); } } diff --git a/packages/rendering/dom/src/field-editor/store.ts b/packages/rendering/dom/src/field-editor/store.ts index a443ef8..287f045 100644 --- a/packages/rendering/dom/src/field-editor/store.ts +++ b/packages/rendering/dom/src/field-editor/store.ts @@ -6,6 +6,7 @@ export interface FieldEditorStoreSnapshot { isEditing: boolean; isFocused: boolean; isComposing: boolean; + domSyncVersion: number; inputMode: "richtext" | "code" | "table" | "none"; mode: "inactive" | "single" | "expanded" | "block"; activeCellCoord: { blockId: string; row: number; col: number } | null; @@ -26,4 +27,5 @@ export interface FieldEditorStore extends FieldEditor { }, ): void; collapseSelectionToPoint(point: { blockId: string; offset: number }): void; + notifyDomReconciled(blockId?: string): void; } diff --git a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx index e4d0b80..df4532f 100644 --- a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx +++ b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx @@ -5,6 +5,15 @@ import { createRoot } from "react-dom/client"; import { describe, expect, it } from "vitest"; import { createEditor } from "@pen/core"; import { defaultPreset } from "@pen/preset-default"; +import { + getInlineAtomElementData, + getLogicalTextContent, + INLINE_ATOM_REPLACEMENT_TEXT, +} from "@pen/dom/field-editor/inlineAtomDom"; +import { + applyDeltaToDOM, + fullReconcileDeltasToDOM, +} from "@pen/dom/field-editor/reconciler"; import { DATA_ATTRS } from "../utils/dataAttributes"; import { domPointToOffset, @@ -87,6 +96,188 @@ describe("Pen inline atom editing", () => { } }); + it("renders inline atoms with configured React renderers", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + ( + + {props.label as string}:{text} + + ), + }} + > + + , + ); + await flushAnimationFrames(2); + }); + + const atom = container.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + const renderedAtom = container.querySelector( + "[data-testid='mention-renderer']", + ); + const inlineElement = container.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + + expect(atom).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + expect(renderedAtom?.textContent).toBe("Ada:@Ada"); + expect(renderedAtom?.getAttribute("data-selected")).toBe("false"); + expect(atom?.textContent).toBe("Ada:@Ada"); + expect(domPointToOffset(inlineElement!, atom!, 0)).toBe(1); + expect(domPointToOffset(inlineElement!, atom!, 1)).toBe(2); + expect( + domPointToOffset( + inlineElement!, + renderedAtom?.firstChild ?? renderedAtom!, + 1, + ), + ).toBe(2); + expect(getInlineAtomElementData(atom!)).toEqual({ + type: "mention", + props: { id: "user-1", label: "Ada" }, + text: "@Ada", + }); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 1 }, + { blockId, offset: 2 }, + ); + await flushAnimationFrames(2); + }); + + expect(renderedAtom?.getAttribute("data-selected")).toBe("true"); + expect(atom?.hasAttribute(DATA_ATTRS.selected)).toBe(true); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("applies text deltas around inline atoms at logical boundaries", () => { + const editor = createPresetEditor(); + const element = document.createElement("span"); + + fullReconcileDeltasToDOM( + [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ], + element, + editor.schema, + ); + + const atom = element.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + expect(atom).not.toBeNull(); + + expect( + applyDeltaToDOM( + [{ retain: 2 }, { insert: "C" }], + element, + editor.schema, + ), + ).toBe(true); + expect(getLogicalTextContent(element)).toBe( + `A${INLINE_ATOM_REPLACEMENT_TEXT}CB`, + ); + expect(getInlineAtomElementData(atom!)).toEqual({ + type: "mention", + props: { id: "user-1", label: "Ada" }, + text: "@Ada", + }); + expect(atom?.textContent).toBe("@Ada"); + + expect( + applyDeltaToDOM( + [{ retain: 1 }, { delete: 1 }], + element, + editor.schema, + ), + ).toBe(true); + expect(getLogicalTextContent(element)).toBe("ACB"); + expect(atom?.isConnected).toBe(false); + + editor.destroy(); + }); + + it("refreshes inline atom metadata when reconciliation changes atom props", () => { + const editor = createPresetEditor(); + const element = document.createElement("span"); + const firstDelta = [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ]; + const secondDelta = [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-2", label: "Ada" }, + }, + }, + { insert: "B" }, + ]; + + fullReconcileDeltasToDOM(firstDelta, element, editor.schema); + const firstAtom = element.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + expect(getInlineAtomElementData(firstAtom!)).toEqual({ + type: "mention", + props: { id: "user-1", label: "Ada" }, + text: "@Ada", + }); + + fullReconcileDeltasToDOM(secondDelta, element, editor.schema); + const secondAtom = element.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + + expect(secondAtom).not.toBe(firstAtom); + expect(firstAtom?.isConnected).toBe(false); + expect(getInlineAtomElementData(secondAtom!)).toEqual({ + type: "mention", + props: { id: "user-2", label: "Ada" }, + text: "@Ada", + }); + + editor.destroy(); + }); + it("round-trips DOM selection offsets around inline atoms", async () => { const editor = createPresetEditor(); const blockId = seedInlineAtomDocument(editor); diff --git a/packages/rendering/react/src/__tests__/keyHandling.test.ts b/packages/rendering/react/src/__tests__/keyHandling.test.ts index 4b79c92..c321730 100644 --- a/packages/rendering/react/src/__tests__/keyHandling.test.ts +++ b/packages/rendering/react/src/__tests__/keyHandling.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - createEditor, - getInlineCompletionController, -} from "@pen/core"; +import { createEditor, getInlineCompletionController } from "@pen/core"; import { getSearchController, searchExtension } from "@pen/search"; import { AI_AUTOCOMPLETE_CONTROLLER_SLOT, @@ -70,9 +67,10 @@ function getYText( const adapter = editor.internals.adapter; const doc = editor.internals.crdtDoc; const ydoc = adapter.raw(doc); - const ytext = ydoc.getMap("blocks").get(blockId)?.get("content") as - | FieldEditorTextLike - | null; + const ytext = ydoc + .getMap("blocks") + .get(blockId) + ?.get("content") as FieldEditorTextLike | null; if (!ytext) { throw new Error(`Missing test Y.Text for block ${blockId}`); } @@ -96,7 +94,7 @@ function createFieldEditorMock(blockId: string) { focusBlockId: blockId, inputMode: "richtext" as const, activeCellCoord: null, - activateCell: () => { }, + activateCell: () => {}, activateTextSelection: ( targetBlockId: string, anchorOffset: number, @@ -119,7 +117,7 @@ function createFieldEditorMock(blockId: string) { focusOffset, }); }, - deactivate: () => { }, + deactivate: () => {}, selectAll: () => false, }, activations, @@ -130,7 +128,9 @@ function createFieldEditorMock(blockId: string) { function createPresetEditor( options: { preset?: Parameters[0]; - extensions?: NonNullable[0]>["extensions"]; + extensions?: NonNullable< + Parameters[0] + >["extensions"]; } = {}, ) { return createEditor({ @@ -140,6 +140,78 @@ function createPresetEditor( } describe("@pen/react key binding contexts", () => { + it("selects inline atoms before arrow navigation moves past them", () => { + const editor = createPresetEditor({ + preset: { + documentOps: false, + deltaStream: false, + undo: false, + shortcuts: false, + }, + }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "A" }, + { + type: "insert-inline-node", + blockId, + offset: 1, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + { type: "insert-text", blockId, offset: 2, text: "B" }, + ]); + const ytext = getYText(editor, blockId); + const fieldEditor = createFieldEditorMock(blockId); + + expect( + handleFieldEditorKeyDown({ + event: createKeyEvent("ArrowLeft"), + editor, + fieldEditor: fieldEditor.controller, + ytext, + range: { start: 2, end: 2 }, + }), + ).toBe(true); + expect(fieldEditor.activations.at(-1)).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 2, + }); + + expect( + handleFieldEditorKeyDown({ + event: createKeyEvent("ArrowLeft"), + editor, + fieldEditor: fieldEditor.controller, + ytext, + range: { start: 1, end: 2 }, + }), + ).toBe(true); + expect(fieldEditor.activations.at(-1)).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 1, + }); + + expect( + handleFieldEditorKeyDown({ + event: createKeyEvent("ArrowRight"), + editor, + fieldEditor: fieldEditor.controller, + ytext, + range: { start: 1, end: 1 }, + }), + ).toBe(true); + expect(fieldEditor.activations.at(-1)).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 2, + }); + + editor.destroy(); + }); + it("filters bindings by collapsed selection state", () => { let handled = 0; const editor = createPresetEditor({ @@ -399,14 +471,16 @@ describe("@pen/react key binding contexts", () => { extensions: [ defineExtension({ name: "history-override", - keyBindings: [{ - key: "Mod-z", - priority: 1000, - handler: () => { - handled += 1; - return true; + keyBindings: [ + { + key: "Mod-z", + priority: 1000, + handler: () => { + handled += 1; + return true; + }, }, - }], + ], }), ], }); @@ -479,14 +553,21 @@ describe("@pen/react key binding contexts", () => { const blockId = editor.firstBlock()!.id; editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "alpha beta alpha" }, + { + type: "insert-text", + blockId, + offset: 0, + text: "alpha beta alpha", + }, ]); const controller = getSearchController(editor); controller?.open(); controller?.setQuery("alpha"); - expect(handleEditorKeyBindings(editor, createKeyEvent("Enter"))).toBe(true); + expect(handleEditorKeyBindings(editor, createKeyEvent("Enter"))).toBe( + true, + ); expect(editor.selection).toMatchObject({ type: "text", anchor: { blockId, offset: 11 }, @@ -494,7 +575,10 @@ describe("@pen/react key binding contexts", () => { }); expect( - handleEditorKeyBindings(editor, createKeyEvent("Enter", { shiftKey: true })), + handleEditorKeyBindings( + editor, + createKeyEvent("Enter", { shiftKey: true }), + ), ).toBe(true); expect(editor.selection).toMatchObject({ type: "text", @@ -502,7 +586,9 @@ describe("@pen/react key binding contexts", () => { focus: { blockId, offset: 5 }, }); - expect(handleEditorKeyBindings(editor, createKeyEvent("Escape"))).toBe(true); + expect(handleEditorKeyBindings(editor, createKeyEvent("Escape"))).toBe( + true, + ); expect(controller?.getState().open).toBe(false); editor.destroy(); @@ -521,7 +607,12 @@ describe("@pen/react key binding contexts", () => { const blockId = editor.firstBlock()!.id; editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "alpha beta alpha" }, + { + type: "insert-text", + blockId, + offset: 0, + text: "alpha beta alpha", + }, ]); const controller = getSearchController(editor); @@ -602,7 +693,11 @@ describe("@pen/react field editor Tab handling", () => { const secondBlockId = crypto.randomUUID(); editor.apply([ - { type: "convert-block", blockId: firstBlockId, newType: "bulletListItem" }, + { + type: "convert-block", + blockId: firstBlockId, + newType: "bulletListItem", + }, { type: "insert-block", blockId: secondBlockId, @@ -610,7 +705,12 @@ describe("@pen/react field editor Tab handling", () => { props: { indent: 0 }, position: { after: firstBlockId }, }, - { type: "insert-text", blockId: secondBlockId, offset: 0, text: "child" }, + { + type: "insert-text", + blockId: secondBlockId, + offset: 0, + text: "child", + }, ]); const fieldEditor = createFieldEditorMock(secondBlockId); @@ -707,18 +807,18 @@ describe("@pen/react field editor Tab handling", () => { lastPolicyInvalidationStage: null, }, }), - subscribe: () => () => { }, + subscribe: () => () => {}, request: (options?: { explicit?: boolean }) => { requestCount += 1; return options?.explicit === true; }, acceptVisibleSuggestion: () => false, hasVisibleSuggestion: () => false, - registerProvider: () => () => { }, + registerProvider: () => () => {}, listProviderDescriptors: () => [], - updateRuntimeSettings: () => { }, - dismiss: () => { }, - setEnabled: () => { }, + updateRuntimeSettings: () => {}, + dismiss: () => {}, + setEnabled: () => {}, }, ); }, @@ -812,23 +912,26 @@ describe("@pen/react field editor Tab handling", () => { lastPolicyInvalidationStage: null, }, }), - subscribe: () => () => { }, + subscribe: () => () => {}, request: () => false, acceptVisibleSuggestion: () => { acceptVisibleSuggestionCount += 1; return true; }, hasVisibleSuggestion: () => true, - registerProvider: () => () => { }, + registerProvider: () => () => {}, listProviderDescriptors: () => [], - updateRuntimeSettings: () => { }, - dismiss: () => { }, - setEnabled: () => { }, + updateRuntimeSettings: () => {}, + dismiss: () => {}, + setEnabled: () => {}, }, ); }, deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor?.internals.setSlot( + FIELD_EDITOR_SLOT_KEY, + null, + ); activeEditor?.internals.setSlot( AI_AUTOCOMPLETE_CONTROLLER_SLOT, null, @@ -954,17 +1057,17 @@ describe("@pen/react field editor Tab handling", () => { lastPolicyInvalidationStage: null, }, }), - subscribe: () => () => { }, + subscribe: () => () => {}, request: () => false, acceptVisibleSuggestion: () => false, hasVisibleSuggestion: () => true, - registerProvider: () => () => { }, + registerProvider: () => () => {}, listProviderDescriptors: () => [], - updateRuntimeSettings: () => { }, + updateRuntimeSettings: () => {}, dismiss: (reason?: string) => { dismissReason = reason ?? null; }, - setEnabled: () => { }, + setEnabled: () => {}, }, ); }, diff --git a/packages/rendering/react/src/context/editorContext.ts b/packages/rendering/react/src/context/editorContext.ts index 3415e80..71e349e 100644 --- a/packages/rendering/react/src/context/editorContext.ts +++ b/packages/rendering/react/src/context/editorContext.ts @@ -22,6 +22,17 @@ export interface PasteImporters { export type RendererOverrides = Partial>; +export interface InlineAtomRenderProps { + type: string; + props: Record; + text: string; + selected: boolean; +} + +export type InlineAtomRenderer = (props: InlineAtomRenderProps) => ReactNode; + +export type InlineAtomRenderers = Partial>; + export interface BlockDragAndDropOptions { enabled?: boolean; } @@ -89,9 +100,7 @@ export interface BlockControlsProps { selected: boolean; } -export type BlockControlsRenderer = ( - props: BlockControlsProps, -) => ReactNode; +export type BlockControlsRenderer = (props: BlockControlsProps) => ReactNode; export interface EditorContextValue { editor: Editor; @@ -105,6 +114,7 @@ export interface EditorContextValue { importers?: PasteImporters; assets?: AssetProvider; renderers?: RendererOverrides; + inlineAtomRenderers?: InlineAtomRenderers; } export const EditorContext = createContext(null); @@ -115,7 +125,7 @@ export function useEditorContext(): EditorContextValue { if (isDevelopmentEnvironment()) { console.error( "Pen: useEditorContext must be used within . " + - "Wrap your editor components in .", + "Wrap your editor components in .", ); } throw new Error("Missing Pen.Editor.Root context"); diff --git a/packages/rendering/react/src/hooks/useFieldEditorState.ts b/packages/rendering/react/src/hooks/useFieldEditorState.ts index 3034efb..560c0ae 100644 --- a/packages/rendering/react/src/hooks/useFieldEditorState.ts +++ b/packages/rendering/react/src/hooks/useFieldEditorState.ts @@ -10,6 +10,7 @@ const EMPTY_FIELD_EDITOR_STATE: FieldEditorStoreSnapshot = { isEditing: false, isFocused: false, isComposing: false, + domSyncVersion: 0, inputMode: "none", mode: "inactive", activeCellCoord: null, @@ -18,7 +19,9 @@ const EMPTY_FIELD_EDITOR_STATE: FieldEditorStoreSnapshot = { export function useFieldEditorState( fieldEditor: FieldEditorStore | null, ): FieldEditorStoreSnapshot { - const snapshotRef = useRef(EMPTY_FIELD_EDITOR_STATE); + const snapshotRef = useRef( + EMPTY_FIELD_EDITOR_STATE, + ); return useSyncExternalStore( (callback) => { @@ -40,6 +43,7 @@ export function useFieldEditorState( prevSnapshot.isEditing === nextSnapshot.isEditing && prevSnapshot.isFocused === nextSnapshot.isFocused && prevSnapshot.isComposing === nextSnapshot.isComposing && + prevSnapshot.domSyncVersion === nextSnapshot.domSyncVersion && prevSnapshot.inputMode === nextSnapshot.inputMode && prevSnapshot.mode === nextSnapshot.mode && prevSnapshot.activeCellCoord === nextSnapshot.activeCellCoord diff --git a/packages/rendering/react/src/index.ts b/packages/rendering/react/src/index.ts index f8207cd..eb5f775 100644 --- a/packages/rendering/react/src/index.ts +++ b/packages/rendering/react/src/index.ts @@ -30,6 +30,9 @@ export { EditorFieldEditor, type EditorCaretVariant, type EditorRootProps, + type InlineAtomRenderProps, + type InlineAtomRenderer, + type InlineAtomRenderers, type EditorContentProps, type EditorBlockProps, type InlineContentProps, diff --git a/packages/rendering/react/src/primitives/editor/index.ts b/packages/rendering/react/src/primitives/editor/index.ts index 4b97466..ec94c1b 100644 --- a/packages/rendering/react/src/primitives/editor/index.ts +++ b/packages/rendering/react/src/primitives/editor/index.ts @@ -1,7 +1,9 @@ -export { - EditorRoot, - type EditorRootProps, -} from "./root"; +export { EditorRoot, type EditorRootProps } from "./root"; +export type { + InlineAtomRenderProps, + InlineAtomRenderer, + InlineAtomRenderers, +} from "../../context/editorContext"; export { EditorContent, type EditorContentProps } from "./content"; export { EditorBlock, type EditorBlockProps } from "./block"; export { InlineContent, type InlineContentProps } from "./inlineContent"; @@ -14,6 +16,9 @@ export { } from "./caretOverlay"; export { EditorBlockHandle, type BlockHandleProps } from "./blockHandle"; export { EditorDragOverlay, type DragOverlayProps } from "./dragOverlay"; -export { EditorRegionSelector, type RegionSelectorProps } from "./regionSelector"; +export { + EditorRegionSelector, + type RegionSelectorProps, +} from "./regionSelector"; export { EditorSelectionRect, type SelectionRectProps } from "./selectionRect"; export { EditorFieldEditor, type FieldEditorWrapperProps } from "./fieldEditor"; diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 7e7b4b1..a3c14c0 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -1,7 +1,23 @@ -import React, { useRef, useLayoutEffect } from "react"; -import type { Editor, InlineDecoration } from "@pen/types"; +import React, { useRef, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { + getOpOriginType, + type Editor, + type InlineDecoration, + type SelectionState, +} from "@pen/types"; +import { + domPointToLogicalOffset, + getInlineAtomElementData, + getLogicalTextContent, + INLINE_ATOM_REPLACEMENT_TEXT, +} from "@pen/dom/field-editor/inlineAtomDom"; import { useEditorContentContext } from "../../context/editorContentContext"; -import { useEditorContext } from "../../context/editorContext"; +import { + useEditorContext, + type InlineAtomRenderer, + type InlineAtomRenderers, +} from "../../context/editorContext"; import { useFieldEditorContext } from "../../context/fieldEditorContext"; import { fullReconcileDeltasToDOM } from "../../field-editor/reconciler"; import { useBlockEditingState } from "../../hooks/useBlockEditingState"; @@ -24,9 +40,19 @@ export interface InlineContentProps extends AsChildProps { ref?: React.Ref; } +interface InlineAtomRenderTarget { + key: string; + element: HTMLElement; + renderer: InlineAtomRenderer; + type: string; + props: Record; + text: string; + offset: number; +} + export function InlineContent(props: InlineContentProps) { const { blockId, className, placeholder: placeholderProp, ...rest } = props; - const { editor } = useEditorContext(); + const { editor, inlineAtomRenderers } = useEditorContext(); const { emptyPlaceholder, isEmpty: isDocumentEmpty } = useEditorContentContext(); const fieldEditor = useFieldEditorContext(); @@ -40,6 +66,10 @@ export function InlineContent(props: InlineContentProps) { const elementRef = useRef(null); const previousCommitRevisionRef = useRef(blockCommit.revision); const previousRenderedDeltasSignatureRef = useRef(null); + const inlineAtomTargetsRef = useRef([]); + const [inlineAtomTargets, setInlineAtomTargets] = useState< + InlineAtomRenderTarget[] + >([]); const isExpandedOwnedBlock = fieldEditorState.mode === "expanded" && fieldEditorState.activeBlockIds.includes(blockId); @@ -55,9 +85,9 @@ export function InlineContent(props: InlineContentProps) { const blockTextEmpty = !textSnapshot.text || textSnapshot.text === "\u200B"; const emptyInlineCompletionText = visibleInlineCompletion?.type === "inline" && - visibleInlineCompletion.blockId === blockId && - blockTextEmpty && - visibleInlineCompletion.text.length > 0 + visibleInlineCompletion.blockId === blockId && + blockTextEmpty && + visibleInlineCompletion.text.length > 0 ? visibleInlineCompletion.text : null; const { @@ -89,9 +119,9 @@ export function InlineContent(props: InlineContentProps) { const renderedDeltas = inlineDecorations.length > 0 ? applyInlineDecorationsToDeltas( - textSnapshot.deltas, - inlineDecorations, - ) + textSnapshot.deltas, + inlineDecorations, + ) : textSnapshot.deltas; const renderedDeltasText = getDeltaText(renderedDeltas); const renderedDeltasSignature = getDeltaSignature(renderedDeltas); @@ -106,6 +136,18 @@ export function InlineContent(props: InlineContentProps) { }, [isActive, fieldEditor, fieldEditorState.mode, blockId]); useLayoutEffect(() => { + const syncInlineAtomTargets = () => { + const nextTargets = resolveNextInlineAtomTargets( + elementRef.current, + inlineAtomRenderers, + inlineAtomTargetsRef.current, + ); + if (nextTargets !== inlineAtomTargetsRef.current) { + inlineAtomTargetsRef.current = nextTargets; + setInlineAtomTargets(nextTargets); + } + }; + const didCommitAdvance = blockCommit.revision !== previousCommitRevisionRef.current; previousCommitRevisionRef.current = blockCommit.revision; @@ -118,22 +160,29 @@ export function InlineContent(props: InlineContentProps) { ? elementRef.current.contains(activeElement) : false); const shouldForceCommitReconcile = - didCommitAdvance && blockCommit.origin === "history"; + didCommitAdvance && + blockCommit.origin !== null && + getOpOriginType(blockCommit.origin) === "history"; if (isExpandedOwnedBlock || isActive) { if (!elementRef.current || fieldEditorState.isComposing) { + syncInlineAtomTargets(); return; } if (!textSnapshot.exists) { elementRef.current.replaceChildren(); previousRenderedDeltasSignatureRef.current = null; + syncInlineAtomTargets(); return; } if ( - elementRef.current.textContent === renderedDeltasText && + !shouldForceCommitReconcile && + getLogicalTextContent(elementRef.current) === + renderedDeltasText && previousRenderedDeltasSignatureRef.current === - renderedDeltasSignature + renderedDeltasSignature ) { + syncInlineAtomTargets(); return; } fullReconcileDeltasToDOM( @@ -144,20 +193,24 @@ export function InlineContent(props: InlineContentProps) { ); previousRenderedDeltasSignatureRef.current = renderedDeltasSignature; + syncInlineAtomTargets(); return; } if (!elementRef.current) { + syncInlineAtomTargets(); return; } if ( !shouldForceCommitReconcile && (isBackendOwned || fieldEditorState.isComposing) ) { + syncInlineAtomTargets(); return; } if (!textSnapshot.exists) { elementRef.current.replaceChildren(); previousRenderedDeltasSignatureRef.current = null; + syncInlineAtomTargets(); return; } fullReconcileDeltasToDOM( @@ -167,10 +220,12 @@ export function InlineContent(props: InlineContentProps) { { preserveSelection: false }, ); previousRenderedDeltasSignatureRef.current = renderedDeltasSignature; + syncInlineAtomTargets(); }, [ editor, isExpandedOwnedBlock, fieldEditorState.isComposing, + fieldEditorState.domSyncVersion, fieldEditorState.activeBlockIds, fieldEditorState.mode, blockCommit, @@ -179,8 +234,13 @@ export function InlineContent(props: InlineContentProps) { renderedDeltasSignature, renderedDeltasText, textSnapshot, + inlineAtomRenderers, ]); + useLayoutEffect(() => { + inlineAtomTargetsRef.current = inlineAtomTargets; + }, [inlineAtomTargets]); + const showPlaceholder = showDocumentPlaceholder || showExplicitPlaceholder || @@ -191,23 +251,197 @@ export function InlineContent(props: InlineContentProps) { [DATA_ATTRS.inlineContent]: "", [DATA_ATTRS.fieldEditorSurface]: "", ...fieldEditorTextEntryAttrs(isActiveSurface), - className: getInlineContentClassName(className, emptyInlineCompletionText), + className: getInlineContentClassName( + className, + emptyInlineCompletionText, + ), "data-suggestion-id": emptyInlineCompletionText ? visibleInlineCompletion?.id : undefined, "data-suggestion-text": emptyInlineCompletionText ?? undefined, - "data-suggestion-type": emptyInlineCompletionText ? "inline" : undefined, - "data-suggestion-placement": emptyInlineCompletionText ? "after" : undefined, + "data-suggestion-type": emptyInlineCompletionText + ? "inline" + : undefined, + "data-suggestion-placement": emptyInlineCompletionText + ? "after" + : undefined, [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, "data-placeholder": showPlaceholder ? placeholder : undefined, style: showPlaceholder ? { - position: "relative" as const, - } + position: "relative" as const, + } : undefined, }; + const inlineAtomPortals = inlineAtomTargets.map((target) => + createPortal( + target.renderer({ + type: target.type, + props: target.props, + text: target.text, + selected: isInlineAtomSelected( + selection, + blockId, + target.offset, + ), + }), + target.element, + target.key, + ), + ); + + useLayoutEffect(() => { + inlineAtomTargets.forEach((target) => { + target.element.toggleAttribute( + DATA_ATTRS.selected, + isInlineAtomSelected(selection, blockId, target.offset), + ); + }); + }, [blockId, inlineAtomTargets, selection]); + + return ( + <> + {renderAsChild( + { ...rest, ref: elementRef }, + "span", + primitiveProps, + )} + {inlineAtomPortals} + + ); +} + +function resolveNextInlineAtomTargets( + root: HTMLElement | null, + renderers: InlineAtomRenderers | undefined, + currentTargets: InlineAtomRenderTarget[], +): InlineAtomRenderTarget[] { + if (!root || !renderers) { + return currentTargets.length === 0 ? currentTargets : []; + } + + const nextTargets = Array.from( + root.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), + ).flatMap((element, index): InlineAtomRenderTarget[] => { + const data = getInlineAtomElementData(element); + if (!data) { + return []; + } + + const renderer = renderers[data.type]; + if (!renderer) { + return []; + } + clearInlineAtomFallbackText(element, data.text); + const offset = domPointToLogicalOffset(root, element, 0); + + return [ + { + key: getInlineAtomTargetKey(data, index), + element, + renderer, + type: data.type, + props: data.props, + text: data.text, + offset, + }, + ]; + }); + + return areInlineAtomTargetsEqual(currentTargets, nextTargets) + ? currentTargets + : nextTargets; +} + +function areInlineAtomTargetsEqual( + currentTargets: InlineAtomRenderTarget[], + nextTargets: InlineAtomRenderTarget[], +): boolean { + if (currentTargets.length !== nextTargets.length) { + return false; + } + + return currentTargets.every((target, index) => { + const nextTarget = nextTargets[index]; + return ( + target.key === nextTarget.key && + target.element === nextTarget.element && + target.renderer === nextTarget.renderer && + target.offset === nextTarget.offset && + target.text === nextTarget.text && + shallowEqualRecords(target.props, nextTarget.props) + ); + }); +} + +function getInlineAtomTargetKey( + data: { type: string; props: Record; text: string }, + index: number, +): string { + return `${index}:${data.type}:${data.text}:${JSON.stringify(data.props)}`; +} + +function isInlineAtomSelected( + selection: SelectionState, + blockId: string, + offset: number, +): boolean { + if ( + selection?.type !== "text" || + selection.isCollapsed || + selection.anchor.blockId !== blockId || + selection.focus.blockId !== blockId + ) { + return false; + } - return renderAsChild({ ...rest, ref: elementRef }, "span", primitiveProps); + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + return selectionStart <= offset && selectionEnd >= offset + 1; +} + +function clearInlineAtomFallbackText(element: HTMLElement, text: string): void { + if ( + element.childNodes.length === 1 && + element.firstChild?.nodeType === Node.TEXT_NODE && + element.textContent === text + ) { + element.replaceChildren(); + return; + } + + for (const child of Array.from(element.childNodes)) { + if ( + child.nodeType === Node.TEXT_NODE && + (child.textContent === text || + child.textContent === INLINE_ATOM_REPLACEMENT_TEXT) + ) { + child.remove(); + } + } +} + +function shallowEqualRecords( + left: Record, + right: Record, +): boolean { + if (left === right) { + return true; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => Object.is(left[key], right[key])); } function getInlineContentClassName( @@ -253,25 +487,5 @@ function getDeltaSignature( } function getInlineNodeText(insert: Record): string { - const props = - insert.props && typeof insert.props === "object" - ? (insert.props as Record) - : insert; - - const label = props.label; - if (typeof label === "string" && label.length > 0) { - return label; - } - - const name = props.name; - if (typeof name === "string" && name.length > 0) { - return name; - } - - const id = props.id; - if (typeof id === "string" && id.length > 0) { - return id; - } - - return typeof insert.type === "string" ? insert.type : ""; + return INLINE_ATOM_REPLACEMENT_TEXT; } diff --git a/packages/rendering/react/src/primitives/editor/root.tsx b/packages/rendering/react/src/primitives/editor/root.tsx index dda3fe2..0e6fd87 100644 --- a/packages/rendering/react/src/primitives/editor/root.tsx +++ b/packages/rendering/react/src/primitives/editor/root.tsx @@ -11,6 +11,7 @@ import { type BlockControlsRenderer, type BlockDragAndDropOptions, type BlockSelectionOptions, + type InlineAtomRenderers, type ResolvedBlockDragAndDropOptions, type PasteImporters, type RendererOverrides, @@ -42,6 +43,7 @@ export interface EditorRootProps extends AsChildProps { importers?: PasteImporters; assets?: AssetProvider; renderers?: RendererOverrides; + inlineAtomRenderers?: InlineAtomRenderers; blockControls?: BlockControlsRenderer; editorViewMode?: EditorViewMode; interactionModel?: InteractionModel; @@ -57,6 +59,7 @@ export function EditorRoot(props: EditorRootProps) { importers, assets, renderers, + inlineAtomRenderers, blockControls, editorViewMode = editor.editorViewMode, interactionModel, @@ -235,6 +238,7 @@ export function EditorRoot(props: EditorRootProps) { importers, assets: resolvedAssets, renderers, + inlineAtomRenderers, }} > From fe7951fb79a5952f314ba7eb31f00ecda645ab64 Mon Sep 17 00:00:00 2001 From: krijn Date: Fri, 22 May 2026 04:17:00 +0200 Subject: [PATCH 17/20] Enhance AI session tracking and suggestion handling - Added sessionId, turnId, and generationId to AI operations for improved tracking of AI interactions. - Introduced ApplySuggestedAIOperations type definitions to streamline suggestion application processes. - Updated PersistentSuggestion and BlockSuggestionMeta interfaces to include new tracking fields. - Refactored suggestion handling logic to incorporate new identifiers, enhancing the overall suggestion management system. - Improved focus handling in the FieldEditor to support new focus policies and lifecycle events. --- .../applySuggestedAIOperations.test.ts | 155 ++++ packages/extensions/ai/src/extension.ts | 3 + packages/extensions/ai/src/index.ts | 5 + .../suggestions/applySuggestedAIOperations.ts | 71 ++ .../ai/src/suggestions/persistent.ts | 68 +- .../ai/src/suggestions/suggestMode.ts | 42 +- packages/extensions/ai/src/types.ts | 6 + .../field-editor/contenteditableBackend.ts | 3 +- .../dom/src/field-editor/controller.ts | 108 ++- .../src/field-editor/editContextBackend.ts | 7 +- .../expandedContentEditableBackend.ts | 33 +- .../dom/src/field-editor/fieldEditorImpl.ts | 499 ++++++++++-- .../rendering/dom/src/field-editor/index.ts | 31 +- .../dom/src/field-editor/inlineAtomDom.ts | 45 ++ .../src/field-editor/inlineAtomInteraction.ts | 339 ++++++++ .../dom/src/field-editor/selectionBridge.ts | 117 +++ packages/rendering/dom/src/index.ts | 14 +- .../dom/src/internal/inputBackend.ts | 5 + .../src/__tests__/inlineAtomEditing.test.tsx | 761 +++++++++++++++++- .../__tests__/placeholderBehavior.test.tsx | 148 +++- .../react/src/context/editorContext.ts | 107 +++ packages/rendering/react/src/hooks/index.ts | 9 + .../src/hooks/useFocusController.test.tsx | 258 ++++++ .../react/src/hooks/useFocusController.ts | 180 +++++ packages/rendering/react/src/index.ts | 21 +- .../react/src/primitives/editor/content.tsx | 12 +- .../react/src/primitives/editor/index.ts | 2 + .../editor/inlineAtomInteraction.ts | 476 +++++++++++ .../src/primitives/editor/inlineContent.tsx | 139 +++- .../react/src/primitives/editor/root.tsx | 40 + .../rendering/react/src/primitives/index.ts | 6 + .../react/src/primitives/toolbar/root.tsx | 17 +- .../react/src/utils/dataAttributes.ts | 1 + .../react/src/utils/editorEmptyState.ts | 20 +- .../react/src/utils/inlineAtomDragPreview.ts | 128 +++ packages/types/src/suggestion.ts | 7 + packages/types/src/types/fieldEditor.ts | 30 +- packages/types/src/types/index.ts | 7 +- packages/types/src/types/suggestions.ts | 4 + packages/types/src/types/tools.ts | 351 ++++---- 40 files changed, 3931 insertions(+), 344 deletions(-) create mode 100644 packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts create mode 100644 packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts create mode 100644 packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts create mode 100644 packages/rendering/dom/src/internal/inputBackend.ts create mode 100644 packages/rendering/react/src/hooks/useFocusController.test.tsx create mode 100644 packages/rendering/react/src/hooks/useFocusController.ts create mode 100644 packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts create mode 100644 packages/rendering/react/src/utils/inlineAtomDragPreview.ts diff --git a/packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts b/packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts new file mode 100644 index 0000000..2ef7f4d --- /dev/null +++ b/packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts @@ -0,0 +1,155 @@ +import { createEditor } from "@pen/core"; +import { describe, expect, it } from "vitest"; +import { + acceptAllSuggestions, + acceptSuggestion, + applySuggestedAIOperations, + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, + rejectSuggestion, +} from "../index"; + +describe("applySuggestedAIOperations", () => { + it("creates accept-compatible text insert suggestions with provenance", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + const result = applySuggestedAIOperations(editor, { + operations: [ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ], + requestId: "request-1", + sessionId: "session-1", + turnId: "turn-1", + generationId: "generation-1", + model: "test-model", + suggestionIds: ["suggestion-insert"], + createdAt: 1_762_000_000_000, + }); + + expect(result.suggestionIds).toEqual(["suggestion-insert"]); + expect(result.suggestions[0]).toMatchObject({ + kind: "text", + id: "suggestion-insert", + action: "insert", + authorType: "ai", + requestId: "request-1", + sessionId: "session-1", + turnId: "turn-1", + generationId: "generation-1", + model: "test-model", + }); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello", + ); + + expect(acceptSuggestion(editor, "suggestion-insert")).toBe(true); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello"); + }); + + it("creates accept-compatible text replace suggestions", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const result = applySuggestedAIOperations(editor, { + operations: [ + { + type: "replace-text", + blockId, + offset: 0, + length: 5, + text: "Hi", + }, + ], + requestId: "request-2", + sessionId: "session-2", + turnId: "turn-2", + generationId: "generation-2", + suggestionIds: ["suggestion-delete", "suggestion-insert"], + }); + + expect(result.suggestionIds).toEqual([ + "suggestion-delete", + "suggestion-insert", + ]); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(2); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hi", + ); + + acceptAllSuggestions(editor); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hi"); + }); + + it("creates reject-compatible text delete suggestions", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const result = applySuggestedAIOperations(editor, { + operations: [ + { type: "delete-text", blockId, offset: 0, length: 5 }, + ], + requestId: "request-3", + sessionId: "session-3", + turnId: "turn-3", + generationId: "generation-3", + suggestionIds: ["suggestion-delete"], + }); + + expect(result.suggestionIds).toEqual(["suggestion-delete"]); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "", + ); + + expect(rejectSuggestion(editor, "suggestion-delete")).toBe(true); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello"); + }); + + it("creates reject-compatible block suggestions", () => { + const editor = createEditor(); + + const result = applySuggestedAIOperations(editor, { + operations: [ + { + type: "insert-block", + blockId: "ai-block", + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + requestId: "request-4", + sessionId: "session-4", + turnId: "turn-4", + generationId: "generation-4", + suggestionIds: ["suggestion-block"], + }); + + expect(result.suggestionIds).toEqual(["suggestion-block"]); + expect( + readBlockSuggestionMeta(editor.getBlock("ai-block")), + ).toMatchObject({ + id: "suggestion-block", + action: "insert-block", + requestId: "request-4", + sessionId: "session-4", + turnId: "turn-4", + generationId: "generation-4", + }); + + expect(rejectSuggestion(editor, "suggestion-block")).toBe(true); + expect(editor.getBlock("ai-block")).toBeNull(); + }); +}); diff --git a/packages/extensions/ai/src/extension.ts b/packages/extensions/ai/src/extension.ts index 18763b4..cdf918b 100644 --- a/packages/extensions/ai/src/extension.ts +++ b/packages/extensions/ai/src/extension.ts @@ -1881,6 +1881,9 @@ class AIControllerImpl implements AIController { operation, }), operation, + sessionId: context?.sessionId, + turnId: sessionTurnId, + generationId: seedGeneration.id, }); for await (const event of stream) { diff --git a/packages/extensions/ai/src/index.ts b/packages/extensions/ai/src/index.ts index 581e03b..55e4f29 100644 --- a/packages/extensions/ai/src/index.ts +++ b/packages/extensions/ai/src/index.ts @@ -108,6 +108,7 @@ export { SUGGESTION_RESOLUTION_ORIGIN, shouldBypassSuggestMode, } from "./suggestions/suggestMode"; +export { applySuggestedAIOperations } from "./suggestions/applySuggestedAIOperations"; export { EphemeralSuggestionManager } from "./suggestions/ephemeral"; export type { @@ -162,6 +163,10 @@ export type { AIMutationReceiptEvidence, AIMutationReceiptStatus, } from "./types"; +export type { + ApplySuggestedAIOperationsOptions, + ApplySuggestedAIOperationsResult, +} from "./suggestions/applySuggestedAIOperations"; export type { DocumentMutationPlan, DocumentMutationPlanKind, diff --git a/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts b/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts new file mode 100644 index 0000000..d5a66aa --- /dev/null +++ b/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts @@ -0,0 +1,71 @@ +import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; +import type { PersistentSuggestion } from "../types"; +import { readAllSuggestions } from "./persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, +} from "./suggestMode"; + +export type ApplySuggestedAIOperationsOptions = { + operations: readonly DocumentOp[]; + author?: string; + authorType?: "user" | "ai"; + model?: string; + requestId?: string; + sessionId?: string; + turnId?: string; + generationId?: string; + suggestionIds?: readonly string[]; + createdAt?: number; + origin?: OpOrigin; + undoGroupId?: string; +}; + +export type ApplySuggestedAIOperationsResult = { + suggestionIds: string[]; + suggestions: PersistentSuggestion[]; +}; + +export function applySuggestedAIOperations( + editor: Editor, + options: ApplySuggestedAIOperationsOptions, +): ApplySuggestedAIOperationsResult { + if (options.operations.length === 0) { + return { suggestionIds: [], suggestions: [] }; + } + + const beforeSuggestionIds = new Set( + readAllSuggestions(editor).map((suggestion) => suggestion.id), + ); + const intercepted = interceptApplyForSuggestMode( + [...options.operations], + editor, + options.author ?? "assistant", + options.authorType ?? "ai", + options.model, + options.sessionId, + { + requestId: options.requestId, + turnId: options.turnId, + generationId: options.generationId, + createdAt: options.createdAt, + suggestionIds: options.suggestionIds, + }, + ); + + editor.apply(intercepted, { + origin: options.origin ?? AI_SESSION_SUGGESTION_ORIGIN, + ...(options.undoGroupId + ? { undoGroupId: options.undoGroupId } + : { undoGroup: true }), + }); + + const suggestions = readAllSuggestions(editor).filter( + (suggestion) => !beforeSuggestionIds.has(suggestion.id), + ); + + return { + suggestionIds: suggestions.map((suggestion) => suggestion.id), + suggestions, + }; +} diff --git a/packages/extensions/ai/src/suggestions/persistent.ts b/packages/extensions/ai/src/suggestions/persistent.ts index 2486da5..6b7b1ee 100644 --- a/packages/extensions/ai/src/suggestions/persistent.ts +++ b/packages/extensions/ai/src/suggestions/persistent.ts @@ -1,12 +1,14 @@ -import type { - BlockHandle, - Editor, - Position, -} from "@pen/types"; -import type { - BlockSuggestionMeta, - PersistentSuggestion, -} from "../types"; +import type { BlockHandle, Editor, Position } from "@pen/types"; +import type { BlockSuggestionMeta, PersistentSuggestion } from "../types"; + +export type SuggestionCreationOptions = { + suggestionId?: string; + requestId?: string; + sessionId?: string; + turnId?: string; + generationId?: string; + createdAt?: number; +}; type DeltaFragment = { insert: string | object; @@ -28,7 +30,8 @@ export function readSuggestionsFromBlock( let offset = 0; for (const delta of ytext.toDelta()) { - const length = typeof delta.insert === "string" ? delta.insert.length : 1; + const length = + typeof delta.insert === "string" ? delta.insert.length : 1; const suggestion = asSuggestion(delta.attributes?.suggestion); if (suggestion) { suggestions.push({ @@ -40,6 +43,9 @@ export function readSuggestionsFromBlock( createdAt: suggestion.createdAt, model: suggestion.model, sessionId: suggestion.sessionId, + requestId: suggestion.requestId, + turnId: suggestion.turnId, + generationId: suggestion.generationId, blockId, offset, length, @@ -65,6 +71,9 @@ export function readAllSuggestions(editor: Editor): PersistentSuggestion[] { createdAt: blockSuggestion.createdAt, model: blockSuggestion.model, sessionId: blockSuggestion.sessionId, + requestId: blockSuggestion.requestId, + turnId: blockSuggestion.turnId, + generationId: blockSuggestion.generationId, blockId: block.id, previousState: blockSuggestion.previousState, }); @@ -107,7 +116,15 @@ export function readBlockSuggestionMeta( authorType: meta.authorType === "ai" ? "ai" : "user", createdAt: meta.createdAt, model: typeof meta.model === "string" ? meta.model : undefined, - sessionId: typeof meta.sessionId === "string" ? meta.sessionId : undefined, + sessionId: + typeof meta.sessionId === "string" ? meta.sessionId : undefined, + requestId: + typeof meta.requestId === "string" ? meta.requestId : undefined, + turnId: typeof meta.turnId === "string" ? meta.turnId : undefined, + generationId: + typeof meta.generationId === "string" + ? meta.generationId + : undefined, previousState: readPreviousState(meta.previousState), }; } @@ -118,16 +135,21 @@ export function createSuggestionMark( authorType: "user" | "ai", model?: string, sessionId?: string, + options: SuggestionCreationOptions = {}, ): Record { + const resolvedSessionId = options.sessionId ?? sessionId; return { suggestion: { - id: crypto.randomUUID(), + id: options.suggestionId ?? crypto.randomUUID(), action, author, authorType, - createdAt: Date.now(), + createdAt: options.createdAt ?? Date.now(), model, - sessionId, + sessionId: resolvedSessionId, + requestId: options.requestId, + turnId: options.turnId, + generationId: options.generationId, }, }; } @@ -160,9 +182,7 @@ function isPosition(value: unknown): value is Position { ); } -function asSuggestion( - value: unknown, -): { +function asSuggestion(value: unknown): { id: string; action: "insert" | "delete"; author: string; @@ -170,6 +190,9 @@ function asSuggestion( createdAt: number; model?: string; sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; } | null { if (!value || typeof value !== "object") return null; const record = value as Record; @@ -193,12 +216,21 @@ function asSuggestion( model: typeof record.model === "string" ? record.model : undefined, sessionId: typeof record.sessionId === "string" ? record.sessionId : undefined, + requestId: + typeof record.requestId === "string" ? record.requestId : undefined, + turnId: typeof record.turnId === "string" ? record.turnId : undefined, + generationId: + typeof record.generationId === "string" + ? record.generationId + : undefined, }; } function getYText(editor: Editor, blockId: string): YTextLike | null { try { - return (editor.internals.getBlockText(blockId) as YTextLike | null) ?? null; + return ( + (editor.internals.getBlockText(blockId) as YTextLike | null) ?? null + ); } catch { return null; } diff --git a/packages/extensions/ai/src/suggestions/suggestMode.ts b/packages/extensions/ai/src/suggestions/suggestMode.ts index e8ab384..016e9c5 100644 --- a/packages/extensions/ai/src/suggestions/suggestMode.ts +++ b/packages/extensions/ai/src/suggestions/suggestMode.ts @@ -1,6 +1,9 @@ import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; import { getOpOriginType } from "@pen/types"; -import { createSuggestionMark } from "./persistent"; +import { + createSuggestionMark, + type SuggestionCreationOptions, +} from "./persistent"; import type { BlockSuggestionMeta } from "../types"; export const SUGGESTION_RESOLUTION_ORIGIN = "suggestion-resolution"; @@ -27,8 +30,18 @@ export function interceptApplyForSuggestMode( authorType: "user" | "ai", model?: string, sessionId?: string, + options: SuggestModeSuggestionOptions = {}, ): DocumentOp[] { const intercepted: DocumentOp[] = []; + let suggestionIdIndex = 0; + const nextSuggestionOptions = (): SuggestionCreationOptions => ({ + requestId: options.requestId, + sessionId, + turnId: options.turnId, + generationId: options.generationId, + createdAt: options.createdAt, + suggestionId: options.suggestionIds?.[suggestionIdIndex++], + }); for (const op of ops) { switch (op.type) { @@ -43,6 +56,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + nextSuggestionOptions(), ), }, }); @@ -62,6 +76,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + nextSuggestionOptions(), ), }); } @@ -79,6 +94,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + nextSuggestionOptions(), ), }, }); @@ -98,6 +114,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + nextSuggestionOptions(), ), }); break; @@ -116,6 +133,7 @@ export function interceptApplyForSuggestMode( model, undefined, sessionId, + nextSuggestionOptions(), ), }); break; @@ -133,6 +151,7 @@ export function interceptApplyForSuggestMode( model, undefined, sessionId, + nextSuggestionOptions(), ), }); break; @@ -162,6 +181,7 @@ export function interceptApplyForSuggestMode( : "first", }, sessionId, + nextSuggestionOptions(), ), }); break; @@ -184,6 +204,7 @@ export function interceptApplyForSuggestMode( props: block ? { ...block.props } : undefined, }, sessionId, + nextSuggestionOptions(), ), }); break; @@ -197,6 +218,14 @@ export function interceptApplyForSuggestMode( return intercepted; } +export type SuggestModeSuggestionOptions = { + requestId?: string; + turnId?: string; + generationId?: string; + createdAt?: number; + suggestionIds?: readonly string[]; +}; + function createBlockSuggestionMeta( action: BlockSuggestionMeta["action"], author: string, @@ -204,15 +233,20 @@ function createBlockSuggestionMeta( model?: string, previousState?: BlockSuggestionMeta["previousState"], sessionId?: string, + options: SuggestionCreationOptions = {}, ): Record { + const resolvedSessionId = options.sessionId ?? sessionId; return { - id: crypto.randomUUID(), + id: options.suggestionId ?? crypto.randomUUID(), action, author, authorType, - createdAt: Date.now(), + createdAt: options.createdAt ?? Date.now(), model, previousState, - sessionId, + sessionId: resolvedSessionId, + requestId: options.requestId, + turnId: options.turnId, + generationId: options.generationId, }; } diff --git a/packages/extensions/ai/src/types.ts b/packages/extensions/ai/src/types.ts index 7bcb264..f6ebfa0 100644 --- a/packages/extensions/ai/src/types.ts +++ b/packages/extensions/ai/src/types.ts @@ -375,6 +375,9 @@ interface PersistentSuggestionBase { createdAt: number; model?: string; sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; blockId: string; } @@ -407,6 +410,9 @@ export interface BlockSuggestionMeta { createdAt: number; model?: string; sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; previousState?: { type?: string; position?: import("@pen/types").Position; diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index d2352ee..5320694 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -2,7 +2,6 @@ import type { DocumentOp, Editor, InlineDecoration, - InputBackend, } from "@pen/types"; import type { FieldEditorInputController } from "./controller"; import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; @@ -32,7 +31,7 @@ import type { FieldEditorTextLike, } from "./crdt"; -export class ContentEditableBackend implements InputBackend { +export class ContentEditableBackend { private element: HTMLElement | null = null; private ytext: FieldEditorTextLike | null = null; private observer: FieldEditorObserver | null = null; diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index 6e7e59c..3d720c7 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -1,7 +1,85 @@ -import type { BlockSchema } from "@pen/types"; +import type { BlockSchema, Editor, FieldEditorFocusOptions } from "@pen/types"; import type { FieldEditorStore } from "./store"; import type { EditorSelectAllBehavior } from "../constants/selectAll"; +export type FieldEditorFocusReason = + | "activate" + | "backend-activate" + | "backend-attach" + | "selection-project" + | "selection-activate" + | "selection-sync" + | "restore" + | "cell" + | "select-all"; + +export type PenFocusAction = + | "activate" + | "attach-backend" + | "focus-dom" + | "project-selection" + | "restore" + | "select-all"; + +export type PenFocusReason = NonNullable; + +export type PenFocusDecision = + | { type: "allow" } + | { type: "allow-passive" } + | { type: "deny" }; + +export interface FieldEditorFocusRequest { + editor: Editor; + target: HTMLElement; + root: HTMLElement | null; + reason: FieldEditorFocusReason; + action: PenFocusAction; + source: PenFocusReason; + blockId: string | null; + passive?: boolean; +} + +export type PenFocusRequest = FieldEditorFocusRequest; + +export interface PenFocusPolicy { + decide(request: FieldEditorFocusRequest): PenFocusDecision; + onDenied?(request: FieldEditorFocusRequest): void; +} + +export type PenFieldEditorFocusOptions = FieldEditorFocusOptions; + +export type PenFocusLifecycleEvent = + | { + type: "field-editor-attached"; + editor: Editor; + root: HTMLElement | null; + } + | { + type: "backend-attach-started" | "backend-attach-completed"; + editor: Editor; + target: HTMLElement; + blockId: string | null; + } + | { + type: "selection-projected"; + editor: Editor; + blockId: string | null; + } + | { + type: "focus-request-denied"; + request: FieldEditorFocusRequest; + } + | { + type: "activation-changed"; + editor: Editor; + activeBlockIds: readonly string[]; + isEditing: boolean; + }; + +export type PenFocusLifecycleListener = ( + event: PenFocusLifecycleEvent, +) => void; + export type ActiveCellCoord = { blockId: string; row: number; @@ -18,7 +96,7 @@ type FieldEditorSelectionState = Pick< export interface FieldEditorRootHandle { setRootElement(element: HTMLElement | null): void; setFocused(focused: boolean): void; - setInputBackend(inputBackend: "contenteditable" | "edit-context"): void; + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void; setSelectAllBehavior(behavior: EditorSelectAllBehavior): void; deactivate(): void; activateTextSelection( @@ -31,10 +109,32 @@ export interface FieldEditorRootHandle { anchorOffset: number, focusOffset: number, ): void; + focusTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): Promise; } export interface FieldEditorDomController extends FieldEditorSelectionState { setComposing(composing: boolean): void; + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ): boolean; + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: PenFieldEditorFocusOptions, + ): boolean; + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean; shouldHandleDomSelectionChange(isApplyingSelection: number): boolean; resolveProgrammaticInputRange( blockId: string | null, @@ -153,5 +253,9 @@ export type FieldEditorSession = FieldEditorStore & blockId: string; offset: number; }): void; + onFocusLifecycle( + listener: PenFocusLifecycleListener, + ): () => void; + waitForAttachment(blockId?: string | null): Promise; delegate(blockSchema: BlockSchema): boolean; }; diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index 3bd919e..8b78e3b 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -3,7 +3,6 @@ import type { DocumentOp, Editor, InlineDecoration, - InputBackend, } from "@pen/types"; import { supportsInlineInputRules } from "@pen/types"; import type { FieldEditorInputController } from "./controller"; @@ -100,7 +99,7 @@ type EditContextGlobal = typeof globalThis & { EditContext?: EditContextConstructor; }; -export class EditContextBackend implements InputBackend { +export class EditContextBackend { private editContext: EditContext | null = null; private element: HTMLElement | null = null; private ytext: FieldEditorTextLike | null = null; @@ -178,7 +177,9 @@ export class EditContextBackend implements InputBackend { ); this.isApplyingSelection++; this.updateSelection(); - element.focus({ preventScroll: true }); + this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }); requestAnimationFrame(() => { this.isApplyingSelection--; }); diff --git a/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts b/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts index 0604baa..64c757e 100644 --- a/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts @@ -1,8 +1,5 @@ import type { Editor } from "@pen/types"; -import { - editorSelectionToDOM, - domSelectionToEditor, -} from "./selectionBridge"; +import { editorSelectionToDOM, domSelectionToEditor } from "./selectionBridge"; import { handlePaste, handleCopy, handleCut } from "./clipboard"; import type { PasteImporters } from "../types/paste"; import type { FieldEditorInputController } from "./controller"; @@ -50,7 +47,16 @@ export class ExpandedContentEditableBackend { const selection = this.editor.selection; if (selection?.type === "text") { this.isApplyingSelection++; - element.focus({ preventScroll: true }); + if ( + !this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }) + ) { + requestAnimationFrame(() => { + this.isApplyingSelection--; + }); + return; + } editorSelectionToDOM(element, selection.anchor, selection.focus); requestAnimationFrame(() => { this.isApplyingSelection--; @@ -58,7 +64,9 @@ export class ExpandedContentEditableBackend { return; } - element.focus({ preventScroll: true }); + this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }); this.isApplyingSelection = 0; } @@ -102,7 +110,11 @@ export class ExpandedContentEditableBackend { private handleSelectionChange = (): void => { if (!this.element) return; - if (!this.fieldEditor.shouldHandleDomSelectionChange(this.isApplyingSelection)) { + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + this.isApplyingSelection, + ) + ) { return; } @@ -309,6 +321,9 @@ function getBlockText( }; }>(doc); return ( - ydoc.getMap("blocks").get(blockId)?.get("content") as FieldEditorTextLike | null - ) ?? null; + (ydoc + .getMap("blocks") + .get(blockId) + ?.get("content") as FieldEditorTextLike | null) ?? null + ); } diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index 4539576..8084133 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -5,7 +5,6 @@ import type { HistoryAppliedEvent, SelectionState, Unsubscribe, - InputBackend, } from "@pen/types"; import { DocumentRangeImpl } from "@pen/core"; import { @@ -18,12 +17,22 @@ import { ContentEditableBackend } from "./contenteditableBackend"; import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; import { SessionReconciler } from "./sessionReconciler"; +import type { InputBackend } from "../internal/inputBackend"; import { classifySelectionSurface } from "./crossBlock"; import { resolveMarksAtPosition } from "./markBoundary"; import type { ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorFocusRequest, FieldEditorInputController, FieldEditorSession, + PenFocusAction, + PenFocusDecision, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusReason, } from "./controller"; import { getCellYText, @@ -52,7 +61,16 @@ import { type FieldEditorOptions = { selectAllBehavior?: EditorSelectAllBehavior; - inputBackend?: "contenteditable" | "edit-context"; + focusPolicy?: PenFocusPolicy; +}; + +const ALLOW_FOCUS_DECISION: PenFocusDecision = { type: "allow" }; + +type ProgrammaticTextSelection = { + blockId: string; + anchorOffset: number; + focusOffset: number; + selectionIntentEpoch: number; }; export class FieldEditorImpl implements FieldEditorSession { @@ -78,25 +96,21 @@ export class FieldEditorImpl implements FieldEditorSession { private _suppressNextDomSelectionProjection = false; private _pointerSelectionDepth = 0; private _pendingSelectionProjectionVersion: number | null = null; + private _selectionIntentEpoch = 0; private readonly _sessionReconciler: SessionReconciler; private readonly _historySelectionCoordinator: HistorySelectionCoordinator; private _selectAllBehavior: EditorSelectAllBehavior; - private _inputBackend: "contenteditable" | "edit-context"; + private _focusPolicy: PenFocusPolicy | undefined; + private _focusLifecycleListeners = new Set(); + private _attachmentResolvers = new Set<() => void>(); private _selectAllCycle: { blockId: string; scope: "cell" | "block" | "document"; } | null = null; private _preserveSelectAllCycle = false; - private _programmaticTextSelection: { - blockId: string; - anchorOffset: number; - focusOffset: number; - } | null = null; - private _pendingProgrammaticTextSelection: { - blockId: string; - anchorOffset: number; - focusOffset: number; - } | null = null; + private _programmaticTextSelection: ProgrammaticTextSelection | null = null; + private _pendingProgrammaticTextSelection: ProgrammaticTextSelection | null = + null; private _activeCellCoord: ActiveCellCoord | null = null; constructor(editor: Editor, options?: FieldEditorOptions) { @@ -104,7 +118,7 @@ export class FieldEditorImpl implements FieldEditorSession { this._selectAllBehavior = options?.selectAllBehavior ?? resolveSelectAllBehavior("content-first"); - this._inputBackend = options?.inputBackend ?? "edit-context"; + this._focusPolicy = options?.focusPolicy; this._historySelectionCoordinator = new HistorySelectionCoordinator( this._editor, ); @@ -188,12 +202,8 @@ export class FieldEditorImpl implements FieldEditorSession { this.resetSelectAllCycle(); } - setInputBackend(inputBackend: "contenteditable" | "edit-context"): void { - if (this._inputBackend === inputBackend) { - return; - } - this._inputBackend = inputBackend; - this._syncBackendForSurfaceMode(); + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { + this._focusPolicy = focusPolicy; } // ── Lifecycle ───────────────────────────────────────────── @@ -270,7 +280,9 @@ export class FieldEditorImpl implements FieldEditorSession { } private _placeCaretInCell(cellEl: HTMLElement): void { - cellEl.focus({ preventScroll: true }); + if (!this.requestDomFocus(cellEl, "cell", { preventScroll: true })) { + return; + } const selection = cellEl.ownerDocument?.getSelection(); if (!selection) return; @@ -302,7 +314,7 @@ export class FieldEditorImpl implements FieldEditorSession { ) { this.attachElement(activeCellElement); } - selectElementContents(activeCellElement); + this._selectElementContents(activeCellElement); if (activeCellBlockId) { this._recordSelectAllScope(activeCellBlockId, "cell"); } @@ -383,8 +395,7 @@ export class FieldEditorImpl implements FieldEditorSession { } beginPointerSelection(): void { - this._programmaticTextSelection = null; - this._pendingProgrammaticTextSelection = null; + this._recordUserSelectionIntent(); this._pointerSelectionDepth += 1; } @@ -393,6 +404,7 @@ export class FieldEditorImpl implements FieldEditorSession { return; } this._pointerSelectionDepth -= 1; + this._recordUserSelectionIntent(); } setComposing(composing: boolean): void { @@ -425,27 +437,44 @@ export class FieldEditorImpl implements FieldEditorSession { this._pendingMarks = {}; for (const cb of this._deactivateListeners) cb(blockIds); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [], + isEditing: false, + }); if (options.restoreFocus) { this._restoreFocusAfterDeactivate(focusTargetId); } this._emitStateChange(); } - focus(): void { - if (!this._isEditing || !this._focusBlockId) return; + focus(options: PenFieldEditorFocusOptions = {}): boolean { + if (!this._isEditing || !this._focusBlockId) return false; const root = this._findEditorRoot(); - if (!root) return; + if (!root) return false; const blockEl = queryBlockElement(root, this._focusBlockId); const inlineEl = blockEl?.querySelector( "[data-pen-inline-content]", ) as HTMLElement | null; - if (!inlineEl) return; + if (!inlineEl) return false; const selection = this._editor.selection; - inlineEl.focus({ preventScroll: false }); + if ( + !this.requestDomFocus( + inlineEl, + "activate", + { + preventScroll: false, + }, + options, + ) + ) { + return false; + } if ( selection?.type === "text" && @@ -453,11 +482,11 @@ export class FieldEditorImpl implements FieldEditorSession { selection.focus.blockId === this._focusBlockId ) { this._backend?.updateSelection(null); - return; + return true; } const nativeSelection = root.ownerDocument?.getSelection(); - if (!nativeSelection) return; + if (!nativeSelection) return true; const range = root.ownerDocument.createRange(); range.selectNodeContents(inlineEl); @@ -465,6 +494,7 @@ export class FieldEditorImpl implements FieldEditorSession { nativeSelection.removeAllRanges(); nativeSelection.addRange(range); + return true; } blur(): void { @@ -476,8 +506,95 @@ export class FieldEditorImpl implements FieldEditorSession { } } + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions: PenFieldEditorFocusOptions = {}, + ): boolean { + const request = this._createFocusRequest(target, reason, policyOptions); + const decision = this._decideFocus(request); + if (decision.type === "deny") { + this._emitFocusDenied(request); + return false; + } + if (decision.type === "allow") { + target.focus(options); + } + return true; + } + + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + const request = this._createFocusRequest(target, reason, options); + const decision = this._decideFocus(request); + if (decision.type === "deny") { + this._emitFocusDenied(request); + return false; + } + return true; + } + + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean { + return this.requestDomFocus(target, reason, options); + } + + private _createFocusRequest( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): FieldEditorFocusRequest { + return { + editor: this._editor, + target, + root: this._findEditorRoot(), + reason, + action: resolvePenFocusAction(reason), + source: options.reason ?? resolvePenFocusReason(reason), + blockId: this._focusBlockId, + passive: options.passive ?? options.domFocus === false, + }; + } + + private _decideFocus( + request: ReturnType, + ): PenFocusDecision { + const policyDecision = this._focusPolicy?.decide(request); + if (policyDecision) { + return request.passive && policyDecision.type === "allow" + ? { type: "allow-passive" } + : policyDecision; + } + + return request.passive ? { type: "allow-passive" } : ALLOW_FOCUS_DECISION; + } + + private _emitFocusDenied( + request: ReturnType, + ): void { + this._focusPolicy?.onDenied?.(request); + this._emitFocusLifecycle({ + type: "focus-request-denied", + request, + }); + } + setRootElement(element: HTMLElement | null): void { this._rootElement = element; + if (element) { + this._emitFocusLifecycle({ + type: "field-editor-attached", + editor: this._editor, + root: element, + }); + } if (element && this._isEditing) { this._syncActiveElement(false); } @@ -502,17 +619,35 @@ export class FieldEditorImpl implements FieldEditorSession { ) as HTMLElement | null; } - attachElement(element: HTMLElement): void { - if (!this._focusBlockId) return; - if (this._attachedElement === element && this._backend) return; + attachElement( + element: HTMLElement, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + if (!this._focusBlockId) return false; + if (this._attachedElement === element && this._backend) return true; + if (!this.requestActivation(element, "backend-attach", options)) return false; + this._emitFocusLifecycle({ + type: "backend-attach-started", + editor: this._editor, + target: element, + blockId: this._focusBlockId, + }); this._backend?.deactivate(); this._backend = this.createBackend(); const ytext = this._getYText(this._focusBlockId); - if (!ytext) return; + if (!ytext) return false; this._backend.activate(element, ytext); this._attachedElement = element; + this._emitFocusLifecycle({ + type: "backend-attach-completed", + editor: this._editor, + target: element, + blockId: this._focusBlockId, + }); + this._resolveAttachmentWaiters(); + return true; } syncTextSelection( @@ -523,23 +658,46 @@ export class FieldEditorImpl implements FieldEditorSession { if (!this._isEditing) return; if (this._focusBlockId !== blockId) return; - this.setTextSelection(blockId, anchorOffset, focusOffset); + const currentSelection = this._editor.selection; const pendingProgrammaticSelection = this._pendingProgrammaticTextSelection; + const isAlreadyCurrentSelection = + currentSelection?.type === "text" && + !currentSelection.isMultiBlock && + currentSelection.anchor.blockId === blockId && + currentSelection.focus.blockId === blockId && + currentSelection.anchor.offset === anchorOffset && + currentSelection.focus.offset === focusOffset; + if (isAlreadyCurrentSelection) { + if ( + pendingProgrammaticSelection && + pendingProgrammaticSelection.blockId === blockId && + pendingProgrammaticSelection.anchorOffset === anchorOffset && + pendingProgrammaticSelection.focusOffset === focusOffset + ) { + this._pendingProgrammaticTextSelection = null; + } + return; + } + if ( pendingProgrammaticSelection && (pendingProgrammaticSelection.blockId !== blockId || pendingProgrammaticSelection.anchorOffset !== anchorOffset || pendingProgrammaticSelection.focusOffset !== focusOffset) ) { - this._pendingProgrammaticTextSelection = null; + this._recordUserSelectionIntent(); + } else if (!pendingProgrammaticSelection) { + this._selectionIntentEpoch += 1; } + this.setTextSelection(blockId, anchorOffset, focusOffset); } applyDocumentTextSelection( anchor: { blockId: string; offset: number }, focus: { blockId: string; offset: number }, ): void { + this._recordUserSelectionIntent(); this._suppressNextDomSelectionProjection = true; if (!this._isEditing || !this._focusBlockId) { @@ -570,6 +728,7 @@ export class FieldEditorImpl implements FieldEditorSession { focusBlockId?: string; }, ): void { + this._recordUserSelectionIntent(); if (anchor.blockId !== focus.blockId) { this.applyDocumentTextSelection(anchor, focus); return; @@ -676,6 +835,16 @@ export class FieldEditorImpl implements FieldEditorSession { ) { this._programmaticTextSelection = null; } + const pendingProgrammaticSelection = + this._pendingProgrammaticTextSelection; + if ( + pendingProgrammaticSelection && + (pendingProgrammaticSelection.blockId !== blockId || + pendingProgrammaticSelection.anchorOffset !== anchorOffset || + pendingProgrammaticSelection.focusOffset !== focusOffset) + ) { + this._pendingProgrammaticTextSelection = null; + } this._emitStateChange(); } @@ -683,10 +852,28 @@ export class FieldEditorImpl implements FieldEditorSession { blockId: string, anchorOffset: number, focusOffset: number, + options?: PenFieldEditorFocusOptions, ): void { this._programmaticTextSelection = null; this._pendingProgrammaticTextSelection = null; - this._projectTextSelection(blockId, anchorOffset, focusOffset); + this._projectTextSelection(blockId, anchorOffset, focusOffset, options); + } + + async focusTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options: PenFieldEditorFocusOptions = {}, + ): Promise { + this.activateTextSelection(blockId, anchorOffset, focusOffset, options); + const attached = await this.waitForAttachment(blockId); + if (!attached) { + return false; + } + if (options.domFocus === false || options.passive) { + return true; + } + return this.focus(options); } commitProgrammaticTextSelection( @@ -698,11 +885,13 @@ export class FieldEditorImpl implements FieldEditorSession { blockId, anchorOffset, focusOffset, + selectionIntentEpoch: this._selectionIntentEpoch, }; this._pendingProgrammaticTextSelection = { blockId, anchorOffset, focusOffset, + selectionIntentEpoch: this._selectionIntentEpoch, }; this._projectTextSelection(blockId, anchorOffset, focusOffset, { syncBackendImmediately: true, @@ -967,6 +1156,11 @@ export class FieldEditorImpl implements FieldEditorSession { return () => this._deactivateListeners.delete(cb); } + onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { + this._focusLifecycleListeners.add(listener); + return () => this._focusLifecycleListeners.delete(listener); + } + onSelectionChange(cb: (sel: SelectionState) => void): Unsubscribe { return this._editor.onSelectionChange(cb); } @@ -995,6 +1189,36 @@ export class FieldEditorImpl implements FieldEditorSession { return () => this._storeListeners.delete(callback); } + waitForAttachment(blockId = this._focusBlockId): Promise { + if ( + this._attachedElement?.isConnected && + (blockId == null || this._focusBlockId === blockId) + ) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + let frame = 0; + const check = () => { + if ( + this._attachedElement?.isConnected && + (blockId == null || this._focusBlockId === blockId) + ) { + resolve(true); + return; + } + if (frame >= 4) { + this._attachmentResolvers.delete(check); + resolve(false); + return; + } + frame += 1; + requestAnimationFrame(check); + }; + this._attachmentResolvers.add(check); + requestAnimationFrame(check); + }); + } + destroy(): void { this._unsubscribeSelection?.(); this._unsubscribeSelection = null; @@ -1006,6 +1230,8 @@ export class FieldEditorImpl implements FieldEditorSession { this._activateListeners.clear(); this._deactivateListeners.clear(); this._storeListeners.clear(); + this._focusLifecycleListeners.clear(); + this._attachmentResolvers.clear(); } // ── Internal ───────────────────────────────────────────── @@ -1028,7 +1254,6 @@ export class FieldEditorImpl implements FieldEditorSession { return ContentEditableBackend; } if ( - this._inputBackend === "edit-context" && "EditContext" in globalThis && typeof (globalThis as typeof globalThis & { EditContext?: unknown }) .EditContext === "function" @@ -1056,12 +1281,14 @@ export class FieldEditorImpl implements FieldEditorSession { if (blockId) { const blockEl = queryBlockElement(root, blockId); if (blockEl) { - blockEl.focus({ preventScroll: true }); + this.requestDomFocus(blockEl, "restore", { + preventScroll: true, + }); return; } } - root.focus({ preventScroll: true }); + this.requestDomFocus(root, "restore", { preventScroll: true }); } private _emitStateChange(): void { @@ -1070,6 +1297,19 @@ export class FieldEditorImpl implements FieldEditorSession { } } + private _emitFocusLifecycle(event: PenFocusLifecycleEvent): void { + for (const listener of this._focusLifecycleListeners) { + listener(event); + } + } + + private _resolveAttachmentWaiters(): void { + for (const resolve of this._attachmentResolvers) { + resolve(); + } + this._attachmentResolvers.clear(); + } + private _consumeDomSelectionProjectionSuppression(): boolean { const shouldSuppress = this._suppressNextDomSelectionProjection; this._suppressNextDomSelectionProjection = false; @@ -1148,6 +1388,12 @@ export class FieldEditorImpl implements FieldEditorSession { if (this._isEditing && blockIdsChanged) { for (const cb of this._activateListeners) cb([...blockIds]); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [...blockIds], + isEditing: true, + }); } this._emitStateChange(); @@ -1185,6 +1431,9 @@ export class FieldEditorImpl implements FieldEditorSession { const ytext = this._getYText(this._focusBlockId); if (!ytext) return; + if (!this.requestActivation(this._attachedElement, "backend-attach")) { + return; + } this._backend.activate(this._attachedElement, ytext); } @@ -1227,6 +1476,12 @@ export class FieldEditorImpl implements FieldEditorSession { }); for (const cb of this._activateListeners) cb([...this._activeBlockIds]); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [...this._activeBlockIds], + isEditing: true, + }); this._emitStateChange(); return true; } @@ -1314,7 +1569,7 @@ export class FieldEditorImpl implements FieldEditorSession { focusOffset: number, options?: { syncBackendImmediately?: boolean; - }, + } & PenFieldEditorFocusOptions, ): void { this.setTextSelection(blockId, anchorOffset, focusOffset); @@ -1325,12 +1580,14 @@ export class FieldEditorImpl implements FieldEditorSession { if (options?.syncBackendImmediately) { this._backend?.updateSelection(null); } - this._syncDomSelectionOnce(); + this._syncDomSelectionOnce(4, undefined, options); } private _syncDomSelectionOnce( remainingAttempts = 4, version?: number, + options: PenFieldEditorFocusOptions = {}, + selectionIntentEpoch = this._selectionIntentEpoch, ): void { if (version === undefined) { version = ++this._syncDomVersion; @@ -1339,6 +1596,10 @@ export class FieldEditorImpl implements FieldEditorSession { const v = version; requestAnimationFrame(() => { if (!this._isEditing || this._syncDomVersion !== v) return; + if (selectionIntentEpoch !== this._selectionIntentEpoch) { + this._cancelSelectionProjection(v); + return; + } let projected = false; const pendingProjectionRequestId = @@ -1347,33 +1608,62 @@ export class FieldEditorImpl implements FieldEditorSession { if (this._mode === "expanded") { const expandedHost = this._findExpandedHost(); if (expandedHost) { + let didAttach = true; if ( this._attachedElement !== expandedHost || !this._attachedElement?.isConnected ) { - this.attachElement(expandedHost); + didAttach = this.attachElement(expandedHost, options); + } + if ( + didAttach && + this.requestDomFocus( + expandedHost, + "selection-project", + { + preventScroll: true, + }, + options, + ) + ) { + this._backend?.updateSelection(null); + projected = true; } - expandedHost.focus({ preventScroll: true }); - this._backend?.updateSelection(null); - projected = true; } } else if (this._focusBlockId) { const inlineEl = this._resolveInlineElement(this._focusBlockId); if (inlineEl) { + let didAttach = true; if ( this._attachedElement !== inlineEl || !this._attachedElement || !this._attachedElement.isConnected ) { - this.attachElement(inlineEl); + didAttach = this.attachElement(inlineEl, options); + } + if ( + didAttach && + this.requestDomFocus( + inlineEl, + "selection-project", + { + preventScroll: true, + }, + options, + ) + ) { + this._backend?.updateSelection(null); + projected = true; } - inlineEl.focus({ preventScroll: true }); - this._backend?.updateSelection(null); - projected = true; } } if (projected) { + this._emitFocusLifecycle({ + type: "selection-projected", + editor: this._editor, + blockId: this._focusBlockId, + }); requestAnimationFrame(() => { if (this._syncDomVersion === v) { if (this._pendingSelectionProjectionVersion === v) { @@ -1387,21 +1677,39 @@ export class FieldEditorImpl implements FieldEditorSession { } if (!projected && remainingAttempts > 0) { - this._syncDomSelectionOnce(remainingAttempts - 1, v); + this._syncDomSelectionOnce( + remainingAttempts - 1, + v, + options, + selectionIntentEpoch, + ); } else if (!projected) { - if (this._pendingSelectionProjectionVersion === v) { - this._pendingSelectionProjectionVersion = null; - } - this._historySelectionCoordinator.cancelDeferredProjection(); + this._cancelSelectionProjection(v); } }); } - private _getActiveProgrammaticTextSelection(blockId: string | null): { - blockId: string; - anchorOffset: number; - focusOffset: number; - } | null { + private _recordUserSelectionIntent(): void { + this._selectionIntentEpoch += 1; + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + const pendingProjectionVersion = this._pendingSelectionProjectionVersion; + if (pendingProjectionVersion !== null) { + this._syncDomVersion += 1; + this._cancelSelectionProjection(pendingProjectionVersion); + } + } + + private _cancelSelectionProjection(version: number): void { + if (this._pendingSelectionProjectionVersion === version) { + this._pendingSelectionProjectionVersion = null; + } + this._historySelectionCoordinator.cancelDeferredProjection(); + } + + private _getActiveProgrammaticTextSelection( + blockId: string | null, + ): ProgrammaticTextSelection | null { const programmaticSelection = this._programmaticTextSelection ?? this._pendingProgrammaticTextSelection; @@ -1430,6 +1738,23 @@ export class FieldEditorImpl implements FieldEditorSession { return getCellYText(this._editor, blockId, row, col); } + private _selectElementContents(element: HTMLElement): void { + if ( + !this.requestDomFocus(element, "select-all", { + preventScroll: true, + }) + ) { + return; + } + const selection = element.ownerDocument?.getSelection(); + if (!selection) return; + + const range = element.ownerDocument.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + } + private _resolveCellElement( blockId: string, row: number, @@ -1464,17 +1789,6 @@ function resolveInputMode( return resolveFieldEditorInputMode(schema); } -function selectElementContents(element: HTMLElement): void { - element.focus({ preventScroll: true }); - const selection = element.ownerDocument?.getSelection(); - if (!selection) return; - - const range = element.ownerDocument.createRange(); - range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); -} - function isDomSelectionCoveringElementContents(element: HTMLElement): boolean { const selection = element.ownerDocument?.getSelection(); if (!selection || selection.rangeCount === 0) { @@ -1508,6 +1822,47 @@ function areBlockIdsEqual( return true; } +function resolvePenFocusAction( + reason: FieldEditorFocusReason, +): PenFocusAction { + switch (reason) { + case "backend-attach": + case "backend-activate": + return "attach-backend"; + case "selection-project": + case "selection-activate": + case "selection-sync": + return "project-selection"; + case "restore": + return "restore"; + case "select-all": + return "select-all"; + case "activate": + case "cell": + return "activate"; + } +} + +function resolvePenFocusReason( + reason: FieldEditorFocusReason, +): PenFocusReason { + switch (reason) { + case "backend-attach": + case "backend-activate": + return "backend"; + case "selection-project": + case "selection-activate": + case "selection-sync": + return "selection-sync"; + case "select-all": + case "cell": + return "keyboard"; + case "activate": + case "restore": + return "programmatic"; + } +} + function getFullDocumentTextRange(editor: Editor): { start: { blockId: string; offset: number }; end: { blockId: string; offset: number }; diff --git a/packages/rendering/dom/src/field-editor/index.ts b/packages/rendering/dom/src/field-editor/index.ts index 32ce4a3..eb51f26 100644 --- a/packages/rendering/dom/src/field-editor/index.ts +++ b/packages/rendering/dom/src/field-editor/index.ts @@ -1,7 +1,4 @@ -export type { - FieldEditorStore, - FieldEditorStoreSnapshot, -} from "./store"; +export type { FieldEditorStore, FieldEditorStoreSnapshot } from "./store"; export { applyDeltaToDOM, fullReconcileToDOM, @@ -19,6 +16,32 @@ export { type SelectionPoint, type TextDiffOp, } from "./selectionBridge"; +export { + INLINE_ATOM_LOGICAL_LENGTH, + buildMoveInlineAtomOps, + getInlineAtomAtOffset, + moveInlineAtom, + replaceInlineAtomWithText, + resolveInlineAtomDropTarget, + type InlineAtomDropTarget, + type InlineAtomSnapshot, + type InlineAtomSource, + type MoveInlineAtomOptions, + type ReplaceInlineAtomWithTextOptions, + type ResolveInlineAtomDropTargetOptions, +} from "./inlineAtomInteraction"; +export type { + FieldEditorFocusReason, + FieldEditorFocusRequest, + PenFocusAction, + PenFocusDecision, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusRequest, + PenFocusReason, +} from "./controller"; export { expandFieldEditorRange, contractFieldEditorRange, diff --git a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts index a912202..ea5e7d2 100644 --- a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts +++ b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts @@ -161,6 +161,51 @@ export function getLogicalTextContent(root: HTMLElement): string { return text; } +export function getInlineAtomPointerOffset( + container: HTMLElement, + clientX: number, + clientY: number, +): number | null { + const atomElements = Array.from( + container.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), + ).filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ); + if (atomElements.length === 0) { + return null; + } + + let bestOffset: number | null = null; + let bestScore = Number.POSITIVE_INFINITY; + + for (const atomElement of atomElements) { + const rect = atomElement.getBoundingClientRect(); + const dx = + clientX < rect.left + ? rect.left - clientX + : clientX > rect.right + ? clientX - rect.right + : 0; + const dy = + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0; + const score = dy * 1000 + dx; + if (score >= bestScore) { + continue; + } + + const atomOffset = getOffsetBeforeNode(container, atomElement); + bestOffset = + clientX <= rect.left + rect.width / 2 ? atomOffset : atomOffset + 1; + bestScore = score; + } + + return bestOffset; +} + export function domPointToLogicalOffset( container: HTMLElement, targetNode: Node, diff --git a/packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts b/packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts new file mode 100644 index 0000000..5ee2dc3 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts @@ -0,0 +1,339 @@ +import type { + ApplyOptions, + DocumentOp, + Editor, + FieldEditor, + InlineDelta, + InlineNodeDeltaInsert, +} from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { + pointToEditorSelectionPoint, + type SelectionPoint, +} from "./selectionBridge"; + +export const INLINE_ATOM_LOGICAL_LENGTH = 1; + +const ZERO_WIDTH_SPACE = "\u200B"; +const OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"; +const DEFAULT_APPLY_OPTIONS: ApplyOptions = { origin: "user", undoGroup: true }; + +export interface InlineAtomSource { + editor: Editor; + blockId: string; + offset: number; +} + +export interface InlineAtomDropTarget { + editor: Editor; + blockId: string; + offset: number; +} + +export interface InlineAtomSnapshot { + blockId: string; + offset: number; + type: string; + props: Record; + text: string; +} + +export interface ResolveInlineAtomDropTargetOptions { + editor: Editor; + root: HTMLElement | null; + clientX: number; + clientY: number; +} + +export interface MoveInlineAtomOptions { + source: InlineAtomSource; + target: InlineAtomDropTarget; + apply?: ApplyOptions; +} + +export interface ReplaceInlineAtomWithTextOptions { + source: InlineAtomSource; + text: string; + selection?: "all" | "end" | "none"; + apply?: ApplyOptions; +} + +export function getInlineAtomAtOffset( + editor: Editor, + source: Pick, +): InlineAtomSnapshot | null { + const block = editor.getBlock(source.blockId); + if (!block) { + return null; + } + + let offset = 0; + for (const delta of block.inlineDeltas()) { + const length = getInlineDeltaLength(delta); + if (offset === source.offset && typeof delta.insert !== "string") { + return { + blockId: source.blockId, + offset: source.offset, + type: delta.insert.type, + props: { ...delta.insert.props }, + text: getInlineAtomText(editor, delta.insert), + }; + } + + offset += length; + } + + return null; +} + +export function resolveInlineAtomDropTarget({ + editor, + root, + clientX, + clientY, +}: ResolveInlineAtomDropTargetOptions): InlineAtomDropTarget | null { + if (!root) { + return null; + } + + const point = pointToEditorSelectionPoint(root, clientX, clientY); + if (!point) { + return null; + } + + return { + editor, + blockId: point.blockId, + offset: point.offset, + }; +} + +export function buildMoveInlineAtomOps( + editor: Editor, + source: Pick, + target: SelectionPoint, +): DocumentOp[] { + const sourceAtom = getInlineAtomAtOffset(editor, source); + if ( + !sourceAtom || + !editor.getBlock(target.blockId) || + isNoopInlineAtomMove(source, target) + ) { + return []; + } + + const targetOffset = getAdjustedTargetOffset(source, target); + return [ + { + type: "delete-text", + blockId: source.blockId, + offset: source.offset, + length: INLINE_ATOM_LOGICAL_LENGTH, + }, + { + type: "insert-inline-node", + blockId: target.blockId, + offset: targetOffset, + nodeType: sourceAtom.type, + props: { ...sourceAtom.props }, + }, + ]; +} + +export function moveInlineAtom({ + source, + target, + apply, +}: MoveInlineAtomOptions): boolean { + if (source.editor === target.editor) { + return moveInlineAtomWithinEditor({ source, target, apply }); + } + + return moveInlineAtomBetweenEditors({ source, target, apply }); +} + +export function replaceInlineAtomWithText({ + source, + text, + selection = "end", + apply, +}: ReplaceInlineAtomWithTextOptions): boolean { + const sourceAtom = getInlineAtomAtOffset(source.editor, source); + if (!sourceAtom) { + return false; + } + + const ops: DocumentOp[] = [ + { + type: "delete-text", + blockId: source.blockId, + offset: source.offset, + length: INLINE_ATOM_LOGICAL_LENGTH, + }, + ]; + if (text.length > 0) { + ops.push({ + type: "insert-text", + blockId: source.blockId, + offset: source.offset, + text, + }); + } + + source.editor.apply(ops, apply ?? DEFAULT_APPLY_OPTIONS); + + const endOffset = source.offset + text.length; + + if (selection === "all") { + source.editor.selectText( + source.blockId, + source.offset, + endOffset, + ); + } else if (selection === "end") { + source.editor.selectText(source.blockId, endOffset, endOffset); + } + + const fieldEditor = source.editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ); + if (fieldEditor && selection !== "none") { + if (selection === "all") { + if (typeof fieldEditor.activateTextSelection === "function") { + fieldEditor.activateTextSelection( + source.blockId, + source.offset, + endOffset, + ); + } else { + fieldEditor.activate(source.blockId); + } + } else if (selection === "end") { + if (typeof fieldEditor.activateTextSelection === "function") { + fieldEditor.activateTextSelection( + source.blockId, + endOffset, + endOffset, + ); + } else { + fieldEditor.activate(source.blockId); + } + } + fieldEditor.focus(); + } + + return true; +} + +function moveInlineAtomWithinEditor({ + source, + target, + apply, +}: MoveInlineAtomOptions): boolean { + const ops = buildMoveInlineAtomOps(source.editor, source, target); + if (ops.length === 0) { + return false; + } + + const targetOffset = getAdjustedTargetOffset(source, target); + source.editor.apply(ops, apply ?? DEFAULT_APPLY_OPTIONS); + source.editor.selectText( + target.blockId, + targetOffset + INLINE_ATOM_LOGICAL_LENGTH, + targetOffset + INLINE_ATOM_LOGICAL_LENGTH, + ); + return true; +} + +function moveInlineAtomBetweenEditors({ + source, + target, + apply, +}: MoveInlineAtomOptions): boolean { + const sourceAtom = getInlineAtomAtOffset(source.editor, source); + if ( + !sourceAtom || + !target.editor.getBlock(target.blockId) || + !canInsertInlineAtom(target.editor, sourceAtom) + ) { + return false; + } + + const applyOptions = apply ?? DEFAULT_APPLY_OPTIONS; + target.editor.apply( + [ + { + type: "insert-inline-node", + blockId: target.blockId, + offset: target.offset, + nodeType: sourceAtom.type, + props: { ...sourceAtom.props }, + }, + ], + applyOptions, + ); + source.editor.apply( + [ + { + type: "delete-text", + blockId: source.blockId, + offset: source.offset, + length: INLINE_ATOM_LOGICAL_LENGTH, + }, + ], + applyOptions, + ); + target.editor.selectText( + target.blockId, + target.offset + INLINE_ATOM_LOGICAL_LENGTH, + target.offset + INLINE_ATOM_LOGICAL_LENGTH, + ); + return true; +} + +function canInsertInlineAtom( + editor: Editor, + atom: Pick, +): boolean { + return editor.schema.resolveInline(atom.type)?.kind === "node"; +} + +function isNoopInlineAtomMove( + source: Pick, + target: Pick, +): boolean { + const sourceEndOffset = source.offset + INLINE_ATOM_LOGICAL_LENGTH; + return ( + target.blockId === source.blockId && + target.offset >= source.offset && + target.offset <= sourceEndOffset + ); +} + +function getAdjustedTargetOffset( + source: Pick, + target: Pick, +): number { + return target.blockId === source.blockId && target.offset > source.offset + ? target.offset - INLINE_ATOM_LOGICAL_LENGTH + : target.offset; +} + +function getInlineDeltaLength(delta: InlineDelta): number { + return typeof delta.insert === "string" + ? delta.insert + .replaceAll(ZERO_WIDTH_SPACE, "") + .replaceAll(OBJECT_REPLACEMENT_CHARACTER, "").length + : INLINE_ATOM_LOGICAL_LENGTH; +} + +function getInlineAtomText( + editor: Editor, + atom: InlineNodeDeltaInsert, +): string { + return ( + editor.schema + .resolveInline(atom.type) + ?.serialize.toMarkdown?.("", atom.props) ?? "" + ); +} diff --git a/packages/rendering/dom/src/field-editor/selectionBridge.ts b/packages/rendering/dom/src/field-editor/selectionBridge.ts index ac226f7..dfbaec5 100644 --- a/packages/rendering/dom/src/field-editor/selectionBridge.ts +++ b/packages/rendering/dom/src/field-editor/selectionBridge.ts @@ -10,9 +10,11 @@ import { } from "../utils/blockSelectionSemantics"; import { domPointToLogicalOffset, + getInlineAtomPointerOffset, findLogicalDOMPoint, getLogicalNodeLength, getLogicalTextContent, + isInlineAtomNode, } from "./inlineAtomDom"; /** @@ -383,6 +385,14 @@ function approximateInlineOffsetFromPoint( ): number { const textLength = getLogicalNodeLength(inlineEl); if (textLength <= 0) return 0; + const inlineAtomOffset = getInlineAtomPointerOffset( + inlineEl, + clientX, + clientY, + ); + if (inlineAtomOffset !== null) { + return inlineAtomOffset; + } let bestOffset = 0; let bestScore = Number.POSITIVE_INFINITY; @@ -602,6 +612,8 @@ export function pointToEditorSelectionPoint( ): SelectionPoint | null { const doc = root.ownerDocument; if (!doc) return null; + const atomPoint = resolveInlineAtomPoint(root, clientX, clientY, options); + if (atomPoint) return atomPoint; const caretFromPoint = doc as Document & { caretPositionFromPoint?: ( x: number, @@ -612,6 +624,16 @@ export function pointToEditorSelectionPoint( const position = caretFromPoint.caretPositionFromPoint?.(clientX, clientY); if (position) { + const inlineBoundaryPoint = resolveInlineContainerBoundaryPoint( + root, + position.offsetNode, + position.offset, + clientX, + clientY, + options, + ); + if (inlineBoundaryPoint) return inlineBoundaryPoint; + const resolved = resolveSelectionPoint( root, position.offsetNode, @@ -623,6 +645,16 @@ export function pointToEditorSelectionPoint( const range = caretFromPoint.caretRangeFromPoint?.(clientX, clientY); if (range) { + const inlineBoundaryPoint = resolveInlineContainerBoundaryPoint( + root, + range.startContainer, + range.startOffset, + clientX, + clientY, + options, + ); + if (inlineBoundaryPoint) return inlineBoundaryPoint; + const resolved = resolveSelectionPoint( root, range.startContainer, @@ -646,6 +678,91 @@ export function pointToEditorSelectionPoint( ); } +function resolveInlineAtomPoint( + root: HTMLElement, + clientX: number, + clientY: number, + options: ResolveSelectionPointOptions, +): SelectionPoint | null { + const hitElement = + typeof root.ownerDocument.elementFromPoint === "function" + ? root.ownerDocument.elementFromPoint(clientX, clientY) + : null; + if (!hitElement || !root.contains(hitElement)) { + return null; + } + + const atomElement = findInlineAtomElement(hitElement, root); + if (!atomElement) { + return null; + } + + const blockEl = findBlockElement(atomElement, root); + if (!blockEl || getBlockSurfaceRole(blockEl) !== "editable-inline") { + return null; + } + + return getSelectionPointForBlockAtPointer( + blockEl, + clientX, + clientY, + options, + ); +} + +function findInlineAtomElement( + element: Element, + root: HTMLElement, +): HTMLElement | null { + let current: Element | null = element; + while (current && current !== root) { + if (isInlineAtomNode(current)) { + return current; + } + current = current.parentElement; + } + return null; +} + +function resolveInlineContainerBoundaryPoint( + root: HTMLElement, + node: Node, + offset: number, + clientX: number, + clientY: number, + options: ResolveSelectionPointOptions, +): SelectionPoint | null { + const blockEl = findBlockElement(node, root); + if (!blockEl || getBlockSurfaceRole(blockEl) !== "editable-inline") { + return null; + } + + const inlineEl = findInlineContentElement(blockEl); + if (!inlineEl || !isInlineBoundaryFallbackPoint(inlineEl, node, offset)) { + return null; + } + + const geometricPoint = getSelectionPointForBlockAtPointer( + blockEl, + clientX, + clientY, + options, + ); + return geometricPoint && geometricPoint.offset > 0 ? geometricPoint : null; +} + +function isInlineBoundaryFallbackPoint( + inlineEl: HTMLElement, + node: Node, + offset: number, +): boolean { + if (node === inlineEl) { + return offset === 0; + } + + return node instanceof HTMLElement && node.contains(inlineEl); +} + /** * Convert DOM selection range to editor (blockId, offset) pairs. */ diff --git a/packages/rendering/dom/src/index.ts b/packages/rendering/dom/src/index.ts index d3ea6dc..8c02622 100644 --- a/packages/rendering/dom/src/index.ts +++ b/packages/rendering/dom/src/index.ts @@ -1,5 +1,17 @@ export { FieldEditorImpl } from "./field-editor/fieldEditorImpl"; -export type { FieldEditorSession } from "./field-editor/controller"; +export type { + FieldEditorFocusReason, + FieldEditorFocusRequest, + FieldEditorSession, + PenFocusAction, + PenFocusDecision, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusRequest, + PenFocusReason, +} from "./field-editor/controller"; export { handleEditorDocumentKeyDown } from "./utils/documentShortcuts"; export { handleEscapeSelectionTransition } from "./utils/escapeSelection"; export { handleTableCellSelectionKeyDown } from "./utils/tableCellNavigation"; diff --git a/packages/rendering/dom/src/internal/inputBackend.ts b/packages/rendering/dom/src/internal/inputBackend.ts new file mode 100644 index 0000000..fce15f1 --- /dev/null +++ b/packages/rendering/dom/src/internal/inputBackend.ts @@ -0,0 +1,5 @@ +export interface InputBackend { + activate(element: HTMLElement, ytext: unknown): void; + deactivate(): void; + updateSelection(relPos: unknown): void; +} diff --git a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx index df4532f..c83b93f 100644 --- a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx +++ b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx @@ -2,9 +2,14 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createEditor } from "@pen/core"; import { defaultPreset } from "@pen/preset-default"; +import { createDefaultSchema } from "@pen/schema-default"; +import { + moveInlineAtom, + replaceInlineAtomWithText, +} from "@pen/dom/field-editor/inlineAtomInteraction"; import { getInlineAtomElementData, getLogicalTextContent, @@ -15,10 +20,12 @@ import { fullReconcileDeltasToDOM, } from "@pen/dom/field-editor/reconciler"; import { DATA_ATTRS } from "../utils/dataAttributes"; +import { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; import { domPointToOffset, domSelectionToEditor, editorSelectionToDOM, + pointToEditorSelectionPoint, } from "../field-editor/selectionBridge"; import { Pen } from "../primitives/index"; @@ -60,7 +67,210 @@ function seedInlineAtomDocument(editor: ReturnType) { return blockId; } +function dispatchPointerEvent( + target: EventTarget, + type: string, + options: MouseEventInit & { pointerId?: number } = {}, +) { + const PointerEventCtor = window.PointerEvent ?? MouseEvent; + target.dispatchEvent( + new PointerEventCtor(type, { + bubbles: true, + cancelable: true, + ...options, + }) as PointerEvent, + ); +} + +function createRect({ + left, + right, + top, + bottom, +}: { + left: number; + right: number; + top: number; + bottom: number; +}): DOMRect { + return { + x: left, + y: top, + left, + right, + top, + bottom, + width: right - left, + height: bottom - top, + toJSON() { + return {}; + }, + } as DOMRect; +} + describe("Pen inline atom editing", () => { + it("maps atom-only pointer positions by chip geometry", () => { + const root = document.createElement("div"); + const block = document.createElement("div"); + const inline = document.createElement("span"); + const atom = document.createElement("span"); + const originalElementFromPoint = document.elementFromPoint; + + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, "block-1"); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + inline.setAttribute(DATA_ATTRS.inlineContent, ""); + atom.setAttribute(DATA_ATTRS.inlineAtom, ""); + atom.textContent = "Ada"; + inline.appendChild(atom); + block.appendChild(inline); + root.appendChild(block); + document.body.appendChild(root); + + inline.getBoundingClientRect = () => + createRect({ left: 0, right: 120, top: 0, bottom: 24 }); + atom.getBoundingClientRect = () => + createRect({ left: 20, right: 80, top: 0, bottom: 24 }); + document.elementFromPoint = vi.fn(() => atom); + + try { + expect(pointToEditorSelectionPoint(root, 25, 12)).toEqual({ + blockId: "block-1", + offset: 0, + }); + expect(pointToEditorSelectionPoint(root, 76, 12)).toEqual({ + blockId: "block-1", + offset: 1, + }); + } finally { + document.elementFromPoint = originalElementFromPoint; + root.remove(); + } + }); + + it("denies backend attachment when the focus policy rejects activation", () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + const fieldEditor = new FieldEditorImpl(editor, { + focusPolicy: { + decide: (request) => + request.action === "attach-backend" + ? { type: "deny" } + : { type: "allow" }, + }, + }); + const root = document.createElement("div"); + const block = document.createElement("div"); + const inline = document.createElement("span"); + const focusSpy = vi.spyOn(inline, "focus"); + + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, blockId); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + inline.setAttribute(DATA_ATTRS.inlineContent, ""); + block.appendChild(inline); + root.appendChild(block); + document.body.appendChild(root); + fieldEditor.setRootElement(root); + + try { + fieldEditor.activate(blockId); + + expect( + ( + fieldEditor as unknown as { + _attachedElement: HTMLElement | null; + } + )._attachedElement, + ).toBeNull(); + expect(focusSpy).not.toHaveBeenCalled(); + } finally { + fieldEditor.destroy(); + root.remove(); + editor.destroy(); + } + }); + + it("uses focusPolicy decisions for passive selection projection", () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + const decide = vi.fn(() => ({ type: "allow-passive" as const })); + const fieldEditor = new FieldEditorImpl(editor, { + focusPolicy: { + decide, + }, + }); + const root = document.createElement("div"); + const block = document.createElement("div"); + const inline = document.createElement("span"); + const focusSpy = vi.spyOn(inline, "focus"); + + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, blockId); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + inline.setAttribute(DATA_ATTRS.inlineContent, ""); + block.appendChild(inline); + root.appendChild(block); + document.body.appendChild(root); + fieldEditor.setRootElement(root); + + try { + fieldEditor.activate(blockId); + expect(fieldEditor.requestDomFocus(inline, "selection-project")).toBe( + true, + ); + expect(focusSpy).not.toHaveBeenCalled(); + expect(decide).toHaveBeenCalledWith( + expect.objectContaining({ + action: "attach-backend", + blockId, + }), + ); + expect(decide).toHaveBeenCalledWith( + expect.objectContaining({ + action: "project-selection", + blockId, + }), + ); + } finally { + fieldEditor.destroy(); + root.remove(); + editor.destroy(); + } + }); + + it("does not let a stale pending programmatic caret hide a newer user caret", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const fieldEditor = new FieldEditorImpl(editor); + const block = editor.getBlock(blockId)!; + const endOffset = block.length(); + + try { + fieldEditor.commitProgrammaticTextSelection(blockId, 0, 0); + fieldEditor.applyDomTextSelection( + { blockId, offset: endOffset }, + { blockId, offset: endOffset }, + ); + + expect( + fieldEditor.shouldIgnoreDomTextSelection( + { blockId, offset: endOffset }, + { blockId, offset: endOffset }, + ), + ).toBe(false); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: endOffset }, + focus: { blockId, offset: endOffset }, + }); + await flushAnimationFrames(1); + } finally { + fieldEditor.destroy(); + editor.destroy(); + } + }); + it("renders inline nodes as logical atom elements", async () => { const editor = createPresetEditor(); seedInlineAtomDocument(editor); @@ -174,6 +384,246 @@ describe("Pen inline atom editing", () => { } }); + it("fires onAfterDestructure once after a successful wrapper double-click destructure", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onAfterDestructure = vi.fn(); + + try { + await act(async () => { + root.render( + + `${atom.props.label as string} `, + }, + onAfterDestructure, + }} + > + + , + ); + await flushAnimationFrames(2); + }); + + const atom = container.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + expect(atom).not.toBeNull(); + + await act(async () => { + atom!.dispatchEvent( + new MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + }), + ); + await flushAnimationFrames(2); + }); + + expect(onAfterDestructure).toHaveBeenCalledTimes(1); + expect(onAfterDestructure).toHaveBeenCalledWith({ + editor, + atom: expect.objectContaining({ + blockId, + offset: 1, + type: "mention", + }), + blockId, + startOffset: 1, + endOffset: 22, + text: "Ada ", + }); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("destructures inline atoms from the Pen wrapper double-click handler", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + `${atom.props.label as string} `, + }, + }} + > + + , + ); + await flushAnimationFrames(2); + }); + + const atom = container.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + expect(atom).not.toBeNull(); + + await act(async () => { + atom!.dispatchEvent( + new MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "AAda B" }, + ]); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 22 }, + focus: { blockId, offset: 22 }, + }); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + + it("shows a Pen-owned preview and renderer drag state while dragging an inline atom", async () => { + const editor = createPresetEditor(); + seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const documentWithCaret = document as Document & { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => CaretPosition | null; + elementFromPoint?: (x: number, y: number) => Element | null; + }; + const originalCaretPositionFromPoint = + documentWithCaret.caretPositionFromPoint; + const originalElementFromPoint = documentWithCaret.elementFromPoint; + + try { + await act(async () => { + root.render( + ( + + {props.label as string}:{text} + + ), + }} + > + + , + ); + await flushAnimationFrames(2); + }); + + const atom = container.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + const inlineElement = container.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + const renderedAtom = container.querySelector( + "[data-testid='mention-renderer']", + ); + expect(atom).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + expect(renderedAtom?.getAttribute("data-dragging")).toBe("false"); + + Object.defineProperty(atom!, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(10, 10, 80, 24), + }); + documentWithCaret.elementFromPoint = () => inlineElement; + documentWithCaret.caretPositionFromPoint = () => ({ + offsetNode: inlineElement!.firstChild ?? inlineElement!, + offset: 1, + getClientRect: () => new DOMRect(40, 10, 0, 20), + }); + + await act(async () => { + dispatchPointerEvent(atom!, "pointerdown", { + button: 0, + clientX: 20, + clientY: 20, + pointerId: 1, + }); + dispatchPointerEvent(document, "pointermove", { + clientX: 50, + clientY: 24, + pointerId: 1, + }); + await flushAnimationFrames(2); + }); + + expect(atom?.hasAttribute(DATA_ATTRS.inlineAtomDragging)).toBe( + true, + ); + expect(renderedAtom?.getAttribute("data-dragging")).toBe("true"); + expect( + document.querySelector( + "[data-pen-inline-atom-drag-preview-root]", + ), + ).not.toBeNull(); + + await act(async () => { + dispatchPointerEvent(document, "pointercancel", { + pointerId: 1, + }); + await flushAnimationFrames(2); + }); + + expect(atom?.hasAttribute(DATA_ATTRS.inlineAtomDragging)).toBe( + false, + ); + expect(renderedAtom?.getAttribute("data-dragging")).toBe("false"); + expect( + document.querySelector( + "[data-pen-inline-atom-drag-preview-root]", + ), + ).toBeNull(); + } finally { + documentWithCaret.caretPositionFromPoint = + originalCaretPositionFromPoint; + documentWithCaret.elementFromPoint = originalElementFromPoint; + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); + it("applies text deltas around inline atoms at logical boundaries", () => { const editor = createPresetEditor(); const element = document.createElement("span"); @@ -228,6 +678,315 @@ describe("Pen inline atom editing", () => { editor.destroy(); }); + it("resolves inline-container tail clicks after an atom to the logical end", () => { + const blockId = "atom-block"; + const container = document.createElement("div"); + container.setAttribute(DATA_ATTRS.editorRoot, ""); + const block = document.createElement("div"); + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, blockId); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + const inlineElement = document.createElement("span"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + const atom = document.createElement("span"); + atom.setAttribute(DATA_ATTRS.inlineAtom, ""); + atom.setAttribute(DATA_ATTRS.inlineAtomType, "mention"); + atom.contentEditable = "false"; + atom.textContent = "Ada"; + + inlineElement.appendChild(atom); + block.appendChild(inlineElement); + container.appendChild(block); + document.body.appendChild(container); + + Object.defineProperty(inlineElement, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 200, 20), + }); + + const documentWithCaret = document as Document & { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => CaretPosition | null; + }; + const originalCaretPositionFromPoint = + documentWithCaret.caretPositionFromPoint; + const originalCreateRange = document.createRange.bind(document); + + documentWithCaret.caretPositionFromPoint = () => ({ + offsetNode: inlineElement, + offset: 0, + getClientRect: () => new DOMRect(0, 0, 0, 20), + }); + document.createRange = () => { + const range = originalCreateRange(); + const originalSetStart = range.setStart.bind(range); + let startContainer: Node | null = null; + let startOffset = 0; + range.setStart = (node: Node, offset: number) => { + startContainer = node; + startOffset = offset; + originalSetStart(node, offset); + }; + ( + range as Range & { getBoundingClientRect: () => DOMRect } + ).getBoundingClientRect = () => { + if (startContainer === inlineElement && startOffset === 0) { + return new DOMRect(0, 0, 80, 20); + } + return new DOMRect(80, 0, 0, 20); + }; + return range; + }; + + try { + expect(pointToEditorSelectionPoint(container, 160, 10)).toEqual({ + blockId, + offset: 1, + }); + } finally { + documentWithCaret.caretPositionFromPoint = + originalCaretPositionFromPoint; + document.createRange = originalCreateRange; + container.remove(); + } + }); + + it("resolves inline-wrapper tail clicks after an atom to the logical end", () => { + const blockId = "atom-block"; + const container = document.createElement("div"); + container.setAttribute(DATA_ATTRS.editorRoot, ""); + const block = document.createElement("div"); + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, blockId); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + const wrapper = document.createElement("div"); + wrapper.setAttribute(DATA_ATTRS.blockType, "paragraph"); + const inlineElement = document.createElement("span"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + const atom = document.createElement("span"); + atom.setAttribute(DATA_ATTRS.inlineAtom, ""); + atom.setAttribute(DATA_ATTRS.inlineAtomType, "mention"); + atom.contentEditable = "false"; + atom.textContent = "Ada"; + + inlineElement.appendChild(atom); + wrapper.appendChild(inlineElement); + block.appendChild(wrapper); + container.appendChild(block); + document.body.appendChild(container); + + Object.defineProperty(inlineElement, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 200, 20), + }); + + const documentWithCaret = document as Document & { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => CaretPosition | null; + }; + const originalCaretPositionFromPoint = + documentWithCaret.caretPositionFromPoint; + const originalCreateRange = document.createRange.bind(document); + + documentWithCaret.caretPositionFromPoint = () => ({ + offsetNode: wrapper, + offset: 0, + getClientRect: () => new DOMRect(0, 0, 0, 20), + }); + document.createRange = () => { + const range = originalCreateRange(); + const originalSetStart = range.setStart.bind(range); + let startContainer: Node | null = null; + let startOffset = 0; + range.setStart = (node: Node, offset: number) => { + startContainer = node; + startOffset = offset; + originalSetStart(node, offset); + }; + ( + range as Range & { getBoundingClientRect: () => DOMRect } + ).getBoundingClientRect = () => { + if (startContainer === inlineElement && startOffset === 0) { + return new DOMRect(0, 0, 80, 20); + } + return new DOMRect(80, 0, 0, 20); + }; + return range; + }; + + try { + expect(pointToEditorSelectionPoint(container, 160, 10)).toEqual({ + blockId, + offset: 1, + }); + } finally { + documentWithCaret.caretPositionFromPoint = + originalCaretPositionFromPoint; + document.createRange = originalCreateRange; + container.remove(); + } + }); + + it("moves an inline atom within one editor", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + + try { + expect( + moveInlineAtom({ + source: { editor, blockId, offset: 1 }, + target: { editor, blockId, offset: 3 }, + }), + ).toBe(true); + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "AB" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + ]); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + }); + } finally { + editor.destroy(); + } + }); + + it("moves an inline atom between compatible editors", () => { + const sourceEditor = createPresetEditor(); + const targetEditor = createPresetEditor(); + const sourceBlockId = seedInlineAtomDocument(sourceEditor); + const targetBlockId = targetEditor.firstBlock()!.id; + targetEditor.apply([ + { + type: "insert-text", + blockId: targetBlockId, + offset: 0, + text: "Z", + }, + ]); + + try { + expect( + moveInlineAtom({ + source: { + editor: sourceEditor, + blockId: sourceBlockId, + offset: 1, + }, + target: { + editor: targetEditor, + blockId: targetBlockId, + offset: 1, + }, + }), + ).toBe(true); + expect( + sourceEditor.getBlock(sourceBlockId)?.inlineDeltas(), + ).toEqual([{ insert: "AB" }]); + expect( + targetEditor.getBlock(targetBlockId)?.inlineDeltas(), + ).toEqual([ + { insert: "Z" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + ]); + expect(targetEditor.selection).toMatchObject({ + type: "text", + anchor: { blockId: targetBlockId, offset: 2 }, + }); + } finally { + sourceEditor.destroy(); + targetEditor.destroy(); + } + }); + + it("rejects cross-editor moves when the target schema does not support the atom", () => { + const sourceEditor = createPresetEditor(); + const targetEditor = createEditor({ + schema: createDefaultSchema().without(["mention"]), + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const sourceBlockId = seedInlineAtomDocument(sourceEditor); + const targetBlockId = targetEditor.firstBlock()!.id; + + try { + expect( + moveInlineAtom({ + source: { + editor: sourceEditor, + blockId: sourceBlockId, + offset: 1, + }, + target: { + editor: targetEditor, + blockId: targetBlockId, + offset: 0, + }, + }), + ).toBe(false); + expect( + sourceEditor.getBlock(sourceBlockId)?.inlineDeltas(), + ).toEqual([ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ]); + expect( + targetEditor.getBlock(targetBlockId)?.inlineDeltas(), + ).toEqual([{ insert: "\u200B" }]); + } finally { + sourceEditor.destroy(); + targetEditor.destroy(); + } + }); + + it("destructures an inline atom into selected editable text", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + + try { + expect( + replaceInlineAtomWithText({ + source: { editor, blockId, offset: 1 }, + text: "Ada Lovelace ", + selection: "all", + }), + ).toBe(true); + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "AAda Lovelace B" }, + ]); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 31 }, + }); + } finally { + editor.destroy(); + } + }); + it("refreshes inline atom metadata when reconciliation changes atom props", () => { const editor = createPresetEditor(); const element = document.createElement("span"); diff --git a/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx b/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx index 46ffcd9..fff5532 100644 --- a/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx +++ b/packages/rendering/react/src/__tests__/placeholderBehavior.test.tsx @@ -4,14 +4,14 @@ import React, { act } from "react"; import { afterEach, describe, expect, it } from "vitest"; import { createRoot } from "react-dom/client"; import { createEditor, ensureInlineCompletionController } from "@pen/core"; -import type { BlockHandle, BlockRenderContext } from "@pen/types"; +import { + type BlockHandle, + type BlockRenderContext, +} from "@pen/types"; import { defaultPreset } from "@pen/preset-default"; import { InlineContent } from "../primitives/editor/inlineContent"; import { Pen } from "../primitives/index"; -import { - ParagraphRenderer, - registerRenderer, -} from "../renderers/index"; +import { ParagraphRenderer, registerRenderer } from "../renderers/index"; ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } @@ -62,7 +62,9 @@ describe("@pen/react placeholder behavior", () => { ); }); - const placeholders = container.querySelectorAll("[data-placeholder-visible]"); + const placeholders = container.querySelectorAll( + "[data-placeholder-visible]", + ); expect(placeholders).toHaveLength(1); expect(placeholders[0]?.getAttribute("data-placeholder")).toBe( "Start writing...", @@ -78,6 +80,53 @@ describe("@pen/react placeholder behavior", () => { editor.destroy(); }); + it("hides the document empty placeholder for a single atom-only block", async () => { + registerRenderer("paragraph", PlaceholderParagraphRenderer); + + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const blockId = editor.firstBlock()!.id; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + editor.apply([ + { + type: "insert-inline-node", + blockId, + offset: 0, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + ]); + + await act(async () => { + root.render( + + + , + ); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(0); + expect( + container.querySelector("[data-pen-inline-atom]"), + ).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + it("hides the document empty placeholder while an inline completion is visible", async () => { registerRenderer("paragraph", PlaceholderParagraphRenderer); @@ -169,7 +218,9 @@ describe("@pen/react placeholder behavior", () => { editor.selectText(blockId, 0, 0); }); - const placeholders = container.querySelectorAll("[data-placeholder-visible]"); + const placeholders = container.querySelectorAll( + "[data-placeholder-visible]", + ); expect(placeholders).toHaveLength(1); expect(placeholders[0]?.getAttribute("data-placeholder")).toContain( "/ for commands", @@ -237,16 +288,18 @@ describe("@pen/react placeholder behavior", () => { }); }); - const suggestionSurface = container.querySelector(".pen-ephemeral-suggestion"); + const suggestionSurface = container.querySelector( + ".pen-ephemeral-suggestion", + ); expect(suggestionSurface?.getAttribute("data-suggestion-id")).toBe( "suggestion-1", ); expect(suggestionSurface?.getAttribute("data-suggestion-text")).toBe( "Thanks for the update.", ); - expect(suggestionSurface?.getAttribute("data-suggestion-placement")).toBe( - "after", - ); + expect( + suggestionSurface?.getAttribute("data-suggestion-placement"), + ).toBe("after"); expect( container.querySelectorAll("[data-placeholder-visible]"), ).toHaveLength(0); @@ -391,13 +444,17 @@ describe("@pen/react placeholder behavior", () => { editor.selectText(secondBlockId, 0, 0); }); - const placeholders = container.querySelectorAll("[data-placeholder-visible]"); + const placeholders = container.querySelectorAll( + "[data-placeholder-visible]", + ); expect(placeholders).toHaveLength(1); expect(placeholders[0]?.getAttribute("data-placeholder")).toBe( "Type ⌘I for AI Agent, or / for commands", ); expect( - placeholders[0]?.closest("[data-block-id]")?.getAttribute("data-block-id"), + placeholders[0] + ?.closest("[data-block-id]") + ?.getAttribute("data-block-id"), ).toBe(secondBlockId); await act(async () => { @@ -407,6 +464,71 @@ describe("@pen/react placeholder behavior", () => { editor.destroy(); }); + it("hides active block placeholders for an atom-only block", async () => { + registerRenderer("paragraph", PlaceholderParagraphRenderer); + + const editor = createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + editor.apply([ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-inline-node", + blockId: secondBlockId, + offset: 0, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + ]); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + editor.selectText(secondBlockId, 0, 0); + }); + + expect( + container.querySelectorAll("[data-placeholder-visible]"), + ).toHaveLength(0); + expect( + container.querySelector("[data-pen-inline-atom]"), + ).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + it("hides active empty block placeholders while any inline completion is visible", async () => { registerRenderer("paragraph", PlaceholderParagraphRenderer); diff --git a/packages/rendering/react/src/context/editorContext.ts b/packages/rendering/react/src/context/editorContext.ts index 71e349e..a5ea900 100644 --- a/packages/rendering/react/src/context/editorContext.ts +++ b/packages/rendering/react/src/context/editorContext.ts @@ -8,6 +8,11 @@ import type { InteractionModel, } from "@pen/types"; import type { PendingBlock } from "@pen/core"; +import type { + InlineAtomDropTarget, + InlineAtomSnapshot, + InlineAtomSource, +} from "@pen/dom/field-editor/inlineAtomInteraction"; import { resolveSelectAllBehavior, type EditorSelectAllBehavior, @@ -23,16 +28,97 @@ export interface PasteImporters { export type RendererOverrides = Partial>; export interface InlineAtomRenderProps { + blockId: string; + offset: number; type: string; props: Record; text: string; selected: boolean; + interaction?: InlineAtomRenderInteractionProps; } export type InlineAtomRenderer = (props: InlineAtomRenderProps) => ReactNode; export type InlineAtomRenderers = Partial>; +export interface InlineAtomRenderInteractionProps { + draggable: boolean; + dragging: boolean; + canDestructure: boolean; + destructure?: () => boolean; +} + +export type InlineAtomDestructureHandler = ( + atom: InlineAtomSnapshot, +) => string | null | undefined; + +export interface InlineAtomMoveEvent { + source: InlineAtomSource; + target: InlineAtomDropTarget; + atom: InlineAtomSnapshot; +} + +export interface InlineAtomMoveRejectedEvent { + source: InlineAtomSource; + target?: InlineAtomDropTarget; + atom?: InlineAtomSnapshot; + reason: + | "readonly" + | "disabled" + | "stale-source" + | "missing-target" + | "schema" + | "policy" + | "noop"; +} + +export interface InlineAtomAfterDestructureEvent { + editor: Editor; + atom: InlineAtomSnapshot; + blockId: string; + startOffset: number; + endOffset: number; + text: string; +} + +export type InlineAtomAfterDestructureObserver = ( + event: InlineAtomAfterDestructureEvent, +) => void; + +export type InlineAtomMoveObserver = ( + event: InlineAtomMoveEvent, +) => boolean | void; + +export type InlineAtomMoveRejectedObserver = ( + event: InlineAtomMoveRejectedEvent, +) => void; + +export type InlineAtomInteractions = + | boolean + | { + drag?: boolean; + destructure?: + | boolean + | InlineAtomDestructureHandler + | Partial>; + onBeforeMove?: InlineAtomMoveObserver; + onMove?: InlineAtomMoveObserver; + onMoveRejected?: InlineAtomMoveRejectedObserver; + onAfterDestructure?: InlineAtomAfterDestructureObserver; + }; + +export interface ResolvedInlineAtomInteractions { + drag: boolean; + destructure: + | boolean + | InlineAtomDestructureHandler + | Partial>; + onBeforeMove?: InlineAtomMoveObserver; + onMove?: InlineAtomMoveObserver; + onMoveRejected?: InlineAtomMoveRejectedObserver; + onAfterDestructure?: InlineAtomAfterDestructureObserver; +} + export interface BlockDragAndDropOptions { enabled?: boolean; } @@ -94,6 +180,26 @@ export function resolveBlockSelection( }; } +export function resolveInlineAtomInteractions( + options?: InlineAtomInteractions, +): ResolvedInlineAtomInteractions { + if (options === true) { + return { drag: true, destructure: false }; + } + if (!options) { + return { drag: false, destructure: false }; + } + + return { + drag: options.drag ?? false, + destructure: options.destructure ?? false, + onBeforeMove: options.onBeforeMove, + onMove: options.onMove, + onMoveRejected: options.onMoveRejected, + onAfterDestructure: options.onAfterDestructure, + }; +} + export interface BlockControlsProps { blockId: string; blockType: string; @@ -115,6 +221,7 @@ export interface EditorContextValue { assets?: AssetProvider; renderers?: RendererOverrides; inlineAtomRenderers?: InlineAtomRenderers; + inlineAtomInteractions: ResolvedInlineAtomInteractions; } export const EditorContext = createContext(null); diff --git a/packages/rendering/react/src/hooks/index.ts b/packages/rendering/react/src/hooks/index.ts index 2119f85..686be32 100644 --- a/packages/rendering/react/src/hooks/index.ts +++ b/packages/rendering/react/src/hooks/index.ts @@ -36,6 +36,15 @@ export { export { useAIActions } from "./useAIActions"; export { useAISessionActions } from "./useAISessionActions"; export { useFieldEditor } from "./useFieldEditor"; +export { + useEditorFocusController, + useFocusController, + type PenFocusController, + type PenFocusOptions, + type PenFocusOffset, + type PenRangeFocusRequest, + type PenTextFocusRequest, +} from "./useFocusController"; export { useHistory } from "./useHistory"; export { useSearch } from "./useSearch"; export { useMultiplayer } from "./useMultiplayer"; diff --git a/packages/rendering/react/src/hooks/useFocusController.test.tsx b/packages/rendering/react/src/hooks/useFocusController.test.tsx new file mode 100644 index 0000000..d93aef3 --- /dev/null +++ b/packages/rendering/react/src/hooks/useFocusController.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +import React, { act, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { createEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import type { Editor } from "@pen/types"; +import type { PenFocusPolicy } from "@pen/dom"; +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { Pen } from "../primitives/index"; +import { + useFocusController, + type PenFocusController, +} from "./useFocusController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createPresetEditor() { + return createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +function FocusProbe({ + editor, + onReady, +}: { + editor: Editor; + onReady: (controller: PenFocusController) => void; +}) { + const controller = Pen.useFocusController(editor); + useEffect(() => { + onReady(controller); + }, [controller, onReady]); + return null; +} + +async function renderEditor({ + editor, + focusPolicy, + onReady, +}: { + editor: Editor; + focusPolicy?: PenFocusPolicy; + onReady: (controller: PenFocusController) => void; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + , + ); + await flushAnimationFrames(3); + }); + + return { + container, + root, + cleanup: () => { + root.unmount(); + container.remove(); + editor.destroy(); + }, + }; +} + +describe("useFocusController", () => { + it("focuses a text selection when policy allows DOM focus", async () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + let controller: PenFocusController | undefined; + const rendered = await renderEditor({ + editor, + onReady: (nextController) => { + controller = nextController; + }, + }); + const inline = rendered.container.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement; + const focusSpy = vi.spyOn(inline, "focus"); + + try { + let didFocus = false; + await act(async () => { + didFocus = await controller!.end(blockId, { + reason: "programmatic", + }); + }); + + expect(didFocus).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 0 }, + }); + } finally { + rendered.cleanup(); + } + }); + + it("returns false when policy denies focus", async () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + let controller: PenFocusController | undefined; + const focusPolicy: PenFocusPolicy = { + decide: () => ({ type: "deny" }), + }; + const rendered = await renderEditor({ + editor, + focusPolicy, + onReady: (nextController) => { + controller = nextController; + }, + }); + + try { + let didFocus = true; + await act(async () => { + didFocus = await controller!.end(blockId, { + reason: "programmatic", + }); + }); + + expect(didFocus).toBe(false); + } finally { + rendered.cleanup(); + } + }); + + it("projects passive selections without DOM focus", async () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + let controller: PenFocusController | undefined; + const decide = vi.fn(() => ({ type: "allow" as const })); + const rendered = await renderEditor({ + editor, + focusPolicy: { decide }, + onReady: (nextController) => { + controller = nextController; + }, + }); + const inline = rendered.container.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement; + const focusSpy = vi.spyOn(inline, "focus"); + + try { + let didProject = false; + await act(async () => { + didProject = await controller!.range({ + blockId, + anchorOffset: 0, + focusOffset: 0, + domFocus: false, + reason: "programmatic", + }); + await flushAnimationFrames(2); + }); + + expect(didProject).toBe(true); + expect(focusSpy).not.toHaveBeenCalled(); + const requests = decide.mock.calls as unknown as Array< + Parameters + >; + expect(requests.some(([request]) => request.passive === true)).toBe( + true, + ); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: 0 }, + }); + } finally { + rendered.cleanup(); + } + }); + + it("returns false when no field editor is attached", async () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + const controller = useFocusController(editor); + + try { + await expect(controller.end(blockId)).resolves.toBe(false); + } finally { + editor.destroy(); + } + }); + + it("restores a previously captured range", async () => { + const editor = createPresetEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Hello", + }, + ]); + let controller: PenFocusController | undefined; + const rendered = await renderEditor({ + editor, + onReady: (nextController) => { + controller = nextController; + }, + }); + + try { + let didRestore = false; + await act(async () => { + didRestore = await controller!.restore({ + blockId, + anchorOffset: 1, + focusOffset: 4, + reason: "programmatic", + }); + }); + + expect(didRestore).toBe(true); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 4 }, + }); + } finally { + rendered.cleanup(); + } + }); +}); diff --git a/packages/rendering/react/src/hooks/useFocusController.ts b/packages/rendering/react/src/hooks/useFocusController.ts new file mode 100644 index 0000000..a0000ff --- /dev/null +++ b/packages/rendering/react/src/hooks/useFocusController.ts @@ -0,0 +1,180 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorSession, PenFocusReason } from "@pen/dom"; +import { getAttachedFieldEditor } from "../utils/fieldEditor"; +import { useEditorContext } from "../context/editorContext"; + +export type PenFocusOffset = number | "start" | "end"; + +export type PenFocusOptions = { + reason?: PenFocusReason; + domFocus?: boolean; + passive?: boolean; +}; + +export type PenTextFocusRequest = PenFocusOptions & { + blockId: string; + offset?: PenFocusOffset; +}; + +export type PenRangeFocusRequest = PenFocusOptions & { + blockId: string; + anchorOffset: number; + focusOffset: number; +}; + +export type PenFocusController = { + text(request: PenTextFocusRequest): Promise; + start(blockId: string, request?: Omit): Promise; + end(blockId: string, request?: Omit): Promise; + range(request: PenRangeFocusRequest): Promise; + restore(request: PenTextFocusRequest | PenRangeFocusRequest): Promise; + blur(): void; + waitForAttachment(blockId?: string | null): Promise; +}; + +export function useEditorFocusController(): PenFocusController { + const { editor } = useEditorContext(); + return useFocusController(editor); +} + +export function useFocusController(editor: Editor): PenFocusController { + const getFieldEditor = () => + getAttachedFieldEditor(editor) as FieldEditorSession | null; + + return { + text: async (request) => { + const offset = resolveFocusOffset(editor, request.blockId, request.offset); + return focusRange(getFieldEditor, { + blockId: request.blockId, + anchorOffset: offset, + focusOffset: offset, + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive, + }); + }, + start: async (blockId, request = {}) => { + return focusRange(getFieldEditor, { + blockId, + anchorOffset: 0, + focusOffset: 0, + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive, + }); + }, + end: async (blockId, request = {}) => { + const offset = resolveFocusOffset(editor, blockId, "end"); + return focusRange(getFieldEditor, { + blockId, + anchorOffset: offset, + focusOffset: offset, + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive, + }); + }, + range: async (request) => + focusRange(getFieldEditor, { + blockId: request.blockId, + anchorOffset: request.anchorOffset, + focusOffset: request.focusOffset, + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive, + }), + restore: async (request) => { + if ("anchorOffset" in request) { + return focusRange(getFieldEditor, { + blockId: request.blockId, + anchorOffset: request.anchorOffset, + focusOffset: request.focusOffset, + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive, + }); + } + const offset = resolveFocusOffset(editor, request.blockId, request.offset); + return focusRange(getFieldEditor, { + blockId: request.blockId, + anchorOffset: offset, + focusOffset: offset, + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive, + }); + }, + blur: () => { + getFieldEditor()?.blur(); + }, + waitForAttachment: async (blockId) => { + const fieldEditor = await waitForFieldEditor(getFieldEditor); + return fieldEditor?.waitForAttachment(blockId) ?? false; + }, + }; +} + +async function focusRange( + getFieldEditor: () => FieldEditorSession | null, + request: { + blockId: string; + anchorOffset: number; + focusOffset: number; + reason?: PenFocusReason; + domFocus?: boolean; + passive?: boolean; + }, +): Promise { + const fieldEditor = await waitForFieldEditor(getFieldEditor); + if (!fieldEditor) { + return false; + } + + return fieldEditor.focusTextSelection( + request.blockId, + request.anchorOffset, + request.focusOffset, + { + reason: request.reason, + domFocus: request.domFocus, + passive: request.passive ?? request.domFocus === false, + }, + ); +} + +async function waitForFieldEditor( + getFieldEditor: () => FieldEditorSession | null, +): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + const fieldEditor = getFieldEditor(); + if (fieldEditor) { + return fieldEditor; + } + await nextFrame(); + } + return null; +} + +function resolveFocusOffset( + editor: Editor, + blockId: string, + offset: PenFocusOffset = "end", +): number { + if (typeof offset === "number") { + return offset; + } + if (offset === "start") { + return 0; + } + return editor.getBlock(blockId)?.length() ?? 0; +} + +function nextFrame(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()); + return; + } + setTimeout(resolve, 0); + }); +} diff --git a/packages/rendering/react/src/index.ts b/packages/rendering/react/src/index.ts index eb5f775..9d9c89f 100644 --- a/packages/rendering/react/src/index.ts +++ b/packages/rendering/react/src/index.ts @@ -30,7 +30,9 @@ export { EditorFieldEditor, type EditorCaretVariant, type EditorRootProps, + type InlineAtomInteractions, type InlineAtomRenderProps, + type InlineAtomRenderInteractionProps, type InlineAtomRenderer, type InlineAtomRenderers, type EditorContentProps, @@ -236,6 +238,8 @@ export { useAttribution, useEditor, useFieldEditor, + useEditorFocusController, + useFocusController, useHistory, useSearch, useMultiplayer, @@ -260,6 +264,11 @@ export { type AIDebugLogFastApplyMetrics, type AIDebugLogState, type AttributionState, + type PenFocusController, + type PenFocusOptions, + type PenFocusOffset, + type PenRangeFocusRequest, + type PenTextFocusRequest, type AIStructuredPreviewSelection, type AIStructuredTargetPreviewSelection, type AISuggestionPopoverPosition, @@ -391,6 +400,17 @@ export { getAttachedFieldEditor, getAttachedFieldEditorStore, } from "./utils/fieldEditor"; +export type { + FieldEditorFocusRequest, + PenFocusAction, + PenFocusDecision, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusRequest, + PenFocusReason, +} from "@pen/dom"; export { isCellInSelection } from "./utils/cellSelection"; // ── Re-export key types from @pen/types for convenience ───── @@ -405,7 +425,6 @@ export type { InlineDecoration, BlockDecoration, FieldEditor, - InputBackend, } from "@pen/types"; export type { diff --git a/packages/rendering/react/src/primitives/editor/content.tsx b/packages/rendering/react/src/primitives/editor/content.tsx index f60ff9a..b35219f 100644 --- a/packages/rendering/react/src/primitives/editor/content.tsx +++ b/packages/rendering/react/src/primitives/editor/content.tsx @@ -369,7 +369,17 @@ export function EditorContent(props: EditorContentProps) { const doc = root.ownerDocument; const activeEl = doc?.activeElement; if (activeEl instanceof Node && root.contains(activeEl)) return; - root.focus({ preventScroll: true }); + if ( + typeof fieldEditor.requestRootFocus === "function" && + !fieldEditor.requestRootFocus(root, "activate", { + preventScroll: true, + }) + ) { + return; + } + if (typeof fieldEditor.requestRootFocus !== "function") { + root.focus({ preventScroll: true }); + } }; const activateCanonicalSelection = ( diff --git a/packages/rendering/react/src/primitives/editor/index.ts b/packages/rendering/react/src/primitives/editor/index.ts index ec94c1b..5644f3e 100644 --- a/packages/rendering/react/src/primitives/editor/index.ts +++ b/packages/rendering/react/src/primitives/editor/index.ts @@ -1,6 +1,8 @@ export { EditorRoot, type EditorRootProps } from "./root"; export type { + InlineAtomInteractions, InlineAtomRenderProps, + InlineAtomRenderInteractionProps, InlineAtomRenderer, InlineAtomRenderers, } from "../../context/editorContext"; diff --git a/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts b/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts new file mode 100644 index 0000000..d0832a5 --- /dev/null +++ b/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts @@ -0,0 +1,476 @@ +import { + getInlineAtomAtOffset, + moveInlineAtom, + replaceInlineAtomWithText, + resolveInlineAtomDropTarget, + type InlineAtomDropTarget, + type InlineAtomSnapshot, + type InlineAtomSource, +} from "@pen/dom/field-editor/inlineAtomInteraction"; +import type { Editor } from "@pen/types"; +import type { FieldEditorSession } from "@pen/dom"; +import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { getAttachedFieldEditor } from "../../utils/fieldEditor"; +import type { + InlineAtomDestructureHandler, + InlineAtomRenderInteractionProps, + ResolvedInlineAtomInteractions, +} from "../../context/editorContext"; +import { + createInlineAtomDragPreview, + type InlineAtomDragPreview, +} from "../../utils/inlineAtomDragPreview"; + +const DRAG_THRESHOLD_PX = 4; + +interface InlineAtomInteractionRootState { + editor: Editor; + readonly: boolean; + interactions: ResolvedInlineAtomInteractions; +} + +export interface InlineAtomWrapperInteractionOptions { + element: HTMLElement; + editor: Editor; + blockId: string; + offset: number; + type: string; + text: string; + props: Record; + selected: boolean; + interactions: ResolvedInlineAtomInteractions; + readonly: boolean; +} + +interface PointerSession { + source: InlineAtomSource; + sourceElement: HTMLElement; + sourceRoot: HTMLElement; + atom: InlineAtomSnapshot; + startX: number; + startY: number; + latestX: number; + latestY: number; + isDragging: boolean; + animationFrameId: number | null; + preview: InlineAtomDragPreview | null; +} + +export interface InlineAtomDragSnapshot { + source: InlineAtomSource | null; + dragging: boolean; + version: number; +} + +const rootRegistry = new Map(); +const dragListeners = new Set<() => void>(); +let dragSnapshot: InlineAtomDragSnapshot = { + source: null, + dragging: false, + version: 0, +}; +let pointerSession: PointerSession | null = null; + +export function registerInlineAtomInteractionRoot( + root: HTMLElement, + state: InlineAtomInteractionRootState, +): () => void { + rootRegistry.set(root, state); + return () => { + const current = rootRegistry.get(root); + if (current === state) { + rootRegistry.delete(root); + } + }; +} + +export function attachInlineAtomWrapperInteractions( + options: InlineAtomWrapperInteractionOptions, +): () => void { + const handlePointerDown = (event: PointerEvent) => { + if ( + event.button !== 0 || + options.readonly || + !options.interactions.drag || + pointerSession + ) { + return; + } + + const atom = getInlineAtomAtOffset(options.editor, { + blockId: options.blockId, + offset: options.offset, + }); + if (!atom) { + notifyRejected(options, { reason: "stale-source" }); + return; + } + const sourceRoot = getRegisteredRootForElement(options.element); + if (!sourceRoot) { + notifyRejected(options, { reason: "missing-target" }); + return; + } + + pointerSession = { + source: { + editor: options.editor, + blockId: options.blockId, + offset: options.offset, + }, + sourceElement: options.element, + sourceRoot, + atom, + startX: event.clientX, + startY: event.clientY, + latestX: event.clientX, + latestY: event.clientY, + isDragging: false, + animationFrameId: null, + preview: null, + }; + options.element.setPointerCapture?.(event.pointerId); + }; + + const handleDoubleClick = (event: MouseEvent) => { + if (options.readonly || !canDestructure(options)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + destructureInlineAtom(options); + }; + + options.element.addEventListener("pointerdown", handlePointerDown); + options.element.addEventListener("dblclick", handleDoubleClick); + return () => { + options.element.removeEventListener("pointerdown", handlePointerDown); + options.element.removeEventListener("dblclick", handleDoubleClick); + }; +} + +export function getInlineAtomRenderInteractionProps( + options: InlineAtomWrapperInteractionOptions, + dragging = false, +): InlineAtomRenderInteractionProps | undefined { + if (!options.interactions.drag && !canDestructure(options)) { + return undefined; + } + + return { + draggable: options.interactions.drag && !options.readonly, + dragging, + canDestructure: canDestructure(options) && !options.readonly, + destructure: canDestructure(options) + ? () => destructureInlineAtom(options) + : undefined, + }; +} + +export function subscribeInlineAtomDragSnapshot( + listener: () => void, +): () => void { + dragListeners.add(listener); + return () => { + dragListeners.delete(listener); + }; +} + +export function getInlineAtomDragSnapshot(): InlineAtomDragSnapshot { + return dragSnapshot; +} + +export function isInlineAtomDragSource( + snapshot: InlineAtomDragSnapshot, + editor: Editor, + blockId: string, + offset: number, +): boolean { + return ( + snapshot.dragging && + snapshot.source?.editor === editor && + snapshot.source.blockId === blockId && + snapshot.source.offset === offset + ); +} + +function handleDocumentPointerMove(event: PointerEvent): void { + const session = pointerSession; + if (!session) { + return; + } + + session.latestX = event.clientX; + session.latestY = event.clientY; + + const movedDistance = Math.hypot( + event.clientX - session.startX, + event.clientY - session.startY, + ); + if (!session.isDragging && movedDistance < DRAG_THRESHOLD_PX) { + return; + } + + event.preventDefault(); + + if (!session.isDragging) { + startInlineAtomDrag(session); + } + + schedulePointerMoveFrame(session); +} + +function handleDocumentPointerUp(event: PointerEvent): void { + const session = pointerSession; + pointerSession = null; + if (!session?.isDragging) { + cleanupPointerSession(session); + return; + } + + event.preventDefault(); + const target = resolveTargetFromPoint(event.clientX, event.clientY); + const sourceState = rootRegistry.get(session.sourceRoot); + cleanupPointerSession(session); + if (!sourceState) { + return; + } + + if (!target) { + sourceState.interactions.onMoveRejected?.({ + source: session.source, + atom: session.atom, + reason: "missing-target", + }); + return; + } + + if ( + sourceState.interactions.onBeforeMove?.({ + source: session.source, + target, + atom: session.atom, + }) === false + ) { + sourceState.interactions.onMoveRejected?.({ + source: session.source, + target, + atom: session.atom, + reason: "policy", + }); + return; + } + + const moved = moveInlineAtom({ source: session.source, target }); + if (!moved) { + sourceState.interactions.onMoveRejected?.({ + source: session.source, + target, + atom: session.atom, + reason: "schema", + }); + return; + } + + sourceState.interactions.onMove?.({ + source: session.source, + target, + atom: session.atom, + }); +} + +function handleDocumentPointerCancel(): void { + const session = pointerSession; + pointerSession = null; + cleanupPointerSession(session); +} + +function startInlineAtomDrag(session: PointerSession): void { + session.isDragging = true; + session.sourceElement.toggleAttribute(DATA_ATTRS.inlineAtomDragging, true); + session.preview = createInlineAtomDragPreview({ + sourceElement: session.sourceElement, + clientX: session.latestX, + clientY: session.latestY, + }); + setDragSnapshot({ + source: session.source, + dragging: true, + version: dragSnapshot.version + 1, + }); +} + +function schedulePointerMoveFrame(session: PointerSession): void { + if (session.animationFrameId != null) { + return; + } + + session.animationFrameId = requestAnimationFrame(() => { + session.animationFrameId = null; + if (pointerSession !== session || !session.isDragging) { + return; + } + + session.preview?.updatePosition(session.latestX, session.latestY); + }); +} + +function cleanupPointerSession(session: PointerSession | null): void { + if (!session) { + return; + } + + if (session.animationFrameId != null) { + cancelAnimationFrame(session.animationFrameId); + session.animationFrameId = null; + } + + session.preview?.destroy(); + session.preview = null; + session.sourceElement.toggleAttribute(DATA_ATTRS.inlineAtomDragging, false); + + if (dragSnapshot.dragging && dragSnapshot.source === session.source) { + setDragSnapshot({ + source: null, + dragging: false, + version: dragSnapshot.version + 1, + }); + } +} + +function setDragSnapshot(nextSnapshot: InlineAtomDragSnapshot): void { + dragSnapshot = nextSnapshot; + dragListeners.forEach((listener) => listener()); +} + +function resolveTargetFromPoint( + clientX: number, + clientY: number, +): InlineAtomDropTarget | null { + const doc = document; + const element = doc.elementFromPoint(clientX, clientY); + const root = + element instanceof HTMLElement + ? element.closest(`[${DATA_ATTRS.editorRoot}]`) + : null; + if (!root) { + return null; + } + + const state = rootRegistry.get(root); + if (!state || state.readonly || !state.interactions.drag) { + return null; + } + + return resolveInlineAtomDropTarget({ + editor: state.editor, + root, + clientX, + clientY, + }); +} + +function destructureInlineAtom( + options: InlineAtomWrapperInteractionOptions, +): boolean { + const atom = getInlineAtomAtOffset(options.editor, { + blockId: options.blockId, + offset: options.offset, + }); + if (!atom) { + notifyRejected(options, { reason: "stale-source" }); + return false; + } + + const text = resolveDestructureText(options.interactions.destructure, atom); + if (text == null) { + return false; + } + + const didReplace = replaceInlineAtomWithText({ + source: { + editor: options.editor, + blockId: options.blockId, + offset: options.offset, + }, + text, + selection: "end", + }); + if (!didReplace) { + return false; + } + + options.interactions.onAfterDestructure?.({ + editor: options.editor, + atom, + blockId: options.blockId, + startOffset: options.offset, + endOffset: options.offset + text.length, + text, + }); + const fieldEditor = getAttachedFieldEditor( + options.editor, + ) as FieldEditorSession | null; + requestAnimationFrame(() => { + fieldEditor?.activateTextSelection( + options.blockId, + options.offset + text.length, + options.offset + text.length, + ); + fieldEditor?.focus(); + }); + return true; +} + +function canDestructure(options: InlineAtomWrapperInteractionOptions): boolean { + return options.interactions.destructure !== false; +} + +function resolveDestructureText( + destructure: ResolvedInlineAtomInteractions["destructure"], + atom: InlineAtomSnapshot, +): string | null | undefined { + if (typeof destructure === "function") { + return destructure(atom); + } + if (destructure === true) { + return atom.text; + } + if (destructure && typeof destructure === "object") { + return destructure[atom.type]?.(atom); + } + return null; +} + +function notifyRejected( + options: InlineAtomWrapperInteractionOptions, + event: Omit< + Parameters< + NonNullable + >[0], + "source" + >, +): void { + options.interactions.onMoveRejected?.({ + source: { + editor: options.editor, + blockId: options.blockId, + offset: options.offset, + }, + ...event, + }); +} + +function getRegisteredRootForElement(element: HTMLElement): HTMLElement | null { + return element.closest(`[${DATA_ATTRS.editorRoot}]`); +} + +if (typeof document !== "undefined") { + document.addEventListener("pointermove", handleDocumentPointerMove, true); + document.addEventListener("pointerup", handleDocumentPointerUp, true); + document.addEventListener( + "pointercancel", + handleDocumentPointerCancel, + true, + ); +} diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index a3c14c0..78b81d7 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -1,4 +1,9 @@ -import React, { useRef, useLayoutEffect, useState } from "react"; +import React, { + useRef, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; import { createPortal } from "react-dom"; import { getOpOriginType, @@ -31,7 +36,15 @@ import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { DATA_ATTRS } from "../../utils/dataAttributes"; import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs"; import { applyInlineDecorationsToDeltas } from "../../utils/inlineDecorations"; +import { isInlineContentEmpty } from "../../utils/editorEmptyState"; import { resolveInlinePlaceholderVisibility } from "../../utils/placeholderVisibility"; +import { + attachInlineAtomWrapperInteractions, + getInlineAtomDragSnapshot, + getInlineAtomRenderInteractionProps, + isInlineAtomDragSource, + subscribeInlineAtomDragSnapshot, +} from "./inlineAtomInteraction"; export interface InlineContentProps extends AsChildProps { blockId: string; @@ -43,7 +56,7 @@ export interface InlineContentProps extends AsChildProps { interface InlineAtomRenderTarget { key: string; element: HTMLElement; - renderer: InlineAtomRenderer; + renderer?: InlineAtomRenderer; type: string; props: Record; text: string; @@ -52,7 +65,8 @@ interface InlineAtomRenderTarget { export function InlineContent(props: InlineContentProps) { const { blockId, className, placeholder: placeholderProp, ...rest } = props; - const { editor, inlineAtomRenderers } = useEditorContext(); + const { editor, inlineAtomInteractions, inlineAtomRenderers, readonly } = + useEditorContext(); const { emptyPlaceholder, isEmpty: isDocumentEmpty } = useEditorContentContext(); const fieldEditor = useFieldEditorContext(); @@ -70,6 +84,11 @@ export function InlineContent(props: InlineContentProps) { const [inlineAtomTargets, setInlineAtomTargets] = useState< InlineAtomRenderTarget[] >([]); + const inlineAtomDragSnapshot = useSyncExternalStore( + subscribeInlineAtomDragSnapshot, + getInlineAtomDragSnapshot, + getInlineAtomDragSnapshot, + ); const isExpandedOwnedBlock = fieldEditorState.mode === "expanded" && fieldEditorState.activeBlockIds.includes(blockId); @@ -82,7 +101,7 @@ export function InlineContent(props: InlineContentProps) { selection.isCollapsed && selection.focus.blockId === blockId); - const blockTextEmpty = !textSnapshot.text || textSnapshot.text === "\u200B"; + const blockTextEmpty = isInlineContentEmpty(textSnapshot.deltas); const emptyInlineCompletionText = visibleInlineCompletion?.type === "inline" && visibleInlineCompletion.blockId === blockId && @@ -273,22 +292,52 @@ export function InlineContent(props: InlineContentProps) { } : undefined, }; - const inlineAtomPortals = inlineAtomTargets.map((target) => - createPortal( - target.renderer({ - type: target.type, - props: target.props, - text: target.text, - selected: isInlineAtomSelected( - selection, + const inlineAtomPortals = inlineAtomTargets.flatMap((target) => { + if (!target.renderer) { + return []; + } + + const selected = isInlineAtomSelected( + selection, + blockId, + target.offset, + ); + const dragging = isInlineAtomDragSource( + inlineAtomDragSnapshot, + editor, + blockId, + target.offset, + ); + return [ + createPortal( + target.renderer({ blockId, - target.offset, - ), - }), - target.element, - target.key, - ), - ); + offset: target.offset, + type: target.type, + props: target.props, + text: target.text, + selected, + interaction: getInlineAtomRenderInteractionProps( + { + element: target.element, + editor, + blockId, + offset: target.offset, + type: target.type, + text: target.text, + props: target.props, + selected, + interactions: inlineAtomInteractions, + readonly, + }, + dragging, + ), + }), + target.element, + target.key, + ), + ]; + }); useLayoutEffect(() => { inlineAtomTargets.forEach((target) => { @@ -296,8 +345,49 @@ export function InlineContent(props: InlineContentProps) { DATA_ATTRS.selected, isInlineAtomSelected(selection, blockId, target.offset), ); + target.element.toggleAttribute( + DATA_ATTRS.inlineAtomDragging, + isInlineAtomDragSource( + inlineAtomDragSnapshot, + editor, + blockId, + target.offset, + ), + ); }); - }, [blockId, inlineAtomTargets, selection]); + }, [blockId, editor, inlineAtomDragSnapshot, inlineAtomTargets, selection]); + + useLayoutEffect(() => { + const cleanups = inlineAtomTargets.map((target) => + attachInlineAtomWrapperInteractions({ + element: target.element, + editor, + blockId, + offset: target.offset, + type: target.type, + text: target.text, + props: target.props, + selected: isInlineAtomSelected( + selection, + blockId, + target.offset, + ), + interactions: inlineAtomInteractions, + readonly, + }), + ); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, [ + blockId, + editor, + inlineAtomInteractions, + inlineAtomTargets, + readonly, + selection, + ]); return ( <> @@ -316,7 +406,7 @@ function resolveNextInlineAtomTargets( renderers: InlineAtomRenderers | undefined, currentTargets: InlineAtomRenderTarget[], ): InlineAtomRenderTarget[] { - if (!root || !renderers) { + if (!root) { return currentTargets.length === 0 ? currentTargets : []; } @@ -328,11 +418,10 @@ function resolveNextInlineAtomTargets( return []; } - const renderer = renderers[data.type]; - if (!renderer) { - return []; + const renderer = renderers?.[data.type]; + if (renderer) { + clearInlineAtomFallbackText(element, data.text); } - clearInlineAtomFallbackText(element, data.text); const offset = domPointToLogicalOffset(root, element, 0); return [ diff --git a/packages/rendering/react/src/primitives/editor/root.tsx b/packages/rendering/react/src/primitives/editor/root.tsx index 0e6fd87..be8b1fb 100644 --- a/packages/rendering/react/src/primitives/editor/root.tsx +++ b/packages/rendering/react/src/primitives/editor/root.tsx @@ -11,11 +11,13 @@ import { type BlockControlsRenderer, type BlockDragAndDropOptions, type BlockSelectionOptions, + type InlineAtomInteractions, type InlineAtomRenderers, type ResolvedBlockDragAndDropOptions, type PasteImporters, type RendererOverrides, resolveBlockSelection, + resolveInlineAtomInteractions, resolveInteractionModel, } from "../../context/editorContext"; import { FieldEditorContext } from "../../context/fieldEditorContext"; @@ -25,6 +27,8 @@ import { handleEditorDocumentKeyDown, shouldHandleEditorKeyboardEvent as shouldHandlePenEditorKeyboardEvent, type FieldEditorSession, + type PenFocusLifecycleListener, + type PenFocusPolicy, } from "@pen/dom"; import { useDocumentEmptyState } from "../../hooks/useDocumentEmptyState"; import { domSelectionToEditor } from "../../field-editor/selectionBridge"; @@ -36,14 +40,18 @@ import { renderAsChild, type AsChildProps } from "../../utils/asChild"; import { composeRefs } from "../../utils/composeRefs"; import { DATA_ATTRS } from "../../utils/dataAttributes"; import { BlockDragSessionProvider } from "./blockDragSession"; +import { registerInlineAtomInteractionRoot } from "./inlineAtomInteraction"; export interface EditorRootProps extends AsChildProps { editor: Editor; readonly?: boolean; + focusPolicy?: PenFocusPolicy; + onFocusLifecycle?: PenFocusLifecycleListener; importers?: PasteImporters; assets?: AssetProvider; renderers?: RendererOverrides; inlineAtomRenderers?: InlineAtomRenderers; + inlineAtomInteractions?: InlineAtomInteractions; blockControls?: BlockControlsRenderer; editorViewMode?: EditorViewMode; interactionModel?: InteractionModel; @@ -56,10 +64,13 @@ export function EditorRoot(props: EditorRootProps) { const { editor, readonly = false, + focusPolicy, + onFocusLifecycle, importers, assets, renderers, inlineAtomRenderers, + inlineAtomInteractions, blockControls, editorViewMode = editor.editorViewMode, interactionModel, @@ -77,6 +88,9 @@ export function EditorRoot(props: EditorRootProps) { interactionModel, ); const resolvedBlockSelection = resolveBlockSelection(blockSelection); + const resolvedInlineAtomInteractions = resolveInlineAtomInteractions( + inlineAtomInteractions, + ); const [focused, setFocused] = useState(false); const [rootElement, setRootElement] = useState(null); const isEmpty = useDocumentEmptyState(editor); @@ -88,6 +102,7 @@ export function EditorRoot(props: EditorRootProps) { if (!fieldEditorRef.current) { const fieldEditorOptions = { selectAllBehavior: resolvedInteractionModel.selectAllBehavior, + focusPolicy, }; fieldEditorRef.current = new FieldEditorImpl( editor, @@ -104,6 +119,17 @@ export function EditorRoot(props: EditorRootProps) { ); }, [resolvedInteractionModel.selectAllBehavior]); + useEffect(() => { + fieldEditorRef.current?.setFocusPolicy(focusPolicy); + }, [focusPolicy]); + + useEffect(() => { + if (!onFocusLifecycle) { + return; + } + return fieldEditorRef.current?.onFocusLifecycle(onFocusLifecycle); + }, [onFocusLifecycle]); + useEffect(() => { const root = rootRef.current; const fieldEditor = fieldEditorRef.current; @@ -166,6 +192,19 @@ export function EditorRoot(props: EditorRootProps) { }; }, []); + useEffect(() => { + const root = rootElement; + if (!root) { + return; + } + + return registerInlineAtomInteractionRoot(root, { + editor, + readonly, + interactions: resolvedInlineAtomInteractions, + }); + }, [editor, readonly, resolvedInlineAtomInteractions, rootElement]); + useEffect(() => { const root = rootRef.current; const fieldEditor = fieldEditorRef.current; @@ -239,6 +278,7 @@ export function EditorRoot(props: EditorRootProps) { assets: resolvedAssets, renderers, inlineAtomRenderers, + inlineAtomInteractions: resolvedInlineAtomInteractions, }} > diff --git a/packages/rendering/react/src/primitives/index.ts b/packages/rendering/react/src/primitives/index.ts index aebc168..c613959 100644 --- a/packages/rendering/react/src/primitives/index.ts +++ b/packages/rendering/react/src/primitives/index.ts @@ -199,8 +199,14 @@ import { MultiplayerRemoteCursors, MultiplayerCaretOverlay, } from "./multiplayer/index"; +import { + useEditorFocusController, + useFocusController, +} from "../hooks/useFocusController"; export const Pen = { + useEditorFocusController, + useFocusController, Editor: { Root: EditorRoot, Content: EditorContent, diff --git a/packages/rendering/react/src/primitives/toolbar/root.tsx b/packages/rendering/react/src/primitives/toolbar/root.tsx index 95df825..933ba3a 100644 --- a/packages/rendering/react/src/primitives/toolbar/root.tsx +++ b/packages/rendering/react/src/primitives/toolbar/root.tsx @@ -1,6 +1,10 @@ import React, { useContext } from "react"; import type { Editor } from "@pen/types"; -import { EditorContext, resolveInteractionModel } from "../../context/editorContext"; +import { + EditorContext, + resolveInlineAtomInteractions, + resolveInteractionModel, +} from "../../context/editorContext"; import { ToolbarContext, type ToolbarContextValue, @@ -33,9 +37,11 @@ export function ToolbarRoot(props: ToolbarRootProps) { readonly: editorContext?.readonly ?? false, documentProfile: editor.documentProfile, editorViewMode: editorContext?.editorViewMode ?? editor.editorViewMode, - interactionModel: editorContext?.interactionModel ?? resolveInteractionModel( - editorContext?.editorViewMode ?? editor.editorViewMode, - ), + interactionModel: + editorContext?.interactionModel ?? + resolveInteractionModel( + editorContext?.editorViewMode ?? editor.editorViewMode, + ), blockDragAndDrop: editorContext?.blockDragAndDrop ?? { enabled: false, }, @@ -46,6 +52,9 @@ export function ToolbarRoot(props: ToolbarRootProps) { importers: editorContext?.importers, assets: editorContext?.assets, renderers: editorContext?.renderers, + inlineAtomInteractions: + editorContext?.inlineAtomInteractions ?? + resolveInlineAtomInteractions(), }; const ctx: ToolbarContextValue = { editor, state }; diff --git a/packages/rendering/react/src/utils/dataAttributes.ts b/packages/rendering/react/src/utils/dataAttributes.ts index da4ad39..749ed63 100644 --- a/packages/rendering/react/src/utils/dataAttributes.ts +++ b/packages/rendering/react/src/utils/dataAttributes.ts @@ -23,6 +23,7 @@ export const DATA_ATTRS = { inlineContent: "data-pen-inline-content", inlineAtom: "data-pen-inline-atom", inlineAtomType: "data-pen-inline-atom-type", + inlineAtomDragging: "data-pen-inline-atom-dragging", fieldEditorSurface: "data-pen-field-editor-surface", fieldEditorActiveSurface: "data-pen-field-editor-active-surface", fieldEditor: "data-pen-field-editor", diff --git a/packages/rendering/react/src/utils/editorEmptyState.ts b/packages/rendering/react/src/utils/editorEmptyState.ts index bf329ce..e3b1b4b 100644 --- a/packages/rendering/react/src/utils/editorEmptyState.ts +++ b/packages/rendering/react/src/utils/editorEmptyState.ts @@ -1,5 +1,11 @@ import type { Editor } from "@pen/types"; +interface InlineDeltaLike { + insert: string | object; +} + +const ZERO_WIDTH_SPACE = "\u200B"; + export function computeDocumentEmpty(editor: Editor): boolean { return editor.documentState.isEmpty; } @@ -14,6 +20,16 @@ export function computeDocumentPlaceholderVisible(editor: Editor): boolean { if (!schema || schema.content !== "inline" || schema.fieldEditor === "none") { return false; } - const text = block.textContent(); - return !text || text === "\u200B"; + return isInlineContentEmpty(block.inlineDeltas()); +} + +export function isInlineContentEmpty( + deltas: readonly InlineDeltaLike[], +): boolean { + return deltas.every((delta) => { + if (typeof delta.insert !== "string") { + return false; + } + return delta.insert.replaceAll(ZERO_WIDTH_SPACE, "").length === 0; + }); } diff --git a/packages/rendering/react/src/utils/inlineAtomDragPreview.ts b/packages/rendering/react/src/utils/inlineAtomDragPreview.ts new file mode 100644 index 0000000..b496134 --- /dev/null +++ b/packages/rendering/react/src/utils/inlineAtomDragPreview.ts @@ -0,0 +1,128 @@ +import { DATA_ATTRS } from "./dataAttributes"; + +const DRAG_PREVIEW_ROOT_ATTR = "data-pen-inline-atom-drag-preview-root"; +const DRAG_PREVIEW_ATTR = "data-pen-inline-atom-drag-preview"; + +export interface InlineAtomDragPreview { + element: HTMLElement; + updatePosition(clientX: number, clientY: number): void; + destroy(): void; +} + +export function createInlineAtomDragPreview(args: { + sourceElement: HTMLElement; + clientX: number; + clientY: number; +}): InlineAtomDragPreview { + const { sourceElement } = args; + const ownerDocument = sourceElement.ownerDocument; + const root = getPreviewRoot(ownerDocument); + const rect = sourceElement.getBoundingClientRect(); + const grabOffsetX = Math.max(0, args.clientX - rect.left); + const grabOffsetY = Math.max(0, args.clientY - rect.top); + const preview = ownerDocument.createElement("div"); + preview.setAttribute(DRAG_PREVIEW_ATTR, ""); + preview.setAttribute("aria-hidden", "true"); + preview.style.position = "fixed"; + preview.style.top = "0"; + preview.style.left = "0"; + preview.style.pointerEvents = "none"; + preview.style.zIndex = "2147483647"; + preview.style.opacity = "0.96"; + preview.style.width = `${rect.width}px`; + preview.style.maxWidth = "min(480px, calc(100vw - 48px))"; + preview.style.filter = "drop-shadow(0 12px 28px rgba(0, 0, 0, 0.22))"; + preview.style.willChange = "transform"; + + const clone = sourceElement.cloneNode(true) as HTMLElement; + removeDuplicateIds(clone); + resetInlineAtomStateAttrs(clone); + clone.style.margin = "0"; + clone.style.pointerEvents = "none"; + preview.append(clone); + root.replaceChildren(preview); + + const updatePosition = (clientX: number, clientY: number) => { + preview.style.transform = `translate3d(${clientX - grabOffsetX}px, ${clientY - grabOffsetY}px, 0)`; + }; + + updatePosition(args.clientX, args.clientY); + + return { + element: preview, + updatePosition, + destroy() { + preview.remove(); + if (root.childElementCount === 0) { + root.remove(); + } + }, + }; +} + +export function clearInlineAtomDragPreview( + ownerDocument: Document | null | undefined, +) { + if (!ownerDocument) { + return; + } + + const previewRoot = ownerDocument.querySelector( + `[${DRAG_PREVIEW_ROOT_ATTR}]`, + ) as HTMLElement | null; + if (!previewRoot) { + return; + } + + previewRoot.replaceChildren(); + previewRoot.remove(); +} + +function getPreviewRoot(ownerDocument: Document): HTMLElement { + let root = ownerDocument.querySelector( + `[${DRAG_PREVIEW_ROOT_ATTR}]`, + ) as HTMLElement | null; + if (root) { + return root; + } + + root = ownerDocument.createElement("div"); + root.setAttribute(DRAG_PREVIEW_ROOT_ATTR, ""); + root.style.position = "fixed"; + root.style.top = "0"; + root.style.left = "0"; + root.style.width = "0"; + root.style.height = "0"; + root.style.pointerEvents = "none"; + root.style.zIndex = "2147483647"; + ownerDocument.body.append(root); + return root; +} + +function removeDuplicateIds(clone: HTMLElement) { + if (clone.id) { + clone.removeAttribute("id"); + } + + for (const element of clone.querySelectorAll("[id]")) { + element.removeAttribute("id"); + } +} + +function resetInlineAtomStateAttrs(clone: HTMLElement) { + const attrsToReset = [ + DATA_ATTRS.selected, + DATA_ATTRS.dragging, + DATA_ATTRS.inlineAtomDragging, + DATA_ATTRS.dropTarget, + DATA_ATTRS.dropPosition, + DATA_ATTRS.focused, + ]; + + for (const attr of attrsToReset) { + clone.removeAttribute(attr); + for (const element of clone.querySelectorAll(`[${attr}]`)) { + element.removeAttribute(attr); + } + } +} diff --git a/packages/types/src/suggestion.ts b/packages/types/src/suggestion.ts index 74f305f..53bad54 100644 --- a/packages/types/src/suggestion.ts +++ b/packages/types/src/suggestion.ts @@ -26,6 +26,13 @@ export const suggestion: InlineSchema = { .describe("Whether the author is a human or AI"), createdAt: prop.number().default(0).describe("Unix timestamp"), model: prop.string().optional().describe("AI model identifier"), + sessionId: prop.string().optional().describe("AI session identifier"), + requestId: prop.string().optional().describe("AI request identifier"), + turnId: prop.string().optional().describe("AI turn identifier"), + generationId: prop + .string() + .optional() + .describe("AI generation identifier"), }), kind: "mark", system: true, diff --git a/packages/types/src/types/fieldEditor.ts b/packages/types/src/types/fieldEditor.ts index 85680ba..3baecb5 100644 --- a/packages/types/src/types/fieldEditor.ts +++ b/packages/types/src/types/fieldEditor.ts @@ -4,6 +4,20 @@ import type { GenerationZone } from "./crdt"; import type { Unsubscribe } from "./utility"; import type { FieldEditorInputMode } from "./fieldEditorCapabilities"; +export type FieldEditorFocusReason = + | "user-pointer" + | "keyboard" + | "programmatic" + | "default" + | "backend" + | "selection-sync"; + +export type FieldEditorFocusOptions = { + reason?: FieldEditorFocusReason; + domFocus?: boolean; + passive?: boolean; +}; + export interface FieldEditor { readonly focusBlockId: string | null; readonly activeBlockIds: readonly string[]; @@ -13,7 +27,7 @@ export interface FieldEditor { readonly inputMode: FieldEditorInputMode; selection: SelectionState | null; - focus(): void; + focus(options?: FieldEditorFocusOptions): boolean; blur(): void; activate(blockId: string): void; activateCell?(blockId: string, row: number, col: number): void; @@ -36,12 +50,20 @@ export interface FieldEditor { blockId: string, anchorOffset: number, focusOffset: number, + options?: FieldEditorFocusOptions, ): void; + focusTextSelection?( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: FieldEditorFocusOptions, + ): Promise; commitProgrammaticTextSelection?( blockId: string, anchorOffset: number, focusOffset: number, ): void; + waitForAttachment?(blockId?: string | null): Promise; expandTo(blockId: string): void; contractToFocused(): void; @@ -61,12 +83,6 @@ export interface FieldEditor { ): Unsubscribe; } -export interface InputBackend { - activate(element: HTMLElement, ytext: unknown): void; - deactivate(): void; - updateSelection(relPos: unknown): void; -} - export interface StreamingTarget { readonly generationZone: GenerationZone | null; beginStreaming(zoneId: string, blockId: string): void; diff --git a/packages/types/src/types/index.ts b/packages/types/src/types/index.ts index 328c6d0..8b2fdac 100644 --- a/packages/types/src/types/index.ts +++ b/packages/types/src/types/index.ts @@ -186,7 +186,12 @@ export type { export { DEFAULT_DATABASE_COLUMN_WIDTH } from "./database"; // ── Field Editor ──────────────────────────────────────────── -export type { FieldEditor, InputBackend, StreamingTarget } from "./fieldEditor"; +export type { + FieldEditor, + FieldEditorFocusOptions, + FieldEditorFocusReason, + StreamingTarget, +} from "./fieldEditor"; export type { FieldEditorBehavior, FieldEditorInputMode, diff --git a/packages/types/src/types/suggestions.ts b/packages/types/src/types/suggestions.ts index 07c8481..7db924c 100644 --- a/packages/types/src/types/suggestions.ts +++ b/packages/types/src/types/suggestions.ts @@ -7,6 +7,10 @@ export interface BlockSuggestion { authorType: "user" | "ai"; createdAt: number; model?: string; + sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; previousState?: { type?: string; position?: Position; diff --git a/packages/types/src/types/tools.ts b/packages/types/src/types/tools.ts index 8bf165b..2b57848 100644 --- a/packages/types/src/types/tools.ts +++ b/packages/types/src/types/tools.ts @@ -6,18 +6,18 @@ import type { PropSchema } from "./schema"; // ── Tool Registry + Runtime ──────────────────────────────── export interface ToolRegistry { - registerTool(def: ToolDefinition): void; - unregisterTool(name: string): void; - listTools(): readonly ToolDefinition[]; - getTool(name: string): ToolDefinition | null; + registerTool(def: ToolDefinition): void; + unregisterTool(name: string): void; + listTools(): readonly ToolDefinition[]; + getTool(name: string): ToolDefinition | null; } export interface ToolRuntime extends ToolRegistry { - executeTool( - name: string, - input: unknown, - ctx: ToolContext, - ): Promise | AsyncIterable; + executeTool( + name: string, + input: unknown, + ctx: ToolContext, + ): Promise | AsyncIterable; } /** @@ -25,223 +25,226 @@ export interface ToolRuntime extends ToolRegistry { */ export interface ToolServer extends ToolRuntime {} -export type ToolExecutionResult = - | Promise - | AsyncIterable; +export type ToolExecutionResult = Promise | AsyncIterable; export interface ToolDefinition { - name: string; - description: string; - inputSchema: PropSchema; - handler: ( - input: unknown, - ctx: ToolContext, - ) => Promise | AsyncIterable; + name: string; + description: string; + inputSchema: PropSchema; + handler: ( + input: unknown, + ctx: ToolContext, + ) => Promise | AsyncIterable; } // ── Model Adapter ─────────────────────────────────────────── export interface ModelAdapter { - capabilities?: { - structuredIntent?: boolean; - }; - stream(options: { - messages: ModelMessage[]; - tools: ToolSchema[]; - signal?: AbortSignal; - requestMode?: string; - operation?: ModelRequestedOperation; - }): AsyncIterable; + capabilities?: { + structuredIntent?: boolean; + }; + stream(options: { + messages: ModelMessage[]; + tools: ToolSchema[]; + signal?: AbortSignal; + requestMode?: string; + operation?: ModelRequestedOperation; + sessionId?: string; + turnId?: string; + generationId?: string; + }): AsyncIterable; } export type ModelOperationKind = - | "rewrite-selection" - | "rewrite-block" - | "continue-block" - | "document-transform"; + | "rewrite-selection" + | "rewrite-block" + | "continue-block" + | "document-transform"; export type ModelOperationApplyPolicy = - | "selection-replace" - | "block-replace" - | "block-continue" - | "document-review"; + | "selection-replace" + | "block-replace" + | "block-continue" + | "document-review"; export interface ModelOperationSelectionTarget { - kind: "selection"; - blockId: string | null; - anchor: { blockId: string; offset: number }; - focus: { blockId: string; offset: number }; - sourceText: string; + kind: "selection"; + blockId: string | null; + anchor: { blockId: string; offset: number }; + focus: { blockId: string; offset: number }; + sourceText: string; } export interface ModelOperationScopedRangeTarget { - kind: "scoped-range"; - blockId: string | null; - anchor: { blockId: string; offset: number }; - focus: { blockId: string; offset: number }; - sourceText: string; - blockIds: readonly string[]; - contentFormat: "text" | "markdown"; - scope: "block" | "paragraph" | "document" | "heading"; + kind: "scoped-range"; + blockId: string | null; + anchor: { blockId: string; offset: number }; + focus: { blockId: string; offset: number }; + sourceText: string; + blockIds: readonly string[]; + contentFormat: "text" | "markdown"; + scope: "block" | "paragraph" | "document" | "heading"; } export interface ModelOperationBlockTarget { - kind: "block"; - blockId: string; - blockType: string | null; - sourceText: string; - insertionOffset?: number; + kind: "block"; + blockId: string; + blockType: string | null; + sourceText: string; + insertionOffset?: number; } export interface ModelOperationDocumentTarget { - kind: "document"; - activeBlockId: string | null; - blockIds?: readonly string[]; - placement?: "append-after-block" | "replace-empty-block" | "replace-blocks"; - transform?: "write" | "rewrite" | "remove"; + kind: "document"; + activeBlockId: string | null; + blockIds?: readonly string[]; + placement?: "append-after-block" | "replace-empty-block" | "replace-blocks"; + transform?: "write" | "rewrite" | "remove"; } export interface ModelOperationProvenance { - documentVersion?: number | null; - blockRevision?: number | null; - selectionSignature?: string | null; - syncedGeneration?: number | null; + documentVersion?: number | null; + blockRevision?: number | null; + selectionSignature?: string | null; + syncedGeneration?: number | null; } export interface ModelRequestedOperation { - kind: ModelOperationKind; - applyPolicy: ModelOperationApplyPolicy; - target: - | ModelOperationSelectionTarget - | ModelOperationScopedRangeTarget - | ModelOperationBlockTarget - | ModelOperationDocumentTarget; - promptIntent?: string; - provenance?: ModelOperationProvenance | null; + kind: ModelOperationKind; + applyPolicy: ModelOperationApplyPolicy; + target: + | ModelOperationSelectionTarget + | ModelOperationScopedRangeTarget + | ModelOperationBlockTarget + | ModelOperationDocumentTarget; + promptIntent?: string; + provenance?: ModelOperationProvenance | null; } export type ModelStreamEvent = - | { type: "text-delta"; delta: string } - | { - type: "replace-preview"; - operation: ModelRequestedOperation; - text: string; - } - | { - type: "replace-final"; - operation: ModelRequestedOperation; - text: string; - } - | { - type: "insert-preview"; - operation: ModelRequestedOperation; - text: string; - } - | { - type: "insert-final"; - operation: ModelRequestedOperation; - text: string; - } - | { - type: "conflict"; - reason: string; - operation?: ModelRequestedOperation; - } - | { - type: "structured-data"; - contract?: "grid" | "app"; - data: unknown; - final?: boolean; - } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - } - | { - type: "done"; - usage?: { promptTokens: number; completionTokens: number }; - } - | { type: "error"; error: unknown }; + | { type: "text-delta"; delta: string } + | { + type: "replace-preview"; + operation: ModelRequestedOperation; + text: string; + } + | { + type: "replace-final"; + operation: ModelRequestedOperation; + text: string; + } + | { + type: "insert-preview"; + operation: ModelRequestedOperation; + text: string; + } + | { + type: "insert-final"; + operation: ModelRequestedOperation; + text: string; + } + | { + type: "conflict"; + reason: string; + operation?: ModelRequestedOperation; + } + | { + type: "structured-data"; + contract?: "grid" | "app"; + data: unknown; + final?: boolean; + } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + } + | { + type: "done"; + usage?: { promptTokens: number; completionTokens: number }; + } + | { type: "error"; error: unknown }; export interface ToolSchema { - name: string; - description: string; - inputSchema: PropSchema; + name: string; + description: string; + inputSchema: PropSchema; } // ── Model Messages ────────────────────────────────────────── export interface ModelMessage { - role: "system" | "user" | "assistant" | "tool"; - content: string | ModelMessagePart[]; - toolCallId?: string; - toolName?: string; + role: "system" | "user" | "assistant" | "tool"; + content: string | ModelMessagePart[]; + toolCallId?: string; + toolName?: string; } export type ModelMessagePart = - | { type: "text"; text: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - } - | { - type: "tool-result"; - toolCallId: string; - result: unknown; - isError?: boolean; - }; + | { type: "text"; text: string } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + } + | { + type: "tool-result"; + toolCallId: string; + result: unknown; + isError?: boolean; + }; // ── Tool Context ──────────────────────────────────────────── export interface ToolContext { - readonly editor: Editor; - readonly docId: string; - emit(part: PenStreamPart): void; - - insertBlock( - blockType: string, - props: Record, - position: Position, - ): string; - updateBlock(blockId: string, props: Record): void; - deleteBlock(blockId: string): void; - beginStreaming(zoneId: string, blockId: string): void; - appendDelta(delta: string): void; - endStreaming(status: "complete" | "cancelled" | "error"): void; -} - -export function isAsyncIterable(value: unknown): value is AsyncIterable { - return ( - value != null && - typeof value === "object" && - Symbol.asyncIterator in (value as object) - ); + readonly editor: Editor; + readonly docId: string; + emit(part: PenStreamPart): void; + + insertBlock( + blockType: string, + props: Record, + position: Position, + ): string; + updateBlock(blockId: string, props: Record): void; + deleteBlock(blockId: string): void; + beginStreaming(zoneId: string, blockId: string): void; + appendDelta(delta: string): void; + endStreaming(status: "complete" | "cancelled" | "error"): void; +} + +export function isAsyncIterable( + value: unknown, +): value is AsyncIterable { + return ( + value != null && + typeof value === "object" && + Symbol.asyncIterator in (value as object) + ); } export async function resolveToolExecution( - result: ToolExecutionResult, + result: ToolExecutionResult, ): Promise> { - return await result; + return await result; } export async function collectToolExecutionOutput( - result: ToolExecutionResult, - onPart?: (part: unknown, output: unknown) => void, + result: ToolExecutionResult, + onPart?: (part: unknown, output: unknown) => void, ): Promise { - const resolved = await resolveToolExecution(result); - if (!isAsyncIterable(resolved)) { - return resolved; - } - - const parts: unknown[] = []; - for await (const part of resolved) { - parts.push(part); - onPart?.(part, parts.length <= 1 ? parts[0] : [...parts]); - } - - return parts.length <= 1 ? parts[0] : parts; + const resolved = await resolveToolExecution(result); + if (!isAsyncIterable(resolved)) { + return resolved; + } + + const parts: unknown[] = []; + for await (const part of resolved) { + parts.push(part); + onPart?.(part, parts.length <= 1 ? parts[0] : [...parts]); + } + + return parts.length <= 1 ? parts[0] : parts; } From bbb7e013e1d1492cc3eb954cb4c0ec96e442cff5 Mon Sep 17 00:00:00 2001 From: krijn Date: Fri, 22 May 2026 22:03:29 +0200 Subject: [PATCH 18/20] Refactor inline completion and AI suggestion handling - Introduced `INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE` for better decoration management in inline completions. - Enhanced `buildDecorations` method to return block decorations for non-inline suggestions, improving visual feedback. - Updated AI suggestion application logic to utilize `interceptApplyForSuggestModeWithMetadata`, streamlining suggestion processing. - Refactored autocomplete controller to manage continuations more effectively, improving state handling during AI interactions. - Improved selection management in `ContentEditableBackend` and `EditContextBackend` to support new backend selection authority methods. --- packages/core/src/editor/inlineCompletion.ts | 42 +- .../src/__tests__/continuationState.test.ts | 74 ++ .../src/__tests__/extension.test.ts | 51 +- .../ai-autocomplete/src/continuationState.ts | 123 ++ .../ai-autocomplete/src/extension.ts | 132 +- .../suggestions/applySuggestedAIOperations.ts | 18 +- .../ai/src/suggestions/suggestMode.ts | 213 +++- .../backendLifecycleController.ts | 58 + .../src/field-editor/cellEditingController.ts | 124 ++ .../dom/src/field-editor/commands.ts | 18 +- .../field-editor/contenteditableBackend.ts | 412 ++++--- .../dom/src/field-editor/controller.ts | 23 + .../src/field-editor/editContextBackend.ts | 548 +++------ .../editContextSelectionAuthority.ts | 303 +++++ .../expandedContentEditableBackend.ts | 18 +- .../dom/src/field-editor/fieldEditorImpl.ts | 1086 +++++------------ .../dom/src/field-editor/focusController.ts | 289 +++++ .../dom/src/field-editor/inlineAtomDom.ts | 393 ++++-- .../dom/src/field-editor/inlineAtomModel.ts | 170 +++ .../src/field-editor/inlineTextTransaction.ts | 148 +++ .../dom/src/field-editor/keyHandling.ts | 120 +- .../src/field-editor/pendingMarkController.ts | 112 ++ .../dom/src/field-editor/reconciler.ts | 40 +- .../src/field-editor/selectAllController.ts | 69 ++ .../src/field-editor/selectionAuthority.ts | 108 ++ .../dom/src/field-editor/selectionBridge.ts | 370 +----- .../src/field-editor/selectionCoordinator.ts | 203 +++ .../src/field-editor/selectionDomQueries.ts | 60 + .../dom/src/field-editor/selectionGeometry.ts | 247 ++++ .../selectionProjectionController.ts | 467 +++++++ .../dom/src/field-editor/textDiff.ts | 51 + .../dom/src/utils/blockSelectionSemantics.ts | 19 +- .../rendering/dom/src/utils/dataAttributes.ts | 4 + .../dom/src/utils/documentShortcuts.ts | 54 +- .../src/__tests__/escapeKeyHandling.test.tsx | 47 +- .../src/__tests__/fieldEditorCommands.test.ts | 2 +- .../src/__tests__/fieldEditorExports.test.ts | 4 + .../inlineAtomDomOperations.test.tsx | 529 ++++++++ .../src/__tests__/inlineAtomEditing.test.tsx | 781 +++++------- .../react/src/__tests__/keyHandling.test.ts | 170 +++ .../__tests__/selectedTextDeletion.test.tsx | 14 +- .../editor/InlineAtomPortalLayer.tsx | 121 ++ .../editor/inlineAtomInteraction.ts | 117 ++ .../primitives/editor/inlineAtomTargets.ts | 199 +++ .../src/primitives/editor/inlineContent.tsx | 274 +---- .../react/src/utils/dataAttributes.ts | 2 + .../rendering/vue/src/internal/editorState.ts | 1 + 47 files changed, 5724 insertions(+), 2704 deletions(-) create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/continuationState.ts create mode 100644 packages/rendering/dom/src/field-editor/backendLifecycleController.ts create mode 100644 packages/rendering/dom/src/field-editor/cellEditingController.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts create mode 100644 packages/rendering/dom/src/field-editor/focusController.ts create mode 100644 packages/rendering/dom/src/field-editor/inlineAtomModel.ts create mode 100644 packages/rendering/dom/src/field-editor/inlineTextTransaction.ts create mode 100644 packages/rendering/dom/src/field-editor/pendingMarkController.ts create mode 100644 packages/rendering/dom/src/field-editor/selectAllController.ts create mode 100644 packages/rendering/dom/src/field-editor/selectionAuthority.ts create mode 100644 packages/rendering/dom/src/field-editor/selectionCoordinator.ts create mode 100644 packages/rendering/dom/src/field-editor/selectionDomQueries.ts create mode 100644 packages/rendering/dom/src/field-editor/selectionGeometry.ts create mode 100644 packages/rendering/dom/src/field-editor/selectionProjectionController.ts create mode 100644 packages/rendering/dom/src/field-editor/textDiff.ts create mode 100644 packages/rendering/react/src/__tests__/inlineAtomDomOperations.test.tsx create mode 100644 packages/rendering/react/src/primitives/editor/InlineAtomPortalLayer.tsx create mode 100644 packages/rendering/react/src/primitives/editor/inlineAtomTargets.ts diff --git a/packages/core/src/editor/inlineCompletion.ts b/packages/core/src/editor/inlineCompletion.ts index bf6402f..90e0ec8 100644 --- a/packages/core/src/editor/inlineCompletion.ts +++ b/packages/core/src/editor/inlineCompletion.ts @@ -1,4 +1,5 @@ import { + INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE, INLINE_COMPLETION_SLOT, type Decoration, type Editor, @@ -108,26 +109,39 @@ class InlineCompletionControllerImpl implements InlineCompletionController { buildDecorations(): readonly Decoration[] { const suggestion = this._state.visibleSuggestion; - if (!suggestion || suggestion.type !== "inline") { + if (!suggestion) { return []; } + const blockDecoration: Decoration = { + type: "block", + blockId: suggestion.blockId, + attributes: { + [INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE]: true, + }, + }; + if (suggestion.type !== "inline") { + return [blockDecoration]; + } const anchor = resolveInlineSuggestionAnchor(this._editor, suggestion); if (!anchor) { - return []; + return [blockDecoration]; } - return [{ - type: "inline", - blockId: suggestion.blockId, - from: anchor.from, - to: anchor.to, - attributes: { - class: "pen-ephemeral-suggestion", - "data-suggestion-id": suggestion.id, - "data-suggestion-text": suggestion.text, - "data-suggestion-type": suggestion.type, - "data-suggestion-placement": anchor.placement, + return [ + blockDecoration, + { + type: "inline", + blockId: suggestion.blockId, + from: anchor.from, + to: anchor.to, + attributes: { + class: "pen-ephemeral-suggestion", + "data-suggestion-id": suggestion.id, + "data-suggestion-text": suggestion.text, + "data-suggestion-type": suggestion.type, + "data-suggestion-placement": anchor.placement, + }, }, - }]; + ]; } destroy(): void { diff --git a/packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts new file mode 100644 index 0000000..1a7424c --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { SelectionState } from "@pen/types"; +import { AutocompleteContinuationState } from "../continuationState"; +import type { AutocompleteStructuredCandidate } from "../structuredCandidate"; + +const candidate: AutocompleteStructuredCandidate = { + rawText: " world", + inlineText: " world", + appendedBlocks: [], + previewBlocks: [], +}; + +function textSelection(blockId: string, offset: number): SelectionState { + return { + type: "text", + anchor: { blockId, offset }, + focus: { blockId, offset }, + isCollapsed: true, + isMultiBlock: false, + blockRange: [blockId], + toRange: () => { + throw new Error("not needed for continuation state tests"); + }, + }; +} + +describe("AutocompleteContinuationState", () => { + it("activates a prefetched continuation only for the accepted caret", () => { + const state = new AutocompleteContinuationState(); + state.setPendingAcceptedContinuation({ + sourceRequestId: "request-1", + blockId: "block-1", + startOffset: 6, + continuationDepth: 1, + }); + state.setPrefetchedContinuation({ + sourceRequestId: "request-1", + requestId: "request-2", + blockId: "block-1", + startOffset: 6, + candidate, + continuationDepth: 1, + }); + + expect( + state.activatePendingAcceptedContinuation( + textSelection("block-1", 5), + ), + ).toBeNull(); + + const activated = state.activatePendingAcceptedContinuation( + textSelection("block-1", 6), + ); + + expect(activated).toMatchObject({ + requestId: "request-2", + blockId: "block-1", + startOffset: 6, + continuationDepth: 1, + }); + expect(state.sequence).toBe(activated); + }); + + it("consumes only the AI commit caused by accepting a sequence segment", () => { + const state = new AutocompleteContinuationState(); + + expect(state.consumeAcceptedAiCommit("ai")).toBe(false); + + state.beginAcceptingSequenceSegment(); + expect(state.consumeAcceptedAiCommit("user")).toBe(false); + expect(state.consumeAcceptedAiCommit("ai")).toBe(true); + expect(state.consumeAcceptedAiCommit("ai")).toBe(false); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts index ee1cc71..ab33f7e 100644 --- a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts @@ -517,16 +517,19 @@ describe("@pen/ai-autocomplete", () => { () => inlineCompletion?.getState().visibleSuggestion?.text === " world!", ); - expect(inlineCompletion?.buildDecorations()).toEqual([ - expect.objectContaining({ - blockId, - from: 4, - to: 5, - attributes: expect.objectContaining({ - "data-suggestion-placement": "after", + expect(inlineCompletion?.buildDecorations()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "inline", + blockId, + from: 4, + to: 5, + attributes: expect.objectContaining({ + "data-suggestion-placement": "after", + }), }), - }), - ]); + ]), + ); editor.destroy(); }); @@ -1387,17 +1390,20 @@ describe("@pen/ai-autocomplete", () => { deniedBlockTypes?: readonly string[]; }; }; - _sequence: { - requestId: string; - blockId: string; - startOffset: number; - candidate: { - inlineText: string; - appendedBlocks: readonly unknown[]; - previewBlocks: readonly unknown[]; - }; - continuationDepth: number; - } | null; + _continuation: { + setSequence(sequence: { + requestId: string; + blockId: string; + startOffset: number; + candidate: { + rawText: string; + inlineText: string; + appendedBlocks: readonly []; + previewBlocks: readonly []; + }; + continuationDepth: number; + }): void; + }; _setState: (nextState: { status: "showing"; activeRequestId: string; @@ -1408,17 +1414,18 @@ describe("@pen/ai-autocomplete", () => { ...controller!.getBlockPolicy(), allowInCodeBlocks: false, }; - controllerImpl._sequence = { + controllerImpl._continuation.setSequence({ requestId: "manual-policy-recheck", blockId: codeBlockId, startOffset: 14, candidate: { + rawText: " value", inlineText: " value", appendedBlocks: [], previewBlocks: [], }, continuationDepth: 0, - }; + }); controllerImpl._setState({ status: "showing", activeRequestId: "manual-policy-recheck", diff --git a/packages/extensions/ai-autocomplete/src/continuationState.ts b/packages/extensions/ai-autocomplete/src/continuationState.ts new file mode 100644 index 0000000..b45c268 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/continuationState.ts @@ -0,0 +1,123 @@ +import type { SelectionState } from "@pen/types"; +import type { AutocompleteStructuredCandidate } from "./structuredCandidate"; + +export type AutocompleteSequence = { + requestId: string; + blockId: string; + startOffset: number; + candidate: AutocompleteStructuredCandidate; + continuationDepth: number; +}; + +export type AcceptedContinuationTarget = { + sourceRequestId: string; + blockId: string; + startOffset: number; + continuationDepth: number; +}; + +export type PrefetchedContinuation = AcceptedContinuationTarget & { + requestId: string; + candidate: AutocompleteStructuredCandidate; +}; + +export class AutocompleteContinuationState { + private _sequence: AutocompleteSequence | null = null; + private _isAcceptingSequenceSegment = false; + private _prefetchedContinuation: PrefetchedContinuation | null = null; + private _pendingAcceptedContinuation: AcceptedContinuationTarget | null = + null; + + get sequence(): AutocompleteSequence | null { + return this._sequence; + } + + get hasPrefetchedContinuation(): boolean { + return this._prefetchedContinuation !== null; + } + + get hasPendingOrPrefetchedContinuation(): boolean { + return ( + this._pendingAcceptedContinuation !== null || + this._prefetchedContinuation !== null + ); + } + + setSequence(sequence: AutocompleteSequence): void { + this._sequence = sequence; + } + + clearSequence(): void { + this._sequence = null; + this._isAcceptingSequenceSegment = false; + } + + clearContinuations(): void { + this._prefetchedContinuation = null; + this._pendingAcceptedContinuation = null; + } + + reset(): void { + this.clearSequence(); + this.clearContinuations(); + } + + beginAcceptingSequenceSegment(): void { + this._isAcceptingSequenceSegment = true; + } + + consumeAcceptedAiCommit(origin: unknown): boolean { + if (!this._isAcceptingSequenceSegment || origin !== "ai") { + return false; + } + this._isAcceptingSequenceSegment = false; + return true; + } + + setPendingAcceptedContinuation( + target: AcceptedContinuationTarget, + ): void { + this._pendingAcceptedContinuation = target; + } + + setPrefetchedContinuation(prefetched: PrefetchedContinuation): void { + this._prefetchedContinuation = prefetched; + } + + activatePendingAcceptedContinuation( + selection: SelectionState, + ): AutocompleteSequence | null { + const prefetched = this._prefetchedContinuation; + const pending = this._pendingAcceptedContinuation; + if (!prefetched || !pending) { + return null; + } + if ( + prefetched.sourceRequestId !== pending.sourceRequestId || + prefetched.blockId !== pending.blockId || + prefetched.startOffset !== pending.startOffset + ) { + return null; + } + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock || + selection.focus.blockId !== pending.blockId || + selection.focus.offset !== pending.startOffset + ) { + return null; + } + + this._pendingAcceptedContinuation = null; + this._prefetchedContinuation = null; + this._sequence = { + requestId: prefetched.requestId, + blockId: prefetched.blockId, + startOffset: prefetched.startOffset, + candidate: prefetched.candidate, + continuationDepth: prefetched.continuationDepth, + }; + return this._sequence; + } +} diff --git a/packages/extensions/ai-autocomplete/src/extension.ts b/packages/extensions/ai-autocomplete/src/extension.ts index 7b35214..060b51b 100644 --- a/packages/extensions/ai-autocomplete/src/extension.ts +++ b/packages/extensions/ai-autocomplete/src/extension.ts @@ -50,8 +50,8 @@ import type { import { createAutocompleteStructuredCandidate, materializeStructuredCandidateAcceptance, - type AutocompleteStructuredCandidate, } from "./structuredCandidate"; +import { AutocompleteContinuationState } from "./continuationState"; export const AI_AUTOCOMPLETE_EXTENSION_NAME = "ai-autocomplete"; export const AUTOCOMPLETE_CONTROLLER_SLOT = AI_AUTOCOMPLETE_CONTROLLER_SLOT; @@ -130,29 +130,8 @@ class AutocompleteControllerImpl implements AutocompleteController { private _abortController: AbortController | null = null; private _unsubscribeSelection: (() => void) | null = null; private _unsubscribeCommit: (() => void) | null = null; - private _sequence: { - requestId: string; - blockId: string; - startOffset: number; - candidate: AutocompleteStructuredCandidate; - continuationDepth: number; - } | null = null; - private _isAcceptingSequenceSegment = false; + private readonly _continuation = new AutocompleteContinuationState(); private _prefetchAbortController: AbortController | null = null; - private _prefetchedContinuation: { - sourceRequestId: string; - requestId: string; - blockId: string; - startOffset: number; - candidate: AutocompleteStructuredCandidate; - continuationDepth: number; - } | null = null; - private _pendingAcceptedContinuation: { - sourceRequestId: string; - blockId: string; - startOffset: number; - continuationDepth: number; - } | null = null; constructor( editor: Editor, @@ -204,8 +183,7 @@ class AutocompleteControllerImpl implements AutocompleteController { if (!this._state.enabled) { return; } - if (this._isAcceptingSequenceSegment && event.origin === "ai") { - this._isAcceptingSequenceSegment = false; + if (this._continuation.consumeAcceptedAiCommit(event.origin)) { return; } if (event.origin !== "user" && event.origin !== "input-rule") { @@ -230,7 +208,7 @@ class AutocompleteControllerImpl implements AutocompleteController { this._abortController = null; this._prefetchAbortController?.abort(); this._prefetchAbortController = null; - this._pendingAcceptedContinuation = null; + this._continuation.clearContinuations(); } getSnapshot(): AutocompleteControllerSnapshot { @@ -320,11 +298,12 @@ class AutocompleteControllerImpl implements AutocompleteController { } acceptVisibleSuggestion(): boolean { - if (!this._sequence || !this.hasVisibleSuggestion()) { + const sequence = this._continuation.sequence; + if (!sequence || !this.hasVisibleSuggestion()) { return false; } const policyFailure = this._resolveCurrentBlockFailure( - this._sequence.blockId, + sequence.blockId, ); if (policyFailure) { this._recordPolicyInvalidation(policyFailure, "showing"); @@ -338,10 +317,11 @@ class AutocompleteControllerImpl implements AutocompleteController { private _acceptFullVisibleSuggestion(options?: { activateContinuation?: boolean; }): boolean { - if (!this._sequence) { + const sequence = this._continuation.sequence; + if (!sequence) { return false; } - const candidate = this._sequence.candidate; + const candidate = sequence.candidate; if ( candidate.inlineText.length === 0 && candidate.previewBlocks.length === 0 @@ -349,18 +329,18 @@ class AutocompleteControllerImpl implements AutocompleteController { this.dismiss(); return false; } - const blockId = this._sequence.blockId; - const requestId = this._sequence.requestId; - const continuationDepth = this._sequence.continuationDepth + 1; + const blockId = sequence.blockId; + const requestId = sequence.requestId; + const continuationDepth = sequence.continuationDepth + 1; const acceptanceResult = materializeStructuredCandidateAcceptance({ blockId, - offset: this._sequence.startOffset, + offset: sequence.startOffset, candidate, }); logAutocompleteEvent("accept visible suggestion", { requestId, blockId, - startOffset: this._sequence.startOffset, + startOffset: sequence.startOffset, inlineLength: candidate.inlineText.length, inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), appendedBlockCount: candidate.appendedBlocks.length, @@ -371,7 +351,7 @@ class AutocompleteControllerImpl implements AutocompleteController { nextCaretBlockId: acceptanceResult.selection.blockId, nextCaretOffset: acceptanceResult.selection.offset, }); - this._isAcceptingSequenceSegment = true; + this._continuation.beginAcceptingSequenceSegment(); this._editor.apply(acceptanceResult.ops, { origin: "ai", undoGroup: true, @@ -431,12 +411,12 @@ class AutocompleteControllerImpl implements AutocompleteController { } if (options?.activateContinuation && this._prefetchAfterAccept) { - this._pendingAcceptedContinuation = { + this._continuation.setPendingAcceptedContinuation({ sourceRequestId: requestId, blockId: nextCaretBlockId, startOffset: nextCaretOffset, continuationDepth, - }; + }); this._clearVisibleSuggestionAfterAccept(); this._startPrefetchForAcceptedContinuation({ sourceRequestId: requestId, @@ -452,7 +432,8 @@ class AutocompleteControllerImpl implements AutocompleteController { hasVisibleSuggestion(): boolean { return ( - this._sequence !== null && this._state.visibleSuggestionId !== null + this._continuation.sequence !== null && + this._state.visibleSuggestionId !== null ); } @@ -497,8 +478,7 @@ class AutocompleteControllerImpl implements AutocompleteController { if (!nextPrefetchAfterAccept) { this._prefetchAbortController?.abort(); this._prefetchAbortController = null; - this._prefetchedContinuation = null; - this._pendingAcceptedContinuation = null; + this._continuation.clearContinuations(); } changed = true; } @@ -560,8 +540,7 @@ class AutocompleteControllerImpl implements AutocompleteController { this._prefetchAbortController?.abort(); this._prefetchAbortController = null; this._clearSequence(); - this._prefetchedContinuation = null; - this._pendingAcceptedContinuation = null; + this._continuation.clearContinuations(); this._setState({ status: "idle", activeRequestId: null, @@ -739,13 +718,13 @@ class AutocompleteControllerImpl implements AutocompleteController { continuationDepth: 0, }, ); - this._sequence = { + this._continuation.setSequence({ requestId, blockId: context.blockId, startOffset: context.offset, candidate, continuationDepth: 0, - }; + }); this._setState({ metrics: { ...this._state.metrics, @@ -958,22 +937,23 @@ class AutocompleteControllerImpl implements AutocompleteController { } private _showSequenceSuggestion(): void { - if (!this._sequence) { + const sequence = this._continuation.sequence; + if (!sequence) { return; } - const suggestionId = this._sequence.requestId; - const preview = this._sequence.candidate; + const suggestionId = sequence.requestId; + const preview = sequence.candidate; this._inlineCompletion.showSuggestion({ id: suggestionId, - blockId: this._sequence.blockId, - offset: this._sequence.startOffset, + blockId: sequence.blockId, + offset: sequence.startOffset, text: preview.inlineText, type: "inline", previewBlocks: preview.previewBlocks, }); this._setState({ status: "showing", - activeRequestId: this._sequence.requestId, + activeRequestId: sequence.requestId, visibleSuggestionId: suggestionId, }); } @@ -1090,56 +1070,31 @@ class AutocompleteControllerImpl implements AutocompleteController { ), previewBlockCount: candidate.previewBlocks.length, }); - this._prefetchedContinuation = { + this._continuation.setPrefetchedContinuation({ sourceRequestId, requestId, blockId: context.blockId, startOffset: context.offset, candidate, continuationDepth, - }; + }); this._activatePendingAcceptedContinuation(); } private _activatePendingAcceptedContinuation(): boolean { - const prefetched = this._prefetchedContinuation; - const pending = this._pendingAcceptedContinuation; - if (!prefetched || !pending) { - return false; - } - if ( - prefetched.sourceRequestId !== pending.sourceRequestId || - prefetched.blockId !== pending.blockId || - prefetched.startOffset !== pending.startOffset - ) { - return false; - } - const selection = this._editor.selection; if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock || - selection.focus.blockId !== pending.blockId || - selection.focus.offset !== pending.startOffset + !this._continuation.activatePendingAcceptedContinuation( + this._editor.selection, + ) ) { return false; } - this._pendingAcceptedContinuation = null; - this._sequence = { - requestId: prefetched.requestId, - blockId: prefetched.blockId, - startOffset: prefetched.startOffset, - candidate: prefetched.candidate, - continuationDepth: prefetched.continuationDepth, - }; - this._prefetchedContinuation = null; this._showSequenceSuggestion(); return true; } private _clearSequence(): void { - this._sequence = null; - this._isAcceptingSequenceSegment = false; + this._continuation.clearSequence(); } private _clearVisibleSuggestionAfterAccept(): void { @@ -1182,14 +1137,15 @@ class AutocompleteControllerImpl implements AutocompleteController { }, }); } - if (invalidationStage || this._prefetchedContinuation) { + if (invalidationStage || this._continuation.hasPrefetchedContinuation) { this.dismiss("policy-change"); } } private _invalidateForPolicyChange(): void { const activeBlockId = - this._sequence?.blockId ?? this._getActiveSelectionBlockId(); + this._continuation.sequence?.blockId ?? + this._getActiveSelectionBlockId(); if (!activeBlockId) { return; } @@ -1215,8 +1171,8 @@ class AutocompleteControllerImpl implements AutocompleteController { } if ( this._state.status === "showing" || - this._sequence || - this._prefetchedContinuation + this._continuation.sequence || + this._continuation.hasPrefetchedContinuation ) { return "showing"; } @@ -1480,6 +1436,10 @@ function maybeInsertMissingBoundarySpace( if (!hasLikelyWordBoundary(completion)) { return completion; } + const leadingWord = completion.match(/^[A-Za-z0-9_'-]+/)?.[0] ?? ""; + if (leadingWord.length > 0 && leadingWord.length <= 2) { + return completion; + } return ` ${completion}`; } diff --git a/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts b/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts index d5a66aa..0c74afc 100644 --- a/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts +++ b/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts @@ -1,9 +1,8 @@ import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; import type { PersistentSuggestion } from "../types"; -import { readAllSuggestions } from "./persistent"; import { AI_SESSION_SUGGESTION_ORIGIN, - interceptApplyForSuggestMode, + interceptApplyForSuggestModeWithMetadata, } from "./suggestMode"; export type ApplySuggestedAIOperationsOptions = { @@ -34,10 +33,7 @@ export function applySuggestedAIOperations( return { suggestionIds: [], suggestions: [] }; } - const beforeSuggestionIds = new Set( - readAllSuggestions(editor).map((suggestion) => suggestion.id), - ); - const intercepted = interceptApplyForSuggestMode( + const intercepted = interceptApplyForSuggestModeWithMetadata( [...options.operations], editor, options.author ?? "assistant", @@ -53,19 +49,15 @@ export function applySuggestedAIOperations( }, ); - editor.apply(intercepted, { + editor.apply(intercepted.operations, { origin: options.origin ?? AI_SESSION_SUGGESTION_ORIGIN, ...(options.undoGroupId ? { undoGroupId: options.undoGroupId } : { undoGroup: true }), }); - const suggestions = readAllSuggestions(editor).filter( - (suggestion) => !beforeSuggestionIds.has(suggestion.id), - ); - return { - suggestionIds: suggestions.map((suggestion) => suggestion.id), - suggestions, + suggestionIds: intercepted.suggestionIds, + suggestions: intercepted.suggestions, }; } diff --git a/packages/extensions/ai/src/suggestions/suggestMode.ts b/packages/extensions/ai/src/suggestions/suggestMode.ts index 016e9c5..9e190a7 100644 --- a/packages/extensions/ai/src/suggestions/suggestMode.ts +++ b/packages/extensions/ai/src/suggestions/suggestMode.ts @@ -4,7 +4,7 @@ import { createSuggestionMark, type SuggestionCreationOptions, } from "./persistent"; -import type { BlockSuggestionMeta } from "../types"; +import type { BlockSuggestionMeta, PersistentSuggestion } from "../types"; export const SUGGESTION_RESOLUTION_ORIGIN = "suggestion-resolution"; export const AI_SESSION_SUGGESTION_ORIGIN = "ai-session"; @@ -32,20 +32,106 @@ export function interceptApplyForSuggestMode( sessionId?: string, options: SuggestModeSuggestionOptions = {}, ): DocumentOp[] { + return interceptApplyForSuggestModeWithMetadata( + ops, + editor, + author, + authorType, + model, + sessionId, + options, + ).operations; +} + +export type InterceptApplyForSuggestModeResult = { + operations: DocumentOp[]; + suggestionIds: string[]; + suggestions: PersistentSuggestion[]; +}; + +export function interceptApplyForSuggestModeWithMetadata( + ops: DocumentOp[], + editor: Editor, + author: string, + authorType: "user" | "ai", + model?: string, + sessionId?: string, + options: SuggestModeSuggestionOptions = {}, +): InterceptApplyForSuggestModeResult { const intercepted: DocumentOp[] = []; + const suggestions: PersistentSuggestion[] = []; let suggestionIdIndex = 0; - const nextSuggestionOptions = (): SuggestionCreationOptions => ({ - requestId: options.requestId, - sessionId, - turnId: options.turnId, - generationId: options.generationId, - createdAt: options.createdAt, - suggestionId: options.suggestionIds?.[suggestionIdIndex++], - }); + const nextSuggestionOptions = (): RequiredSuggestionCreationOptions => { + const suggestionId = + options.suggestionIds?.[suggestionIdIndex] ?? crypto.randomUUID(); + suggestionIdIndex += 1; + return { + requestId: options.requestId, + sessionId, + turnId: options.turnId, + generationId: options.generationId, + createdAt: options.createdAt ?? Date.now(), + suggestionId, + }; + }; + const pushTextSuggestion = ( + action: "insert" | "delete", + blockId: string, + offset: number, + length: number, + suggestionOptions: RequiredSuggestionCreationOptions, + ) => { + suggestions.push({ + kind: "text", + id: suggestionOptions.suggestionId, + action, + author, + authorType, + createdAt: suggestionOptions.createdAt, + model, + sessionId: suggestionOptions.sessionId, + requestId: suggestionOptions.requestId, + turnId: suggestionOptions.turnId, + generationId: suggestionOptions.generationId, + blockId, + offset, + length, + }); + }; + const pushBlockSuggestion = ( + action: BlockSuggestionMeta["action"], + blockId: string, + previousState: BlockSuggestionMeta["previousState"], + suggestionOptions: RequiredSuggestionCreationOptions, + ) => { + suggestions.push({ + kind: "block", + id: suggestionOptions.suggestionId, + action, + author, + authorType, + createdAt: suggestionOptions.createdAt, + model, + sessionId: suggestionOptions.sessionId, + requestId: suggestionOptions.requestId, + turnId: suggestionOptions.turnId, + generationId: suggestionOptions.generationId, + blockId, + previousState, + }); + }; for (const op of ops) { switch (op.type) { case "insert-text": { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "insert", + op.blockId, + op.offset, + op.text.length, + suggestionOptions, + ); intercepted.push({ ...op, marks: { @@ -56,7 +142,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }, }); @@ -65,6 +151,14 @@ export function interceptApplyForSuggestMode( case "replace-text": { if (op.length > 0) { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "delete", + op.blockId, + op.offset, + op.length, + suggestionOptions, + ); intercepted.push({ type: "format-text", blockId: op.blockId, @@ -76,11 +170,19 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }); } if (op.text.length > 0) { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "insert", + op.blockId, + op.offset + op.length, + op.text.length, + suggestionOptions, + ); intercepted.push({ type: "insert-text", blockId: op.blockId, @@ -94,7 +196,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }, }); @@ -103,6 +205,14 @@ export function interceptApplyForSuggestMode( } case "delete-text": { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "delete", + op.blockId, + op.offset, + op.length, + suggestionOptions, + ); intercepted.push({ type: "format-text", blockId: op.blockId, @@ -114,13 +224,20 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }); break; } case "insert-block": { + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "insert-block", + op.blockId, + undefined, + suggestionOptions, + ); intercepted.push(op); intercepted.push({ type: "set-meta", @@ -133,13 +250,20 @@ export function interceptApplyForSuggestMode( model, undefined, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }); break; } case "delete-block": { + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "delete-block", + op.blockId, + undefined, + suggestionOptions, + ); intercepted.push({ type: "set-meta", blockId: op.blockId, @@ -151,7 +275,7 @@ export function interceptApplyForSuggestMode( model, undefined, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }); break; @@ -160,6 +284,23 @@ export function interceptApplyForSuggestMode( case "move-block": { const block = editor.getBlock(op.blockId); const layoutParent = block?.layoutParent(); + const previousState: BlockSuggestionMeta["previousState"] = { + position: layoutParent + ? { + parent: layoutParent.id, + index: block?.index ?? 0, + } + : block?.prev + ? { after: block.prev.id } + : "first", + }; + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "move-block", + op.blockId, + previousState, + suggestionOptions, + ); intercepted.push(op); intercepted.push({ type: "set-meta", @@ -170,18 +311,9 @@ export function interceptApplyForSuggestMode( author, authorType, model, - { - position: layoutParent - ? { - parent: layoutParent.id, - index: block?.index ?? 0, - } - : block?.prev - ? { after: block.prev.id } - : "first", - }, + previousState, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }); break; @@ -189,6 +321,17 @@ export function interceptApplyForSuggestMode( case "convert-block": { const block = editor.getBlock(op.blockId); + const previousState: BlockSuggestionMeta["previousState"] = { + type: block?.type, + props: block ? { ...block.props } : undefined, + }; + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "convert-block", + op.blockId, + previousState, + suggestionOptions, + ); intercepted.push(op); intercepted.push({ type: "set-meta", @@ -199,12 +342,9 @@ export function interceptApplyForSuggestMode( author, authorType, model, - { - type: block?.type, - props: block ? { ...block.props } : undefined, - }, + previousState, sessionId, - nextSuggestionOptions(), + suggestionOptions, ), }); break; @@ -215,7 +355,11 @@ export function interceptApplyForSuggestMode( } } - return intercepted; + return { + operations: intercepted, + suggestionIds: suggestions.map((suggestion) => suggestion.id), + suggestions, + }; } export type SuggestModeSuggestionOptions = { @@ -226,6 +370,11 @@ export type SuggestModeSuggestionOptions = { suggestionIds?: readonly string[]; }; +type RequiredSuggestionCreationOptions = SuggestionCreationOptions & { + suggestionId: string; + createdAt: number; +}; + function createBlockSuggestionMeta( action: BlockSuggestionMeta["action"], author: string, diff --git a/packages/rendering/dom/src/field-editor/backendLifecycleController.ts b/packages/rendering/dom/src/field-editor/backendLifecycleController.ts new file mode 100644 index 0000000..2bbaea3 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/backendLifecycleController.ts @@ -0,0 +1,58 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import type { InputBackend } from "../internal/inputBackend"; + +export type InputBackendConstructor = new ( + editor: Editor, + fieldEditor: FieldEditorInputController, +) => InputBackend; + +export class BackendLifecycleController { + private readonly editor: Editor; + private readonly fieldEditor: FieldEditorInputController; + private backend: InputBackend | null = null; + + constructor(editor: Editor, fieldEditor: FieldEditorInputController) { + this.editor = editor; + this.fieldEditor = fieldEditor; + } + + get current(): InputBackend | null { + return this.backend; + } + + hasBackend(BackendClass: InputBackendConstructor): boolean { + return this.backend?.constructor === BackendClass; + } + + create(BackendClass: InputBackendConstructor): InputBackend { + return new BackendClass(this.editor, this.fieldEditor); + } + + replace(BackendClass: InputBackendConstructor): InputBackend { + this.deactivate(); + this.backend = this.create(BackendClass); + return this.backend; + } + + ensure(BackendClass: InputBackendConstructor): InputBackend { + if (this.backend?.constructor === BackendClass) { + return this.backend; + } + return this.replace(BackendClass); + } + + activate(element: HTMLElement, ytext: FieldEditorTextLike): void { + this.backend?.activate(element, ytext); + } + + updateSelection(relPos: unknown): void { + this.backend?.updateSelection(relPos); + } + + deactivate(): void { + this.backend?.deactivate(); + this.backend = null; + } +} diff --git a/packages/rendering/dom/src/field-editor/cellEditingController.ts b/packages/rendering/dom/src/field-editor/cellEditingController.ts new file mode 100644 index 0000000..f9d1c7e --- /dev/null +++ b/packages/rendering/dom/src/field-editor/cellEditingController.ts @@ -0,0 +1,124 @@ +import type { + ActiveCellCoord, + FieldEditorFocusReason, + PenFieldEditorFocusOptions, +} from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import { resolveCellInlineElement } from "./contentResolution"; + +type CellEditingControllerOptions = { + getRootElement: () => HTMLElement | null; + getYTextForCell: ( + blockId: string, + row: number, + col: number, + ) => FieldEditorTextLike | null; + attachElement: (element: HTMLElement) => boolean; + requestDomFocus: ( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ) => boolean; +}; + +export class CellEditingController { + private readonly options: CellEditingControllerOptions; + private coord: ActiveCellCoord | null = null; + + constructor(options: CellEditingControllerOptions) { + this.options = options; + } + + get activeCellCoord(): ActiveCellCoord | null { + return this.coord; + } + + setActiveCell(blockId: string, row: number, col: number): void { + this.coord = { blockId, row, col }; + } + + clear(): void { + this.coord = null; + } + + trySyncBackend(attempt = 0): void { + const coord = this.coord; + if (!coord) return; + + const ytext = this.options.getYTextForCell( + coord.blockId, + coord.row, + coord.col, + ); + if (!ytext) return; + + const cellEl = this.resolveCellElement( + coord.blockId, + coord.row, + coord.col, + ); + if (cellEl) { + this.options.attachElement(cellEl); + this.placeCaretInCell(cellEl); + return; + } + + if (attempt < 3) { + requestAnimationFrame(() => this.trySyncBackend(attempt + 1)); + } + } + + placeCaretInCell(cellEl: HTMLElement): void { + if ( + !this.options.requestDomFocus(cellEl, "cell", { + preventScroll: true, + }) + ) { + return; + } + const selection = cellEl.ownerDocument?.getSelection(); + if (!selection) return; + + const range = cellEl.ownerDocument.createRange(); + range.selectNodeContents(cellEl); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + + resolveInlineElement(blockId: string): HTMLElement | null { + const coord = this.coord; + if (coord?.blockId !== blockId) { + return null; + } + return this.resolveCellElement(coord.blockId, coord.row, coord.col); + } + + resolveActiveCellElement( + rootElement?: HTMLElement | null, + ): HTMLElement | null { + const coord = this.coord; + if (!coord) return null; + return this.resolveCellElement( + coord.blockId, + coord.row, + coord.col, + rootElement, + ); + } + + resolveCellElement( + blockId: string, + row: number, + col: number, + root?: HTMLElement | null, + ): HTMLElement | null { + return resolveCellInlineElement( + blockId, + row, + col, + root ?? this.options.getRootElement(), + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/commands.ts b/packages/rendering/dom/src/field-editor/commands.ts index f59f57e..2a60d24 100644 --- a/packages/rendering/dom/src/field-editor/commands.ts +++ b/packages/rendering/dom/src/field-editor/commands.ts @@ -31,6 +31,7 @@ export interface SelectionTarget { type InlineTextLike = { length: number; toString(): string; + toDelta?(): Array<{ insert?: string | Record }>; }; type BlockInputRuleEngine = { @@ -103,6 +104,21 @@ function getAdjacentEditableBlock( } export function getLogicalInlineLength(ytext: InlineTextLike): number { + const delta = ytext.toDelta?.(); + if (delta) { + return delta.reduce((length, entry) => { + if (typeof entry.insert === "string") { + return ( + length + + (entry.insert === ZERO_WIDTH_SPACE + ? 0 + : entry.insert.length) + ); + } + return entry.insert ? length + 1 : length; + }, 0); + } + const text = ytext.toString(); if (!text || text === ZERO_WIDTH_SPACE) { return 0; @@ -283,7 +299,7 @@ export function applyListTabBehavior( const sharesParent = previousBlockId !== null && editor.documentState.parentOf(previousBlockId) === - editor.documentState.parentOf(blockId); + editor.documentState.parentOf(blockId); if ( isListBlock(previousBlock) && diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index 5320694..a2a18e7 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -1,8 +1,4 @@ -import type { - DocumentOp, - Editor, - InlineDecoration, -} from "@pen/types"; +import type { Editor, InlineDecoration } from "@pen/types"; import type { FieldEditorInputController } from "./controller"; import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; import { @@ -24,6 +20,11 @@ import { } from "./commands"; import { handleFieldEditorKeyDown } from "./keyHandling"; import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { + buildInlineTextDiffOps, + buildInlineTextEditTransaction, + type InlineTextDiffOp, +} from "./inlineTextTransaction"; import type { FieldEditorDelta, FieldEditorObserver, @@ -36,19 +37,11 @@ export class ContentEditableBackend { private ytext: FieldEditorTextLike | null = null; private observer: FieldEditorObserver | null = null; private mutationObserver: MutationObserver | null = null; - private isApplyingSelection = 0; private isComposing = false; private compositionStartTimestamp = 0; private compositionStartText: string | null = null; private deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }> = []; private pendingDomSyncFrame: number | null = null; - private pendingSelectionOverride: { - blockId: string; - anchorOffset: number; - focusOffset: number; - cell?: { row: number; col: number }; - } | null = null; - private activeCellSelection: { start: number; end: number } | null = null; private editor: Editor; private fieldEditor: FieldEditorInputController; @@ -62,10 +55,10 @@ export class ContentEditableBackend { this.ytext = ytext as FieldEditorTextLike; element.contentEditable = "true"; - this.isApplyingSelection++; + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); this.isComposing = false; this.compositionStartText = null; - this.activeCellSelection = null; this.fieldEditor.setComposing(false); element.addEventListener("beforeinput", this.handleBeforeInput); @@ -79,6 +72,7 @@ export class ContentEditableBackend { element.addEventListener("cut", this.handleCutEvent); element.addEventListener("dragstart", this.handleDragStart); element.addEventListener("drop", this.handleDrop); + element.addEventListener("pointerdown", this.handlePointerDown); element.ownerDocument?.addEventListener( "selectionchange", this.handleSelectionChange, @@ -102,9 +96,6 @@ export class ContentEditableBackend { this.fieldEditor.focusBlockId ?? undefined, ); this.restoreDOMSelectionFromEditor(); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); } deactivate(): void { @@ -127,6 +118,10 @@ export class ContentEditableBackend { this.element.removeEventListener("cut", this.handleCutEvent); this.element.removeEventListener("dragstart", this.handleDragStart); this.element.removeEventListener("drop", this.handleDrop); + this.element.removeEventListener( + "pointerdown", + this.handlePointerDown, + ); this.element.ownerDocument?.removeEventListener( "selectionchange", this.handleSelectionChange, @@ -147,10 +142,9 @@ export class ContentEditableBackend { this.ytext = null; this.observer = null; this.deferredRemoteDeltas = []; - this.isApplyingSelection = 0; + this.fieldEditor.resetBackendSelectionAuthority(); this.isComposing = false; this.compositionStartText = null; - this.activeCellSelection = null; this.fieldEditor.setComposing(false); } @@ -178,74 +172,38 @@ export class ContentEditableBackend { }): void { const { blockId, range, text, marks } = options; const cellCoord = this._getActiveCellCoord(blockId); - const ops: DocumentOp[] = []; - const nextOffset = range.start + text.length; - this.pendingSelectionOverride = { + const transaction = buildInlineTextEditTransaction({ blockId, - anchorOffset: nextOffset, - focusOffset: nextOffset, - cell: cellCoord - ? { row: cellCoord.row, col: cellCoord.col } - : undefined, - }; - - if (range.end > range.start) { - if (cellCoord) { - ops.push({ - type: "delete-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: range.start, - length: range.end - range.start, - }); - } else { - ops.push({ - type: "delete-text", - blockId, - offset: range.start, - length: range.end - range.start, - }); - } - } - - if (text.length > 0) { - if (cellCoord) { - ops.push({ - type: "insert-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: range.start, - text, - }); - } else { - ops.push({ - type: "insert-text", - blockId, - offset: range.start, - text, - marks, - }); - } - } + range, + text, + marks, + cellCoord, + }); + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + transaction.selection, + ); - if (ops.length > 0) { - this.editor.apply(ops, { origin: "user" }); + if (transaction.ops.length > 0) { + this.editor.apply(transaction.ops, { origin: "user" }); } if (cellCoord) { - this.activeCellSelection = { - start: nextOffset, - end: nextOffset, - }; + this.fieldEditor.setBackendSelectionAuthority( + "cell", + transaction.selection, + ); } else { - this.fieldEditor.syncTextSelection(blockId, nextOffset, nextOffset); + this.fieldEditor.syncTextSelection( + blockId, + transaction.selection.anchorOffset, + transaction.selection.focusOffset, + ); } this.ensureActiveDOMMatchesYText(); this.restoreDOMSelectionFromEditor(); this.scheduleActiveDOMMatchCheck(); - this.pendingSelectionOverride = null; + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); } applyListInputRule(options: { @@ -256,11 +214,11 @@ export class ContentEditableBackend { const target = applyListInputRule(this.editor, options); if (!target) return false; - this.pendingSelectionOverride = { + this.fieldEditor.setBackendSelectionAuthority("programmatic", { blockId: target.blockId, anchorOffset: target.anchorOffset, focusOffset: target.focusOffset, - }; + }); this.fieldEditor.syncTextSelection( target.blockId, @@ -268,7 +226,7 @@ export class ContentEditableBackend { target.focusOffset, ); this.restoreDOMSelectionFromEditor(); - this.pendingSelectionOverride = null; + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); return true; } @@ -279,10 +237,10 @@ export class ContentEditableBackend { if (!blockId) return; const selection = this.editor.selection; - const pendingSelection = - this.pendingSelectionOverride?.blockId === blockId - ? this.pendingSelectionOverride - : null; + const pendingSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + blockId, + ); const activeCell = this._getActiveCellCoord(blockId); if ( activeCell && @@ -292,12 +250,7 @@ export class ContentEditableBackend { ) { const activeSelection = pendingSelection ?? - (this.activeCellSelection - ? { - anchorOffset: this.activeCellSelection.start, - focusOffset: this.activeCellSelection.end, - } - : null) ?? + this.fieldEditor.getBackendSelectionAuthority("cell", blockId) ?? (selection?.type === "text" && selection.anchor.blockId === blockId && selection.focus.blockId === blockId @@ -309,11 +262,8 @@ export class ContentEditableBackend { if (!activeSelection) return; const start = activeSelection.anchorOffset; const end = activeSelection.focusOffset; - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); setSelectionOffsets(this.element, start, end); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); return; } const anchor = @@ -339,17 +289,26 @@ export class ContentEditableBackend { if (anchor.blockId !== blockId || focus.blockId !== blockId) { return; } + if ( + pendingSelection == null && + anchor.offset === focus.offset && + selection?.type === "text" && + selection.isCollapsed + ) { + this.fieldEditor.setBackendSelectionAuthority("programmatic", { + blockId, + anchorOffset: anchor.offset, + focusOffset: focus.offset, + }); + } const root = this.element.closest( "[data-pen-editor-root]", ) as HTMLElement | null; if (!root) return; - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); editorSelectionToDOM(root, anchor, focus); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); } // ── Direct input handling ───────────────────────────────── @@ -520,7 +479,7 @@ export class ContentEditableBackend { }); this.fieldEditor.notifyDomReconciled(blockId ?? undefined); if ( - this.pendingSelectionOverride != null || + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || event.transaction?.origin === "remote" || event.transaction?.origin === "collaborator" ) { @@ -543,7 +502,7 @@ export class ContentEditableBackend { } if ( - this.pendingSelectionOverride != null || + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || event.transaction?.origin === "remote" || event.transaction?.origin === "collaborator" ) { @@ -553,81 +512,46 @@ export class ContentEditableBackend { private applyTextDiffAsOps( blockId: string, - diff: Array< - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number } - >, + diff: InlineTextDiffOp[], ): void { if (diff.length === 0) return; const ytext = this.ytext; if (!ytext) return; - const ops: DocumentOp[] = []; const cellCoord = this._getActiveCellCoord(blockId); - for (const op of diff) { - if (op.type === "delete") { - if (cellCoord) { - ops.push({ - type: "delete-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: op.offset, - length: op.length, - }); - } else { - ops.push({ - type: "delete-text", - blockId, - offset: op.offset, - length: op.length, - }); - } - continue; - } - - if (cellCoord) { - ops.push({ - type: "insert-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: op.offset, - text: op.text, - }); - } else { - ops.push({ - type: "insert-text", - blockId, - offset: op.offset, - text: op.text, - marks: this.fieldEditor.resolveInsertMarks( - ytext, - op.offset, - ), - }); - } - } + const ops = buildInlineTextDiffOps({ + blockId, + diff, + ytext, + resolveInsertMarks: (sourceText, offset) => + this.fieldEditor.resolveInsertMarks(sourceText, offset), + cellCoord, + }); if (ops.length === 0) return; const range = this.element ? getSelectionOffsets(this.element) : null; if (range) { - this.pendingSelectionOverride = { + this.fieldEditor.setBackendSelectionAuthority("programmatic", { blockId, anchorOffset: range.start, focusOffset: range.end, cell: cellCoord ? { row: cellCoord.row, col: cellCoord.col } : undefined, - }; + }); } this.editor.apply(ops, { origin: "user" }); if (range) { if (cellCoord) { - this.activeCellSelection = range; + this.fieldEditor.setBackendSelectionAuthority("cell", { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + cell: { row: cellCoord.row, col: cellCoord.col }, + }); } else { this.fieldEditor.syncTextSelection( blockId, @@ -639,7 +563,7 @@ export class ContentEditableBackend { this.ensureActiveDOMMatchesYText(); this.restoreDOMSelectionFromEditor(); this.scheduleActiveDOMMatchCheck(); - this.pendingSelectionOverride = null; + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); } private ensureActiveDOMMatchesYText(): boolean { @@ -689,6 +613,10 @@ export class ContentEditableBackend { private handleKeyDown = (event: KeyboardEvent): void => { if (!this.ytext) return; + if (isNavigationSelectionKey(event)) { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + this.fieldEditor.clearBackendSelectionAuthority("user-dom"); + } const handled = handleFieldEditorKeyDown({ event, @@ -707,24 +635,31 @@ export class ContentEditableBackend { start: number; end: number; } | null { + const blockId = this.fieldEditor.focusBlockId; const liveRange = this.element ? getSelectionOffsets(this.element) : null; return ( this.fieldEditor.resolveProgrammaticInputRange( - this.fieldEditor.focusBlockId, + blockId, liveRange, - ) ?? liveRange + ) ?? + liveRange ); } private handleSelectionChange = (): void => { if (!this.element) return; + const isApplyingSelection = + this.fieldEditor.getBackendSelectionApplicationDepth(); if ( !this.fieldEditor.shouldHandleDomSelectionChange( - this.isApplyingSelection, + isApplyingSelection, ) ) { + if (this.shouldRestoreSuppressedFullBlockSelection()) { + this.restoreDOMSelectionFromEditor(); + } return; } @@ -735,7 +670,12 @@ export class ContentEditableBackend { if (activeCell) { const range = getSelectionOffsets(this.element); if (!range) return; - this.activeCellSelection = range; + this.fieldEditor.setBackendSelectionAuthority("cell", { + blockId: activeCell.blockId, + anchorOffset: range.start, + focusOffset: range.end, + cell: { row: activeCell.row, col: activeCell.col }, + }); return; } @@ -751,6 +691,16 @@ export class ContentEditableBackend { selection, ); + if (this.shouldRestoreStaleFullBlockSelection(normalizedSelection)) { + this.restoreDOMSelectionFromEditor(); + return; + } + + if (this.shouldRestoreStaleProjectedSelection(normalizedSelection)) { + this.restoreDOMSelectionFromEditor(); + return; + } + if (normalizedSelection.type === "block") { this.fieldEditor.deactivate(); this.editor.setSelection({ @@ -770,12 +720,111 @@ export class ContentEditableBackend { return; } + this.fieldEditor.setBackendSelectionAuthority("user-dom", { + blockId: normalizedSelection.anchor.blockId, + anchorOffset: normalizedSelection.anchor.offset, + focusOffset: normalizedSelection.focus.offset, + }); + const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + normalizedSelection.anchor.blockId, + ); + if ( + !projectedSelection || + projectedSelection.anchorOffset !== normalizedSelection.anchor.offset || + projectedSelection.focusOffset !== normalizedSelection.focus.offset + ) { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } this.fieldEditor.applyDomTextSelection( normalizedSelection.anchor, normalizedSelection.focus, ); }; + private shouldRestoreStaleFullBlockSelection( + selection: ReturnType, + ): boolean { + if (selection.type === "block") { + return false; + } + if (selection.anchor.blockId !== selection.focus.blockId) { + return false; + } + + const currentSelection = this.fieldEditor.selection; + if ( + currentSelection?.type !== "text" || + !currentSelection.isCollapsed || + currentSelection.focus.blockId !== selection.anchor.blockId + ) { + return false; + } + + const block = this.editor.getBlock(selection.anchor.blockId); + const blockLength = block?.length() ?? null; + if (blockLength == null) { + return false; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + return selectionStart === 0 && selectionEnd === blockLength; + } + + private shouldRestoreStaleProjectedSelection( + selection: ReturnType, + ): boolean { + if ( + selection.type === "block" || + selection.anchor.blockId !== selection.focus.blockId || + selection.anchor.offset !== selection.focus.offset + ) { + return false; + } + const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + selection.anchor.blockId, + ) ?? this.fieldEditor.getBackendSelectionAuthority( + "user-dom", + selection.anchor.blockId, + ); + if (!projectedSelection) { + return false; + } + return ( + selection.anchor.offset !== projectedSelection.anchorOffset || + selection.focus.offset !== projectedSelection.focusOffset + ); + } + + private shouldRestoreSuppressedFullBlockSelection(): boolean { + if (!this.element) { + return false; + } + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) { + return false; + } + + const selection = domSelectionToEditor(root); + if (!selection) { + return false; + } + + return this.shouldRestoreStaleFullBlockSelection( + normalizeSelectionFormation(this.editor, selection), + ); + } + // ── Clipboard events ────────────────────────────────────── private handleCopyEvent = (event: ClipboardEvent): void => { @@ -795,6 +844,10 @@ export class ContentEditableBackend { private handleDrop = (event: DragEvent): void => { event.preventDefault(); }; + + private handlePointerDown = (): void => { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + }; } // ── Direct input handlers ────────────────────────────────── @@ -863,11 +916,13 @@ const DIRECT_HANDLERS: Record = { editor.deleteSelection(); return; } + const blockId = fe.focusBlockId; + if (!blockId) return; const range = backend.resolveCurrentInputRange(); if (!range) return; const target = applyDeleteBehavior(editor, { - blockId: fe.focusBlockId ?? "", + blockId, ytext, range, direction: "backward", @@ -888,7 +943,7 @@ const DIRECT_HANDLERS: Record = { if (range.start !== range.end) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range, text: "", }); @@ -897,7 +952,7 @@ const DIRECT_HANDLERS: Record = { if (range.start > 0) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range: { start: range.start - 1, end: range.start }, text: "", }); @@ -909,11 +964,13 @@ const DIRECT_HANDLERS: Record = { editor.deleteSelection(); return; } + const blockId = fe.focusBlockId; + if (!blockId) return; const range = backend.resolveCurrentInputRange(); if (!range) return; const target = applyDeleteBehavior(editor, { - blockId: fe.focusBlockId ?? "", + blockId, ytext, range, direction: "forward", @@ -934,7 +991,7 @@ const DIRECT_HANDLERS: Record = { if (range.start < ytext.length) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range: { start: range.start, end: range.start + 1 }, text: "", }); @@ -946,23 +1003,27 @@ const DIRECT_HANDLERS: Record = { editor.deleteSelection(); return; } + const blockId = fe.focusBlockId; + if (!blockId) return; const range = backend.resolveCurrentInputRange(); if (!range || range.start === range.end) return; backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range, text: "", }); }, deleteWordBackward: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; const range = backend.resolveCurrentInputRange(); if (!range) return; if (range.start !== range.end) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range, text: "", }); @@ -975,7 +1036,7 @@ const DIRECT_HANDLERS: Record = { while (pos > 0 && !/\s/.test(text[pos - 1])) pos--; if (pos < range.start) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range: { start: pos, end: range.start }, text: "", }); @@ -983,12 +1044,14 @@ const DIRECT_HANDLERS: Record = { }, deleteWordForward: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; const range = backend.resolveCurrentInputRange(); if (!range) return; if (range.start !== range.end) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range, text: "", }); @@ -1001,7 +1064,7 @@ const DIRECT_HANDLERS: Record = { while (pos < text.length && !/\s/.test(text[pos])) pos++; if (pos > range.end) { backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", + blockId, range: { start: range.end, end: pos }, text: "", }); @@ -1319,3 +1382,16 @@ function mapOffsetThroughRemoteDeltas( return mappedOffset; } + +function isNavigationSelectionKey(event: KeyboardEvent): boolean { + return ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" + ); +} diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index 3d720c7..e83ab11 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -1,6 +1,10 @@ import type { BlockSchema, Editor, FieldEditorFocusOptions } from "@pen/types"; import type { FieldEditorStore } from "./store"; import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; export type FieldEditorFocusReason = | "activate" @@ -136,6 +140,25 @@ export interface FieldEditorDomController extends FieldEditorSelectionState { options?: FocusOptions, ): boolean; shouldHandleDomSelectionChange(isApplyingSelection: number): boolean; + resetBackendSelectionAuthority(): void; + setBackendSelectionAuthority( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void; + getBackendSelectionAuthority( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null; + hasBackendSelectionAuthority(source: FieldEditorSelectionSource): boolean; + clearBackendSelectionAuthority(source: FieldEditorSelectionSource): void; + applyBackendSelectionUntilNextFrame(): void; + getBackendSelectionApplicationDepth(): number; + setEditContextSelectionSnapshot( + selection: FieldEditorSelectionSnapshot | null, + ): void; + getEditContextSelectionSnapshot( + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null; resolveProgrammaticInputRange( blockId: string | null, liveRange: { start: number; end: number } | null, diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index 8b78e3b..99e4e6e 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -1,9 +1,5 @@ import { INPUT_RULES_ENGINE_SLOT_KEY } from "@pen/types"; -import type { - DocumentOp, - Editor, - InlineDecoration, -} from "@pen/types"; +import type { DocumentOp, Editor, InlineDecoration } from "@pen/types"; import { supportsInlineInputRules } from "@pen/types"; import type { FieldEditorInputController } from "./controller"; import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; @@ -12,6 +8,16 @@ import { editorSelectionToDOM, getDirectionalSelectionOffsets, } from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; import { normalizeSelectionFormation } from "../utils/selectionFormation"; import { handleFieldEditorKeyDown } from "./keyHandling"; import { isHistoryTransactionOrigin } from "./historyOrigin"; @@ -19,6 +25,7 @@ import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; import type { PasteImporters } from "../types/paste"; import { applyListInputRule } from "./commands"; import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { buildInlineTextEditTransaction } from "./inlineTextTransaction"; import type { FieldEditorObserver, FieldEditorTextChangeEvent, @@ -35,31 +42,10 @@ type EditContextTextUpdateEvent = Event & { selectionEnd?: number; }; -type EditContextSelection = { - blockId: string; - anchorOffset: number; - focusOffset: number; -}; - type EditContextSelectionOptions = { source?: "text-update"; }; -type EditContextRange = { - start: number; - end: number; -}; - -type DirectionalSelectionOffsets = NonNullable< - ReturnType ->; - -type KeyDownRangeResolution = { - range: EditContextRange; - nextSelection: EditContextSelection | null; - shouldSyncEditContextSelection: boolean; -}; - type EditContextTextFormat = { rangeStart: number; rangeEnd: number; @@ -104,12 +90,6 @@ export class EditContextBackend { private element: HTMLElement | null = null; private ytext: FieldEditorTextLike | null = null; private observer: FieldEditorObserver | null = null; - private isApplyingSelection = 0; - private editContextSelection: EditContextSelection | null = null; - // A textupdate carries the freshest post-input caret. Keep it authoritative - // until a real user selection gesture or navigation key moves the caret. - private authoritativeTextInputSelection: EditContextSelection | null = null; - private pendingSelectionOverride: EditContextSelection | null = null; private editor: Editor; private fieldEditor: FieldEditorInputController; @@ -175,14 +155,12 @@ export class EditContextBackend { this.fieldEditor.notifyDomReconciled( this.fieldEditor.focusBlockId ?? undefined, ); - this.isApplyingSelection++; + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); this.updateSelection(); this.fieldEditor.requestDomFocus(element, "backend-activate", { preventScroll: true, }); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); } deactivate(): void { @@ -228,9 +206,7 @@ export class EditContextBackend { this.element = null; this.ytext = null; this.observer = null; - this.editContextSelection = null; - this.authoritativeTextInputSelection = null; - this.pendingSelectionOverride = null; + this.fieldEditor.resetBackendSelectionAuthority(); this.fieldEditor.setComposing(false); } @@ -256,11 +232,8 @@ export class EditContextBackend { anchorOffset, focusOffset, }); - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); this.projectDOMSelection(blockId, anchorOffset, focusOffset); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); return; } @@ -268,13 +241,15 @@ export class EditContextBackend { ? 0 : this.ytext.length; this.editContext.updateSelection(len, len); - this.editContextSelection = blockId - ? { - blockId, - anchorOffset: len, - focusOffset: len, - } - : null; + this.fieldEditor.setEditContextSelectionSnapshot( + blockId + ? { + blockId, + anchorOffset: len, + focusOffset: len, + } + : null, + ); } private projectDOMSelection( @@ -332,7 +307,10 @@ export class EditContextBackend { anchorOffset: listInputRuleTarget.anchorOffset, focusOffset: listInputRuleTarget.focusOffset, }; - this.pendingSelectionOverride = nextSelection; + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + nextSelection, + ); this.setEditContextSelection(nextSelection, { source: "text-update", }); @@ -342,7 +320,7 @@ export class EditContextBackend { listInputRuleTarget.focusOffset, ); this.restoreDOMCaret(); - this.pendingSelectionOverride = null; + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); return; } @@ -352,7 +330,10 @@ export class EditContextBackend { text, ); if (inlineInputRuleTarget) { - this.pendingSelectionOverride = inlineInputRuleTarget; + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + inlineInputRuleTarget, + ); this.setEditContextSelection(inlineInputRuleTarget, { source: "text-update", }); @@ -362,35 +343,26 @@ export class EditContextBackend { inlineInputRuleTarget.focusOffset, ); this.restoreDOMCaret(); - this.pendingSelectionOverride = null; + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); return; } - this.pendingSelectionOverride = resolvedTextUpdate.selection; + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + resolvedTextUpdate.selection, + ); - const ops: DocumentOp[] = []; - if (range.end > range.start) { - ops.push({ - type: "delete-text" as const, - blockId, - offset: range.start, - length: range.end - range.start, - }); - } - if (text.length > 0) { - ops.push({ - type: "insert-text" as const, - blockId, - offset: range.start, - text, - marks: this.fieldEditor.resolveInsertMarks( - this.ytext, - range.start, - ), - }); - } - if (ops.length > 0) { - this.editor.apply(ops, { origin: "user" }); + const transaction = buildInlineTextEditTransaction({ + blockId, + range, + text, + marks: this.fieldEditor.resolveInsertMarks( + this.ytext, + range.start, + ), + }); + if (transaction.ops.length > 0) { + this.editor.apply(transaction.ops, { origin: "user" }); } if (resolvedTextUpdate.selection) { @@ -405,7 +377,7 @@ export class EditContextBackend { this.restoreDOMCaret(); } - this.pendingSelectionOverride = null; + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); }; private resolveTextUpdateRange(input: { @@ -420,99 +392,37 @@ export class EditContextBackend { selection: EditContextSelection | null; } { const selection = this.fieldEditor.selection; - const isLogicallyEmpty = isLogicallyEmptyText( - this.ytext?.toString() ?? "", - ); - const editorSelectionRange = this.resolveEditorSelectionRange( - input.blockId, - ); - const isCollapsedInsert = - input.text.length > 0 && - input.updateRangeStart === input.updateRangeEnd; - const programmaticInputRange = - this.fieldEditor.resolveProgrammaticInputRange(input.blockId, { - start: input.updateRangeStart, - end: input.updateRangeEnd, - }); - const editContextCaret = collapsedSelectionOffset( - this.editContextSelection, - input.blockId, - ); - const authoritativeInputCaret = collapsedSelectionOffset( - this.authoritativeTextInputSelection, - input.blockId, - ); const editorCaret = selection?.type === "text" && selection.isCollapsed && selection.focus.blockId === input.blockId ? selection.focus.offset : null; - const trustedCaret = - authoritativeInputCaret ?? - (isLogicallyEmpty ? 0 : (editContextCaret ?? editorCaret)); - const shouldUseTrustedCaret = - isCollapsedInsert && - trustedCaret != null && - trustedCaret !== input.updateRangeStart; - const shouldUseEditorSelectionRange = - editorSelectionRange != null && - input.updateRangeStart === input.updateRangeEnd && - (input.updateRangeStart !== editorSelectionRange.start || - input.updateRangeEnd !== editorSelectionRange.end); - const shouldClampEmptyRange = - isLogicallyEmpty && authoritativeInputCaret == null; - const rangeStart = programmaticInputRange - ? programmaticInputRange.start - : shouldUseEditorSelectionRange - ? editorSelectionRange.start - : shouldClampEmptyRange - ? 0 - : shouldUseTrustedCaret - ? trustedCaret - : input.updateRangeStart; - const rangeEnd = programmaticInputRange - ? programmaticInputRange.end - : shouldUseEditorSelectionRange - ? editorSelectionRange.end - : shouldClampEmptyRange - ? 0 - : shouldUseTrustedCaret - ? trustedCaret - : input.updateRangeEnd; - const hasCollapsedEventSelection = - typeof input.selectionStart !== "number" || - typeof input.selectionEnd !== "number" || - input.selectionStart === input.selectionEnd; - const nextSelectionOffset = - input.text.length > 0 && hasCollapsedEventSelection - ? rangeStart + input.text.length - : null; - const anchorOffset = - nextSelectionOffset ?? - (typeof input.selectionStart === "number" - ? input.selectionStart - : null); - const focusOffset = - nextSelectionOffset ?? - (typeof input.selectionEnd === "number" - ? input.selectionEnd - : null); - return { - range: { - start: rangeStart, - end: rangeEnd, - }, - selection: - anchorOffset != null && focusOffset != null - ? { - blockId: input.blockId, - anchorOffset, - focusOffset, - } - : null, - }; + return resolveEditContextTextUpdateRange({ + ...input, + isLogicallyEmpty: isLogicallyEmptyText( + this.ytext?.toString() ?? "", + ), + editorSelectionRange: this.resolveEditorSelectionRange( + input.blockId, + ), + programmaticInputRange: + this.fieldEditor.resolveProgrammaticInputRange(input.blockId, { + start: input.updateRangeStart, + end: input.updateRangeEnd, + }), + editContextSelection: + this.fieldEditor.getEditContextSelectionSnapshot( + input.blockId, + ), + authoritativeTextInputSelection: + this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + input.blockId, + ), + editorCaret, + }); } private setEditContextSelection( @@ -530,12 +440,12 @@ export class EditContextBackend { options, ), }; - this.editContextSelection = resolvedSelection; + this.fieldEditor.setEditContextSelectionSnapshot(resolvedSelection); if (options?.source === "text-update") { - this.authoritativeTextInputSelection = resolvedSelection; - } else { - // Programmatic/editor selections supersede stale EditContext text-update carets. - this.authoritativeTextInputSelection = null; + this.fieldEditor.setBackendSelectionAuthority( + "edit-context-textupdate", + resolvedSelection, + ); } this.editContext?.updateSelection( resolvedSelection.anchorOffset, @@ -585,9 +495,9 @@ export class EditContextBackend { return false; } - const editorSelectionRange = this.resolveEditorSelectionRange( - selection.anchor.blockId, - ); + const editorSelectionRange = + this.resolveEditorSelectionRange(selection.anchor.blockId) ?? + this.resolveCollapsedEditorSelectionRange(selection.anchor.blockId); if (!editorSelectionRange) { return false; } @@ -747,11 +657,16 @@ export class EditContextBackend { private handleSelectionChange = (): void => { if (!this.element || !this.editContext) return; + const isApplyingSelection = + this.fieldEditor.getBackendSelectionApplicationDepth(); if ( !this.fieldEditor.shouldHandleDomSelectionChange( - this.isApplyingSelection, + isApplyingSelection, ) ) { + if (isApplyingSelection === 0) { + this.restoreDOMCaret(); + } return; } @@ -849,7 +764,8 @@ export class EditContextBackend { anchorOffset: offsets.anchor, focusOffset: offsets.focus, }; - this.editContextSelection = nextSelection; + this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); + this.fieldEditor.setBackendSelectionAuthority("user-dom", nextSelection); this.fieldEditor.syncTextSelection( normalizedSelection.anchor.blockId, offsets.anchor, @@ -861,6 +777,9 @@ export class EditContextBackend { if (!this.editContext || !this.element || !this.ytext) return; const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); if (isHistory) { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); const nextText = toEditContextText(this.ytext?.toString?.() ?? ""); this.editContext.updateText( 0, @@ -880,13 +799,15 @@ export class EditContextBackend { clampedSelectionEnd, ); const blockId = this.fieldEditor.focusBlockId; - this.editContextSelection = blockId - ? { - blockId, - anchorOffset: clampedSelectionStart, - focusOffset: clampedSelectionEnd, - } - : null; + this.fieldEditor.setEditContextSelectionSnapshot( + blockId + ? { + blockId, + anchorOffset: clampedSelectionStart, + focusOffset: clampedSelectionEnd, + } + : null, + ); fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { preserveSelection: true, inlineDecorations: this.getInlineDecorationsForBlock(), @@ -942,8 +863,14 @@ export class EditContextBackend { } } - if (this.pendingSelectionOverride) { - this.setEditContextSelection(this.pendingSelectionOverride, { + const pendingSelection = this.fieldEditor.focusBlockId + ? this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + this.fieldEditor.focusBlockId, + ) + : null; + if (pendingSelection) { + this.setEditContextSelection(pendingSelection, { source: "text-update", }); } @@ -959,19 +886,21 @@ export class EditContextBackend { const selection = this.fieldEditor.selection; const blockId = this.fieldEditor.focusBlockId; const pendingSelection = - blockId != null && - this.pendingSelectionOverride?.blockId === blockId - ? this.pendingSelectionOverride + blockId != null + ? this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + blockId, + ) : null; const authoritativeInputSelection = - blockId != null && - this.authoritativeTextInputSelection?.blockId === blockId - ? this.authoritativeTextInputSelection + blockId != null + ? this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + blockId, + ) : null; const editContextSelection = - blockId != null && this.editContextSelection?.blockId === blockId - ? this.editContextSelection - : null; + this.fieldEditor.getEditContextSelectionSnapshot(blockId); const editorSelection = selection?.type === "text" && blockId && @@ -992,15 +921,12 @@ export class EditContextBackend { editContextSelection?.focusOffset ?? null; if (root && blockId && anchorOffset != null && focusOffset != null) { - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); editorSelectionToDOM( root, { blockId, offset: anchorOffset }, { blockId, offset: focusOffset }, ); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); return; } @@ -1015,15 +941,12 @@ export class EditContextBackend { const sel = this.element.ownerDocument?.getSelection(); if (!sel) return; - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); sel.removeAllRanges(); const range = document.createRange(); range.setStart(anchorPoint.node, anchorPoint.offset); range.setEnd(focusPoint.node, focusPoint.offset); sel.addRange(range); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); } private getInlineDecorationsForBlock(): readonly InlineDecoration[] { @@ -1043,7 +966,9 @@ export class EditContextBackend { private handleKeyDown = (event: KeyboardEvent): void => { if (!this.editContext || !this.element || !this.ytext) return; if (isNavigationSelectionKey(event)) { - this.authoritativeTextInputSelection = null; + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); } const blockId = this.fieldEditor.focusBlockId; @@ -1053,7 +978,7 @@ export class EditContextBackend { if (shouldSyncEditContextSelection) { this.editContext.updateSelection(range.start, range.end); - this.editContextSelection = nextSelection; + this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); } const handled = handleFieldEditorKeyDown({ @@ -1073,96 +998,41 @@ export class EditContextBackend { event: KeyboardEvent, liveDomOffsets: DirectionalSelectionOffsets | null, ): KeyDownRangeResolution { - if (!blockId) { - return { - range: liveDomOffsets - ? directionalSelectionToRange(liveDomOffsets) - : this.resolveEditContextSelectionRange(), - nextSelection: null, - shouldSyncEditContextSelection: false, - }; - } - - const editorSelectionRange = this.resolveEditorSelectionRange(blockId); + const isTextEditingKey = isFieldEditorTextEditingKey(event); const liveRange = liveDomOffsets - ? directionalSelectionToRange(liveDomOffsets) - : null; - const programmaticInputRange = isFieldEditorTextEditingKey(event) - ? this.fieldEditor.resolveProgrammaticInputRange(blockId, liveRange) + ? { + start: liveDomOffsets.start, + end: liveDomOffsets.end, + } : null; - if (programmaticInputRange) { - return { - range: programmaticInputRange, - nextSelection: rangeToSelection( - blockId, - programmaticInputRange, - ), - shouldSyncEditContextSelection: true, - }; - } - - const trustedKeyRange = this.resolveTrustedKeyDownRange( + return resolveEditContextKeyDownRange({ blockId, - event, - editorSelectionRange, - ); - if (trustedKeyRange) { - return { - range: trustedKeyRange, - nextSelection: rangeToSelection(blockId, trustedKeyRange), - shouldSyncEditContextSelection: true, - }; - } - - if ( - editorSelectionRange && - (!liveDomOffsets || - (liveDomOffsets.start === liveDomOffsets.end && - !rangesEqual(liveDomOffsets, editorSelectionRange))) - ) { - return { - range: editorSelectionRange, - nextSelection: rangeToSelection(blockId, editorSelectionRange), - shouldSyncEditContextSelection: true, - }; - } - - if ( - liveDomOffsets && - this.shouldUseLiveDomSelection(blockId, liveDomOffsets) - ) { - return { - range: directionalSelectionToRange(liveDomOffsets), - nextSelection: { - blockId, - anchorOffset: liveDomOffsets.anchor, - focusOffset: liveDomOffsets.focus, - }, - shouldSyncEditContextSelection: true, - }; - } - - return { - range: liveDomOffsets - ? directionalSelectionToRange(liveDomOffsets) - : this.resolveEditContextSelectionRange(), - nextSelection: null, - shouldSyncEditContextSelection: false, - }; - } - - private shouldUseLiveDomSelection( - blockId: string, - liveDomOffsets: DirectionalSelectionOffsets, - ): boolean { - const authoritativeSelection = - this.getAuthoritativeTextInputSelection(blockId); - return !( - authoritativeSelection && - liveDomOffsets.anchor === liveDomOffsets.focus && - (liveDomOffsets.anchor !== authoritativeSelection.anchorOffset || - liveDomOffsets.focus !== authoritativeSelection.focusOffset) - ); + isTextEditingKey, + liveDomOffsets, + editContextRange: this.resolveEditContextSelectionRange(), + editorSelectionRange: blockId + ? this.resolveEditorSelectionRange(blockId) + : null, + programmaticInputRange: + blockId && isTextEditingKey + ? this.fieldEditor.resolveProgrammaticInputRange( + blockId, + liveRange, + ) + : null, + authoritativeTextInputSelection: blockId + ? this.getAuthoritativeTextInputSelection(blockId) + : null, + collapsedEditorSelectionRange: blockId + ? this.resolveCollapsedEditorSelectionRange(blockId) + : null, + projectedTextSelection: blockId + ? this.getProjectedTextSelection(blockId) + : null, + synchronizedEditContextRange: blockId + ? this.resolveSynchronizedEditContextRange(blockId) + : null, + }); } private resolveEditContextSelectionRange(): EditContextRange { @@ -1182,51 +1052,10 @@ export class EditContextBackend { }; } - private resolveTrustedKeyDownRange( - blockId: string, - event: KeyboardEvent, - editorSelectionRange: EditContextRange | null, - ): EditContextRange | null { - if (!isFieldEditorTextEditingKey(event)) { - return null; - } - - if (editorSelectionRange) { - return editorSelectionRange; - } - - const authoritativeSelection = - this.getAuthoritativeTextInputSelection(blockId); - if (authoritativeSelection) { - return selectionToRange(authoritativeSelection); - } - - const collapsedEditorSelection = - this.resolveCollapsedEditorSelectionRange(blockId); - if (collapsedEditorSelection) { - return collapsedEditorSelection; - } - - const projectedSelection = this.getProjectedTextSelection(blockId); - if (projectedSelection) { - return selectionToRange(projectedSelection); - } - - const synchronizedEditContextRange = - this.resolveSynchronizedEditContextRange(blockId); - if (synchronizedEditContextRange) { - return synchronizedEditContextRange; - } - - return null; - } - private getProjectedTextSelection( blockId: string, ): EditContextSelection | null { - return this.editContextSelection?.blockId === blockId - ? this.editContextSelection - : null; + return this.fieldEditor.getEditContextSelectionSnapshot(blockId); } private resolveCollapsedEditorSelectionRange( @@ -1306,16 +1135,19 @@ export class EditContextBackend { }; private handlePointerDown = (): void => { - this.authoritativeTextInputSelection = null; + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); }; private getAuthoritativeTextInputSelection( blockId: string, ): EditContextSelection | null { const selection = - this.authoritativeTextInputSelection?.blockId === blockId - ? this.authoritativeTextInputSelection - : null; + this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + blockId, + ); if (!selection || selection.anchorOffset !== selection.focusOffset) { return null; } @@ -1432,50 +1264,6 @@ function shouldReplaceEditContextText( return false; } -function collapsedSelectionOffset( - selection: EditContextSelection | null, - blockId: string, -): number | null { - if ( - selection?.blockId !== blockId || - selection.anchorOffset !== selection.focusOffset - ) { - return null; - } - return selection.focusOffset; -} - -function selectionToRange(selection: EditContextSelection): EditContextRange { - return { - start: Math.min(selection.anchorOffset, selection.focusOffset), - end: Math.max(selection.anchorOffset, selection.focusOffset), - }; -} - -function directionalSelectionToRange( - selection: DirectionalSelectionOffsets, -): EditContextRange { - return { - start: selection.start, - end: selection.end, - }; -} - -function rangeToSelection( - blockId: string, - range: EditContextRange, -): EditContextSelection { - return { - blockId, - anchorOffset: range.start, - focusOffset: range.end, - }; -} - -function rangesEqual(left: EditContextRange, right: EditContextRange): boolean { - return left.start === right.start && left.end === right.end; -} - function isNavigationSelectionKey(event: KeyboardEvent): boolean { return ( event.key === "ArrowLeft" || diff --git a/packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts b/packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts new file mode 100644 index 0000000..0745bae --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts @@ -0,0 +1,303 @@ +export type EditContextSelection = { + blockId: string; + anchorOffset: number; + focusOffset: number; +}; + +export type EditContextRange = { + start: number; + end: number; +}; + +export type DirectionalSelectionOffsets = { + anchor: number; + focus: number; + start: number; + end: number; +}; + +export type KeyDownRangeResolution = { + range: EditContextRange; + nextSelection: EditContextSelection | null; + shouldSyncEditContextSelection: boolean; +}; + +export type TextUpdateRangeResolution = { + range: EditContextRange; + selection: EditContextSelection | null; +}; + +export function resolveEditContextTextUpdateRange(input: { + blockId: string; + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; + isLogicallyEmpty: boolean; + editorSelectionRange: EditContextRange | null; + programmaticInputRange: EditContextRange | null; + editContextSelection: EditContextSelection | null; + authoritativeTextInputSelection: EditContextSelection | null; + editorCaret: number | null; +}): TextUpdateRangeResolution { + const isCollapsedInsert = + input.text.length > 0 && + input.updateRangeStart === input.updateRangeEnd; + const editContextCaret = collapsedSelectionOffset( + input.editContextSelection, + input.blockId, + ); + const authoritativeInputCaret = collapsedSelectionOffset( + input.authoritativeTextInputSelection, + input.blockId, + ); + const trustedCaret = + authoritativeInputCaret ?? + (input.isLogicallyEmpty ? 0 : (editContextCaret ?? input.editorCaret)); + const shouldUseTrustedCaret = + isCollapsedInsert && + trustedCaret != null && + trustedCaret !== input.updateRangeStart; + const editorSelectionRange = input.editorSelectionRange; + const shouldUseEditorSelectionRange = + editorSelectionRange != null && + input.updateRangeStart === input.updateRangeEnd && + (input.updateRangeStart !== editorSelectionRange.start || + input.updateRangeEnd !== editorSelectionRange.end); + const shouldClampEmptyRange = + input.isLogicallyEmpty && authoritativeInputCaret == null; + const selectedEditorRange = shouldUseEditorSelectionRange + ? editorSelectionRange + : null; + const rangeStart = input.programmaticInputRange + ? input.programmaticInputRange.start + : selectedEditorRange + ? selectedEditorRange.start + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeStart; + const rangeEnd = input.programmaticInputRange + ? input.programmaticInputRange.end + : selectedEditorRange + ? selectedEditorRange.end + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeEnd; + const hasCollapsedEventSelection = + typeof input.selectionStart !== "number" || + typeof input.selectionEnd !== "number" || + input.selectionStart === input.selectionEnd; + const nextSelectionOffset = + input.text.length > 0 && hasCollapsedEventSelection + ? rangeStart + input.text.length + : null; + const anchorOffset = + nextSelectionOffset ?? + (typeof input.selectionStart === "number" + ? input.selectionStart + : null); + const focusOffset = + nextSelectionOffset ?? + (typeof input.selectionEnd === "number" ? input.selectionEnd : null); + + return { + range: { + start: rangeStart, + end: rangeEnd, + }, + selection: + anchorOffset != null && focusOffset != null + ? { + blockId: input.blockId, + anchorOffset, + focusOffset, + } + : null, + }; +} + +export function resolveEditContextKeyDownRange(input: { + blockId: string | null; + isTextEditingKey: boolean; + liveDomOffsets: DirectionalSelectionOffsets | null; + editContextRange: EditContextRange; + editorSelectionRange: EditContextRange | null; + programmaticInputRange: EditContextRange | null; + authoritativeTextInputSelection: EditContextSelection | null; + collapsedEditorSelectionRange: EditContextRange | null; + projectedTextSelection: EditContextSelection | null; + synchronizedEditContextRange: EditContextRange | null; +}): KeyDownRangeResolution { + if (!input.blockId) { + return { + range: input.liveDomOffsets + ? directionalSelectionToRange(input.liveDomOffsets) + : input.editContextRange, + nextSelection: null, + shouldSyncEditContextSelection: false, + }; + } + + if (input.programmaticInputRange) { + return { + range: input.programmaticInputRange, + nextSelection: rangeToSelection( + input.blockId, + input.programmaticInputRange, + ), + shouldSyncEditContextSelection: true, + }; + } + + const trustedKeyRange = resolveTrustedKeyDownRange(input); + if (trustedKeyRange) { + return { + range: trustedKeyRange, + nextSelection: rangeToSelection(input.blockId, trustedKeyRange), + shouldSyncEditContextSelection: true, + }; + } + + if ( + input.editorSelectionRange && + (!input.liveDomOffsets || + (input.liveDomOffsets.start === input.liveDomOffsets.end && + !rangesEqual(input.liveDomOffsets, input.editorSelectionRange))) + ) { + return { + range: input.editorSelectionRange, + nextSelection: rangeToSelection( + input.blockId, + input.editorSelectionRange, + ), + shouldSyncEditContextSelection: true, + }; + } + + if ( + input.liveDomOffsets && + shouldUseLiveDomSelection( + input.liveDomOffsets, + input.authoritativeTextInputSelection, + ) + ) { + return { + range: directionalSelectionToRange(input.liveDomOffsets), + nextSelection: { + blockId: input.blockId, + anchorOffset: input.liveDomOffsets.anchor, + focusOffset: input.liveDomOffsets.focus, + }, + shouldSyncEditContextSelection: true, + }; + } + + return { + range: input.liveDomOffsets + ? directionalSelectionToRange(input.liveDomOffsets) + : input.editContextRange, + nextSelection: null, + shouldSyncEditContextSelection: false, + }; +} + +export function collapsedSelectionOffset( + selection: EditContextSelection | null, + blockId: string, +): number | null { + if ( + selection?.blockId !== blockId || + selection.anchorOffset !== selection.focusOffset + ) { + return null; + } + return selection.focusOffset; +} + +export function selectionToRange( + selection: EditContextSelection, +): EditContextRange { + return { + start: Math.min(selection.anchorOffset, selection.focusOffset), + end: Math.max(selection.anchorOffset, selection.focusOffset), + }; +} + +export function directionalSelectionToRange( + selection: DirectionalSelectionOffsets, +): EditContextRange { + return { + start: selection.start, + end: selection.end, + }; +} + +export function rangeToSelection( + blockId: string, + range: EditContextRange, +): EditContextSelection { + return { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + }; +} + +export function rangesEqual( + left: EditContextRange, + right: EditContextRange, +): boolean { + return left.start === right.start && left.end === right.end; +} + +function resolveTrustedKeyDownRange(input: { + isTextEditingKey: boolean; + editorSelectionRange: EditContextRange | null; + authoritativeTextInputSelection: EditContextSelection | null; + collapsedEditorSelectionRange: EditContextRange | null; + projectedTextSelection: EditContextSelection | null; + synchronizedEditContextRange: EditContextRange | null; +}): EditContextRange | null { + if (!input.isTextEditingKey) { + return null; + } + + if (input.editorSelectionRange) { + return input.editorSelectionRange; + } + + if (input.authoritativeTextInputSelection) { + return selectionToRange(input.authoritativeTextInputSelection); + } + + if (input.collapsedEditorSelectionRange) { + return input.collapsedEditorSelectionRange; + } + + if (input.projectedTextSelection) { + return selectionToRange(input.projectedTextSelection); + } + + if (input.synchronizedEditContextRange) { + return input.synchronizedEditContextRange; + } + + return null; +} + +function shouldUseLiveDomSelection( + liveDomOffsets: DirectionalSelectionOffsets, + authoritativeSelection: EditContextSelection | null, +): boolean { + return !( + authoritativeSelection && + liveDomOffsets.anchor === liveDomOffsets.focus && + (liveDomOffsets.anchor !== authoritativeSelection.anchorOffset || + liveDomOffsets.focus !== authoritativeSelection.focusOffset) + ); +} diff --git a/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts b/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts index 64c757e..d6cf4cb 100644 --- a/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts @@ -21,7 +21,6 @@ export class ExpandedContentEditableBackend { private element: HTMLElement | null = null; private editor: Editor; private fieldEditor: FieldEditorInputController; - private isApplyingSelection = 0; constructor(editor: Editor, fieldEditor: FieldEditorInputController) { this.editor = editor; @@ -32,6 +31,7 @@ export class ExpandedContentEditableBackend { this.element = element; element.contentEditable = "true"; element.tabIndex = -1; + this.fieldEditor.resetBackendSelectionAuthority(); element.addEventListener("beforeinput", this.handleBeforeInput); element.addEventListener("keydown", this.handleKeyDown); @@ -46,28 +46,21 @@ export class ExpandedContentEditableBackend { const selection = this.editor.selection; if (selection?.type === "text") { - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); if ( !this.fieldEditor.requestDomFocus(element, "backend-activate", { preventScroll: true, }) ) { - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); return; } editorSelectionToDOM(element, selection.anchor, selection.focus); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); return; } this.fieldEditor.requestDomFocus(element, "backend-activate", { preventScroll: true, }); - this.isApplyingSelection = 0; } deactivate(): void { @@ -101,18 +94,15 @@ export class ExpandedContentEditableBackend { if (!this.element) return; const selection = this.editor.selection; if (selection?.type !== "text") return; - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); editorSelectionToDOM(this.element, selection.anchor, selection.focus); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); } private handleSelectionChange = (): void => { if (!this.element) return; if ( !this.fieldEditor.shouldHandleDomSelectionChange( - this.isApplyingSelection, + this.fieldEditor.getBackendSelectionApplicationDepth(), ) ) { return; diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index 8084133..7029a33 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -14,31 +14,34 @@ import { } from "@pen/types"; import { EditContextBackend } from "./editContextBackend"; import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; import { SessionReconciler } from "./sessionReconciler"; -import type { InputBackend } from "../internal/inputBackend"; import { classifySelectionSurface } from "./crossBlock"; -import { resolveMarksAtPosition } from "./markBoundary"; import type { ActiveCellCoord, FieldEditorFocusReason, - FieldEditorFocusRequest, FieldEditorInputController, FieldEditorSession, - PenFocusAction, - PenFocusDecision, PenFieldEditorFocusOptions, PenFocusLifecycleEvent, PenFocusLifecycleListener, PenFocusPolicy, - PenFocusReason, } from "./controller"; -import { - getCellYText, - getResolvedYText, - resolveCellInlineElement, -} from "./contentResolution"; +import { getCellYText, getResolvedYText } from "./contentResolution"; import type { FieldEditorTextLike } from "./crdt"; import { domSelectionToEditor, @@ -54,25 +57,13 @@ import { shouldForceBlockScopedSelectAll, } from "../utils/flowCapabilities"; import type { FieldEditorStoreSnapshot } from "./store"; -import { - resolveSelectAllBehavior, - type EditorSelectAllBehavior, -} from "../constants/selectAll"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; type FieldEditorOptions = { selectAllBehavior?: EditorSelectAllBehavior; focusPolicy?: PenFocusPolicy; }; -const ALLOW_FOCUS_DECISION: PenFocusDecision = { type: "allow" }; - -type ProgrammaticTextSelection = { - blockId: string; - anchorOffset: number; - focusOffset: number; - selectionIntentEpoch: number; -}; - export class FieldEditorImpl implements FieldEditorSession { private _focusBlockId: string | null = null; private _activeBlockIds: string[] = []; @@ -80,9 +71,9 @@ export class FieldEditorImpl implements FieldEditorSession { private _isEditing = false; private _isFocused = false; private _isComposing = false; + private _suppressNextBackendActivationFocus = false; private _inputMode: "richtext" | "code" | "table" | "none" = "none"; private _mode: "inactive" | "single" | "expanded" | "block" = "inactive"; - private _backend: InputBackend | null = null; private _editor: Editor; private _rootElement: HTMLElement | null = null; private _activateListeners = new Set<(blockIds: string[]) => void>(); @@ -90,57 +81,107 @@ export class FieldEditorImpl implements FieldEditorSession { private _storeListeners = new Set<() => void>(); private _unsubscribeSelection: Unsubscribe | null = null; private _unsubscribeHistoryApplied: Unsubscribe | null = null; - private _pendingMarks: Record = {}; - private _syncDomVersion = 0; private _domSyncVersion = 0; - private _suppressNextDomSelectionProjection = false; - private _pointerSelectionDepth = 0; - private _pendingSelectionProjectionVersion: number | null = null; - private _selectionIntentEpoch = 0; private readonly _sessionReconciler: SessionReconciler; + private readonly _backendLifecycle: BackendLifecycleController; + private readonly _focusController: FocusController; + private readonly _cellEditingController: CellEditingController; private readonly _historySelectionCoordinator: HistorySelectionCoordinator; - private _selectAllBehavior: EditorSelectAllBehavior; - private _focusPolicy: PenFocusPolicy | undefined; - private _focusLifecycleListeners = new Set(); - private _attachmentResolvers = new Set<() => void>(); - private _selectAllCycle: { - blockId: string; - scope: "cell" | "block" | "document"; - } | null = null; - private _preserveSelectAllCycle = false; - private _programmaticTextSelection: ProgrammaticTextSelection | null = null; - private _pendingProgrammaticTextSelection: ProgrammaticTextSelection | null = - null; - private _activeCellCoord: ActiveCellCoord | null = null; + private readonly _pendingMarkController: PendingMarkController; + private readonly _selectAllController: SelectAllController; + private readonly _selectionCoordinator: FieldEditorSelectionCoordinator; constructor(editor: Editor, options?: FieldEditorOptions) { this._editor = editor; - this._selectAllBehavior = - options?.selectAllBehavior ?? - resolveSelectAllBehavior("content-first"); - this._focusPolicy = options?.focusPolicy; + this._backendLifecycle = new BackendLifecycleController( + this._editor, + this, + ); + this._selectAllController = new SelectAllController( + options?.selectAllBehavior, + ); + this._focusController = new FocusController({ + editor: this._editor, + getRootElement: () => this._findEditorRoot(), + getFocusBlockId: () => this._focusBlockId, + getAttachedElement: () => this._attachedElement, + }); + this._focusController.setFocusPolicy(options?.focusPolicy); + this._cellEditingController = new CellEditingController({ + getRootElement: () => this._findEditorRoot(), + getYTextForCell: (blockId, row, col) => + this._getYTextForCell(blockId, row, col), + attachElement: (element) => this.attachElement(element), + requestDomFocus: (target, reason, focusOptions, policyOptions) => + this.requestDomFocus( + target, + reason, + focusOptions, + policyOptions, + ), + }); + this._pendingMarkController = new PendingMarkController({ + editor: this._editor, + getFocusBlockId: () => this._focusBlockId, + getYText: (blockId) => this._getYText(blockId), + emitStateChange: () => this._emitStateChange(), + }); this._historySelectionCoordinator = new HistorySelectionCoordinator( this._editor, ); + this._selectionCoordinator = new FieldEditorSelectionCoordinator({ + historySelectionCoordinator: this._historySelectionCoordinator, + isEditing: () => this._isEditing, + getMode: () => this._mode, + getFocusBlockId: () => this._focusBlockId, + getAttachedElement: () => this._attachedElement, + getRootElement: () => this._findEditorRoot(), + findExpandedHost: () => this._findExpandedHost(), + resolveInlineElement: (blockId) => + this._resolveInlineElement(blockId), + attachElement: (element, focusOptions) => + this.attachElement(element, focusOptions), + requestDomFocus: (target, reason, focusOptions, policyOptions) => + this.requestDomFocus( + target, + reason, + focusOptions, + policyOptions, + ), + updateBackendSelection: () => { + this._backendLifecycle.updateSelection(null); + }, + setTextSelection: (blockId, anchorOffset, focusOffset) => + this.setTextSelection(blockId, anchorOffset, focusOffset), + activate: (blockId) => this.activate(blockId), + emitSelectionProjected: () => { + this._emitFocusLifecycle({ + type: "selection-projected", + editor: this._editor, + blockId: this._focusBlockId, + }); + }, + }); this._unsubscribeSelection = this._editor.onSelectionChange( (selection) => { - const preserveSelectAllCycle = - this._preserveSelectAllCycle || - this._selectionMatchesSelectAllCycle(selection); - this._preserveSelectAllCycle = false; - if (!preserveSelectAllCycle) { - this._selectAllCycle = null; - } + this._selectAllController.consumeShouldPreserveCycle( + selection, + (cycle, nextSelection) => + this._selectionMatchesSelectAllCycle( + cycle, + nextSelection, + ), + ); if ( selection?.type !== "text" || !selection.isCollapsed || selection.isMultiBlock ) { - this._clearPendingMarks(true); + this._pendingMarkController.clear(true); } const suppressSelectionSync = - this._consumeDomSelectionProjectionSuppression() || - this._shouldSuppressSelectionSync(); + this._selectionCoordinator.consumeDomSelectionProjectionSuppression() || + this._selectionCoordinator.shouldSuppressSelectionSync(); this._recomputeSurfaceFromSelection({ syncSelectionToBackend: !suppressSelectionSync, }); @@ -157,10 +198,11 @@ export class FieldEditorImpl implements FieldEditorSession { getInlineElement: (blockId) => this._resolveInlineElement(blockId), getYText: (blockId) => this._getYText(blockId), shouldPreserveSelection: () => - this._shouldProjectSelectionAfterReconcile(), + this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), shouldProjectSelection: () => - this._shouldProjectSelectionAfterReconcile(), - projectSelection: () => this._syncDomSelectionOnce(), + this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), + projectSelection: () => + this._selectionCoordinator.syncDomSelectionOnce(), notifyDomReconciled: (blockId) => this.notifyDomReconciled(blockId), }); } @@ -191,19 +233,15 @@ export class FieldEditorImpl implements FieldEditorSession { this._emitStateChange(); } get activeCellCoord(): ActiveCellCoord | null { - return this._activeCellCoord; + return this._cellEditingController.activeCellCoord; } setSelectAllBehavior(behavior: EditorSelectAllBehavior): void { - if (this._selectAllBehavior === behavior) { - return; - } - this._selectAllBehavior = behavior; - this.resetSelectAllCycle(); + this._selectAllController.setBehavior(behavior); } setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { - this._focusPolicy = focusPolicy; + this._focusController.setFocusPolicy(focusPolicy); } // ── Lifecycle ───────────────────────────────────────────── @@ -219,7 +257,8 @@ export class FieldEditorImpl implements FieldEditorSession { activateCell(blockId: string, row: number, col: number): void { this._activateCell(blockId, row, col); - this._trySyncCellBackend(0); + this._attachedElement = null; + this._cellEditingController.trySyncBackend(); } activateCellFromElement( @@ -230,11 +269,11 @@ export class FieldEditorImpl implements FieldEditorSession { ): void { this._activateCell(blockId, row, col); this.attachElement(element); - this._placeCaretInCell(element); + this._cellEditingController.placeCaretInCell(element); } private _activateCell(blockId: string, row: number, col: number): void { - this._activeCellCoord = { blockId, row, col }; + this._cellEditingController.setActiveCell(blockId, row, col); if (!this._isEditing || this._focusBlockId !== blockId) { this._startSession(blockId, { stopCapturing: true, @@ -246,53 +285,6 @@ export class FieldEditorImpl implements FieldEditorSession { this._emitStateChange(); } - private _trySyncCellBackend(attempt: number): void { - const coord = this._activeCellCoord; - if (!coord) return; - - const ytext = this._getYTextForCell( - coord.blockId, - coord.row, - coord.col, - ); - if (!ytext) return; - - const root = this._findEditorRoot(); - if (!root) return; - - const cellEl = this._resolveCellElement( - coord.blockId, - coord.row, - coord.col, - root, - ); - - if (cellEl) { - this._attachedElement = null; - this.attachElement(cellEl); - this._placeCaretInCell(cellEl); - return; - } - - if (attempt < 3) { - requestAnimationFrame(() => this._trySyncCellBackend(attempt + 1)); - } - } - - private _placeCaretInCell(cellEl: HTMLElement): void { - if (!this.requestDomFocus(cellEl, "cell", { preventScroll: true })) { - return; - } - const selection = cellEl.ownerDocument?.getSelection(); - if (!selection) return; - - const range = cellEl.ownerDocument.createRange(); - range.selectNodeContents(cellEl); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - deactivate(): void { this._deactivate({ restoreFocus: true }); } @@ -301,12 +293,11 @@ export class FieldEditorImpl implements FieldEditorSession { const activeCellElement = this._resolveActiveCellElement(rootElement); if (activeCellElement) { const activeCellBlockId = - this._activeCellCoord?.blockId ?? + this._cellEditingController.activeCellCoord?.blockId ?? this._resolveSelectAllBlockId(rootElement); const shouldSelectCellContents = !isDomSelectionCoveringElementContents(activeCellElement) || - this._selectAllCycle?.scope !== "cell" || - this._selectAllCycle.blockId !== activeCellBlockId; + !this._selectAllController.hasScope(activeCellBlockId, "cell"); if (shouldSelectCellContents) { if ( this._attachedElement !== activeCellElement || @@ -316,13 +307,16 @@ export class FieldEditorImpl implements FieldEditorSession { } this._selectElementContents(activeCellElement); if (activeCellBlockId) { - this._recordSelectAllScope(activeCellBlockId, "cell"); + this._selectAllController.recordScope( + activeCellBlockId, + "cell", + ); } return true; } } - if (this._selectAllBehavior === "document-first") { + if (this._selectAllController.getBehavior() === "document-first") { const activeBlockId = this._resolveSelectAllBlockId(rootElement); const activeCapability = activeBlockId ? getEditorFlowCapability(this._editor, activeBlockId) @@ -349,18 +343,17 @@ export class FieldEditorImpl implements FieldEditorSession { ); const shouldSelectDocument = blockLength === 0 || - (this._selectAllCycle?.blockId === blockId && - this._selectAllCycle.scope === "block"); + this._selectAllController.hasScope(blockId, "block"); const nextScope = shouldSelectDocument ? "document" : "block"; if (nextScope === "block") { if (blockRole && blockRole !== "editable-inline") { this.deactivate(); this._editor.selectBlock(blockId); - this._recordSelectAllScope(blockId, "block"); + this._selectAllController.recordScope(blockId, "block"); return true; } - this.activateTextSelection(blockId, 0, blockLength); - this._recordSelectAllScope(blockId, "block"); + this.commitProgrammaticTextSelection(blockId, 0, blockLength); + this._selectAllController.recordScope(blockId, "block"); return true; } } @@ -374,13 +367,21 @@ export class FieldEditorImpl implements FieldEditorSession { return true; } - if (!this._isEditing) { - this.activate(range.focusBlockId); + if (range.start.blockId === range.end.blockId) { + this.commitProgrammaticTextSelection( + range.start.blockId, + range.start.offset, + range.end.offset, + ); + } else { + if (!this._isEditing) { + this.activate(range.focusBlockId); + } + this._editor.selectTextRange(range.start, range.end); } - this._editor.selectTextRange(range.start, range.end); this._recomputeSurfaceFromSelection(); - if (this._selectAllBehavior === "block-first") { - this._recordSelectAllScope( + if (this._selectAllController.getBehavior() === "block-first") { + this._selectAllController.recordScope( blockId ?? range.focusBlockId, "document", ); @@ -395,16 +396,11 @@ export class FieldEditorImpl implements FieldEditorSession { } beginPointerSelection(): void { - this._recordUserSelectionIntent(); - this._pointerSelectionDepth += 1; + this._selectionCoordinator.beginPointerSelection(); } endPointerSelection(): void { - if (this._pointerSelectionDepth === 0) { - return; - } - this._pointerSelectionDepth -= 1; - this._recordUserSelectionIntent(); + this._selectionCoordinator.endPointerSelection(); } setComposing(composing: boolean): void { @@ -418,23 +414,19 @@ export class FieldEditorImpl implements FieldEditorSession { const blockIds = [...this._activeBlockIds]; const focusTargetId = this._focusBlockId ?? blockIds[0] ?? null; - this._backend?.deactivate(); - this._backend = null; + this._backendLifecycle.deactivate(); this._attachedElement = null; - this._activeCellCoord = null; + this._cellEditingController.clear(); this._focusBlockId = null; this._activeBlockIds = []; this._isEditing = false; this._isComposing = false; this._historySelectionCoordinator.reset(); - this._suppressNextDomSelectionProjection = false; - this._programmaticTextSelection = null; - this._pendingProgrammaticTextSelection = null; - this._pointerSelectionDepth = 0; + this._selectionCoordinator.reset(); this._inputMode = "none"; this._mode = "inactive"; - this._pendingMarks = {}; + this._pendingMarkController.reset(); for (const cb of this._deactivateListeners) cb(blockIds); this._emitFocusLifecycle({ @@ -481,7 +473,7 @@ export class FieldEditorImpl implements FieldEditorSession { selection.anchor.blockId === this._focusBlockId && selection.focus.blockId === this._focusBlockId ) { - this._backend?.updateSelection(null); + this._backendLifecycle.updateSelection(null); return true; } @@ -498,12 +490,7 @@ export class FieldEditorImpl implements FieldEditorSession { } blur(): void { - const root = this._findEditorRoot(); - if (!root) return; - const activeEl = root.ownerDocument?.activeElement; - if (activeEl instanceof HTMLElement && root.contains(activeEl)) { - activeEl.blur(); - } + this._focusController.blur(); } requestDomFocus( @@ -512,16 +499,18 @@ export class FieldEditorImpl implements FieldEditorSession { options?: FocusOptions, policyOptions: PenFieldEditorFocusOptions = {}, ): boolean { - const request = this._createFocusRequest(target, reason, policyOptions); - const decision = this._decideFocus(request); - if (decision.type === "deny") { - this._emitFocusDenied(request); - return false; - } - if (decision.type === "allow") { - target.focus(options); + if ( + reason === "backend-activate" && + this._suppressNextBackendActivationFocus + ) { + return true; } - return true; + return this._focusController.requestDomFocus( + target, + reason, + options, + policyOptions, + ); } requestActivation( @@ -529,13 +518,7 @@ export class FieldEditorImpl implements FieldEditorSession { reason: FieldEditorFocusReason, options: PenFieldEditorFocusOptions = {}, ): boolean { - const request = this._createFocusRequest(target, reason, options); - const decision = this._decideFocus(request); - if (decision.type === "deny") { - this._emitFocusDenied(request); - return false; - } - return true; + return this._focusController.requestActivation(target, reason, options); } requestRootFocus( @@ -543,57 +526,13 @@ export class FieldEditorImpl implements FieldEditorSession { reason: FieldEditorFocusReason, options?: FocusOptions, ): boolean { - return this.requestDomFocus(target, reason, options); - } - - private _createFocusRequest( - target: HTMLElement, - reason: FieldEditorFocusReason, - options: PenFieldEditorFocusOptions = {}, - ): FieldEditorFocusRequest { - return { - editor: this._editor, - target, - root: this._findEditorRoot(), - reason, - action: resolvePenFocusAction(reason), - source: options.reason ?? resolvePenFocusReason(reason), - blockId: this._focusBlockId, - passive: options.passive ?? options.domFocus === false, - }; - } - - private _decideFocus( - request: ReturnType, - ): PenFocusDecision { - const policyDecision = this._focusPolicy?.decide(request); - if (policyDecision) { - return request.passive && policyDecision.type === "allow" - ? { type: "allow-passive" } - : policyDecision; - } - - return request.passive ? { type: "allow-passive" } : ALLOW_FOCUS_DECISION; - } - - private _emitFocusDenied( - request: ReturnType, - ): void { - this._focusPolicy?.onDenied?.(request); - this._emitFocusLifecycle({ - type: "focus-request-denied", - request, - }); + return this._focusController.requestRootFocus(target, reason, options); } setRootElement(element: HTMLElement | null): void { this._rootElement = element; if (element) { - this._emitFocusLifecycle({ - type: "field-editor-attached", - editor: this._editor, - root: element, - }); + this._focusController.notifyRootAttached(element); } if (element && this._isEditing) { this._syncActiveElement(false); @@ -624,21 +563,28 @@ export class FieldEditorImpl implements FieldEditorSession { options: PenFieldEditorFocusOptions = {}, ): boolean { if (!this._focusBlockId) return false; - if (this._attachedElement === element && this._backend) return true; - if (!this.requestActivation(element, "backend-attach", options)) return false; + if (this._attachedElement === element && this._backendLifecycle.current) + return true; + if (!this.requestActivation(element, "backend-attach", options)) + return false; this._emitFocusLifecycle({ type: "backend-attach-started", editor: this._editor, target: element, blockId: this._focusBlockId, }); - this._backend?.deactivate(); - this._backend = this.createBackend(); + this._backendLifecycle.replace(this._resolveBackendClass()); const ytext = this._getYText(this._focusBlockId); if (!ytext) return false; - this._backend.activate(element, ytext); + this._suppressNextBackendActivationFocus = + options.domFocus === false || options.passive === true; + try { + this._backendLifecycle.activate(element, ytext); + } finally { + this._suppressNextBackendActivationFocus = false; + } this._attachedElement = element; this._emitFocusLifecycle({ type: "backend-attach-completed", @@ -646,7 +592,7 @@ export class FieldEditorImpl implements FieldEditorSession { target: element, blockId: this._focusBlockId, }); - this._resolveAttachmentWaiters(); + this._focusController.resolveAttachmentWaiters(); return true; } @@ -658,37 +604,15 @@ export class FieldEditorImpl implements FieldEditorSession { if (!this._isEditing) return; if (this._focusBlockId !== blockId) return; - const currentSelection = this._editor.selection; - const pendingProgrammaticSelection = - this._pendingProgrammaticTextSelection; - const isAlreadyCurrentSelection = - currentSelection?.type === "text" && - !currentSelection.isMultiBlock && - currentSelection.anchor.blockId === blockId && - currentSelection.focus.blockId === blockId && - currentSelection.anchor.offset === anchorOffset && - currentSelection.focus.offset === focusOffset; - if (isAlreadyCurrentSelection) { - if ( - pendingProgrammaticSelection && - pendingProgrammaticSelection.blockId === blockId && - pendingProgrammaticSelection.anchorOffset === anchorOffset && - pendingProgrammaticSelection.focusOffset === focusOffset - ) { - this._pendingProgrammaticTextSelection = null; - } - return; - } - if ( - pendingProgrammaticSelection && - (pendingProgrammaticSelection.blockId !== blockId || - pendingProgrammaticSelection.anchorOffset !== anchorOffset || - pendingProgrammaticSelection.focusOffset !== focusOffset) + this._selectionCoordinator.prepareSyncedTextSelection( + this._editor.selection, + blockId, + anchorOffset, + focusOffset, + ) === "skip" ) { - this._recordUserSelectionIntent(); - } else if (!pendingProgrammaticSelection) { - this._selectionIntentEpoch += 1; + return; } this.setTextSelection(blockId, anchorOffset, focusOffset); } @@ -697,8 +621,8 @@ export class FieldEditorImpl implements FieldEditorSession { anchor: { blockId: string; offset: number }, focus: { blockId: string; offset: number }, ): void { - this._recordUserSelectionIntent(); - this._suppressNextDomSelectionProjection = true; + this._selectionCoordinator.recordUserSelectionIntent(); + this._selectionCoordinator.suppressNextDomSelectionProjection(); if (!this._isEditing || !this._focusBlockId) { this._startSession(anchor.blockId, { @@ -728,13 +652,20 @@ export class FieldEditorImpl implements FieldEditorSession { focusBlockId?: string; }, ): void { - this._recordUserSelectionIntent(); if (anchor.blockId !== focus.blockId) { this.applyDocumentTextSelection(anchor, focus); return; } - this._suppressNextDomSelectionProjection = true; + const isProgrammaticDomSelection = + this._selectionCoordinator.isProgrammaticDomTextSelection( + anchor, + focus, + ); + if (!isProgrammaticDomSelection) { + this._selectionCoordinator.recordUserSelectionIntent(); + } + this._selectionCoordinator.suppressNextDomSelectionProjection(); if ( anchor.blockId === focus.blockId && @@ -760,61 +691,79 @@ export class FieldEditorImpl implements FieldEditorSession { } shouldHandleDomSelectionChange(isApplyingSelection: number): boolean { - return ( - isApplyingSelection === 0 && - this._pointerSelectionDepth === 0 && - this._pendingProgrammaticTextSelection === null && - !this._shouldSuppressSelectionSync() + return this._selectionCoordinator.shouldHandleDomSelectionChange( + this._focusBlockId, + isApplyingSelection, + ); + } + + resetBackendSelectionAuthority(): void { + this._selectionCoordinator.resetAuthority(); + } + + setBackendSelectionAuthority( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._selectionCoordinator.setAuthoritySelection(source, selection); + } + + getBackendSelectionAuthority( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._selectionCoordinator.getAuthoritySelection( + source, + blockId, ); } + hasBackendSelectionAuthority(source: FieldEditorSelectionSource): boolean { + return this._selectionCoordinator.hasAuthoritySelection(source); + } + + clearBackendSelectionAuthority(source: FieldEditorSelectionSource): void { + this._selectionCoordinator.clearAuthoritySelection(source); + } + + applyBackendSelectionUntilNextFrame(): void { + this._selectionCoordinator.applySelectionUntilNextFrame(); + } + + getBackendSelectionApplicationDepth(): number { + return this._selectionCoordinator.isApplyingSelection; + } + + setEditContextSelectionSnapshot( + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._selectionCoordinator.setEditContextSelection(selection); + } + + getEditContextSelectionSnapshot( + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._selectionCoordinator.getEditContextSelection(blockId); + } + resolveProgrammaticInputRange( blockId: string | null, liveRange: { start: number; end: number } | null, ): { start: number; end: number } | null { - const programmaticSelection = - this._getActiveProgrammaticTextSelection(blockId); - if (!programmaticSelection) { - return null; - } - if (!liveRange) { - this._programmaticTextSelection = null; - return { - start: programmaticSelection.anchorOffset, - end: programmaticSelection.focusOffset, - }; - } - if ( - liveRange.start === liveRange.end && - (liveRange.start !== programmaticSelection.anchorOffset || - liveRange.end !== programmaticSelection.focusOffset) - ) { - this._programmaticTextSelection = null; - return { - start: programmaticSelection.anchorOffset, - end: programmaticSelection.focusOffset, - }; - } - return null; + return this._selectionCoordinator.resolveProgrammaticInputRange( + blockId, + liveRange, + ); } shouldIgnoreDomTextSelection( anchor: { blockId: string; offset: number }, focus: { blockId: string; offset: number }, ): boolean { - const programmaticSelection = this._getActiveProgrammaticTextSelection( - anchor.blockId, + return this._selectionCoordinator.shouldIgnoreDomTextSelection( + anchor, + focus, ); - if (!programmaticSelection || anchor.blockId !== focus.blockId) { - return false; - } - if ( - anchor.offset === programmaticSelection.anchorOffset && - focus.offset === programmaticSelection.focusOffset - ) { - return false; - } - return anchor.offset === focus.offset; } setTextSelection( @@ -823,28 +772,14 @@ export class FieldEditorImpl implements FieldEditorSession { focusOffset: number, ): void { if (anchorOffset !== focusOffset) { - this._clearPendingMarks(true); + this._pendingMarkController.clear(true); } this._editor.selectText(blockId, anchorOffset, focusOffset); - const programmaticSelection = this._programmaticTextSelection; - if ( - programmaticSelection && - (programmaticSelection.blockId !== blockId || - programmaticSelection.anchorOffset !== anchorOffset || - programmaticSelection.focusOffset !== focusOffset) - ) { - this._programmaticTextSelection = null; - } - const pendingProgrammaticSelection = - this._pendingProgrammaticTextSelection; - if ( - pendingProgrammaticSelection && - (pendingProgrammaticSelection.blockId !== blockId || - pendingProgrammaticSelection.anchorOffset !== anchorOffset || - pendingProgrammaticSelection.focusOffset !== focusOffset) - ) { - this._pendingProgrammaticTextSelection = null; - } + this._selectionCoordinator.notifyTextSelectionSet( + blockId, + anchorOffset, + focusOffset, + ); this._emitStateChange(); } @@ -854,9 +789,12 @@ export class FieldEditorImpl implements FieldEditorSession { focusOffset: number, options?: PenFieldEditorFocusOptions, ): void { - this._programmaticTextSelection = null; - this._pendingProgrammaticTextSelection = null; - this._projectTextSelection(blockId, anchorOffset, focusOffset, options); + this._selectionCoordinator.activateTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); } async focusTextSelection( @@ -865,7 +803,12 @@ export class FieldEditorImpl implements FieldEditorSession { focusOffset: number, options: PenFieldEditorFocusOptions = {}, ): Promise { - this.activateTextSelection(blockId, anchorOffset, focusOffset, options); + this.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); const attached = await this.waitForAttachment(blockId); if (!attached) { return false; @@ -873,29 +816,27 @@ export class FieldEditorImpl implements FieldEditorSession { if (options.domFocus === false || options.passive) { return true; } - return this.focus(options); + const focused = this.focus(options); + this.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + ); + return focused; } commitProgrammaticTextSelection( blockId: string, anchorOffset: number, focusOffset: number, + options?: PenFieldEditorFocusOptions, ): void { - this._programmaticTextSelection = { - blockId, - anchorOffset, - focusOffset, - selectionIntentEpoch: this._selectionIntentEpoch, - }; - this._pendingProgrammaticTextSelection = { + this._selectionCoordinator.commitProgrammaticTextSelection( blockId, anchorOffset, focusOffset, - selectionIntentEpoch: this._selectionIntentEpoch, - }; - this._projectTextSelection(blockId, anchorOffset, focusOffset, { - syncBackendImmediately: true, - }); + options, + ); } collapseSelectionToFocus(): void { @@ -926,7 +867,7 @@ export class FieldEditorImpl implements FieldEditorSession { this.activate(point.blockId); } - this._syncDomSelectionOnce(); + this._selectionCoordinator.syncDomSelectionOnce(); } delegate(blockSchema: BlockSchema): boolean { @@ -934,29 +875,20 @@ export class FieldEditorImpl implements FieldEditorSession { } getPendingMarks(): Readonly> { - return this._pendingMarks; + return this._pendingMarkController.getSnapshot(); } clearPendingMarks(): void { - this._clearPendingMarks(); - } - - private _recordSelectAllScope( - blockId: string, - scope: "cell" | "block" | "document", - ): void { - this._preserveSelectAllCycle = true; - this._selectAllCycle = { blockId, scope }; + this._pendingMarkController.clear(); } resetSelectAllCycle(): void { - this._preserveSelectAllCycle = false; - this._selectAllCycle = null; + this._selectAllController.resetCycle(); } private _syncSelectionToDOM(): void { if (!this._isEditing) return; - this._syncDomSelectionOnce(); + this._selectionCoordinator.syncDomSelectionOnce(); } private _resolveSelectAllBlockId( @@ -967,7 +899,7 @@ export class FieldEditorImpl implements FieldEditorSession { return selection.focus.blockId; } if ( - this._selectAllBehavior === "block-first" && + this._selectAllController.getBehavior() === "block-first" && selection?.type === "block" && selection.blockIds.length === 1 ) { @@ -1007,13 +939,9 @@ export class FieldEditorImpl implements FieldEditorSession { } private _selectionMatchesSelectAllCycle( + cycle: { blockId: string; scope: "cell" | "block" | "document" }, selection: SelectionState | null, ): boolean { - const cycle = this._selectAllCycle; - if (!cycle) { - return false; - } - if (cycle.scope === "cell") { return ( selection?.type === "cell" && @@ -1071,41 +999,18 @@ export class FieldEditorImpl implements FieldEditorSession { } togglePendingMark(markType: string): boolean { - if (!this._isEditing || this._inputMode !== "richtext") return false; - - const baseMarks = this._resolveBaseInsertMarks(); - const baseValue = baseMarks[markType]; - const effectiveMarks = this._applyPendingMarks(baseMarks); - const nextValue = effectiveMarks[markType] != null ? null : true; - const nextPendingMarks = { ...this._pendingMarks }; - - if ((baseValue ?? null) === nextValue) { - delete nextPendingMarks[markType]; - } else { - nextPendingMarks[markType] = nextValue; - } - - this._pendingMarks = nextPendingMarks; - this._emitStateChange(); - return true; + return this._pendingMarkController.toggle( + markType, + this._isEditing, + this._inputMode, + ); } resolveInsertMarks( ytext: FieldEditorTextLike, offset: number, ): Record | undefined { - const baseMarks = - resolveMarksAtPosition(ytext, offset, this._editor.schema) ?? {}; - const resolved = this._applyPendingMarks(baseMarks); - const insertMarks: Record = { ...resolved }; - - for (const [markType, value] of Object.entries(this._pendingMarks)) { - if (value == null && markType in baseMarks) { - insertMarks[markType] = null; - } - } - - return Object.keys(insertMarks).length > 0 ? insertMarks : undefined; + return this._pendingMarkController.resolveInsertMarks(ytext, offset); } // ── Cross-block expansion ──────────────────────────────── @@ -1157,8 +1062,7 @@ export class FieldEditorImpl implements FieldEditorSession { } onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { - this._focusLifecycleListeners.add(listener); - return () => this._focusLifecycleListeners.delete(listener); + return this._focusController.onFocusLifecycle(listener); } onSelectionChange(cb: (sel: SelectionState) => void): Unsubscribe { @@ -1175,7 +1079,7 @@ export class FieldEditorImpl implements FieldEditorSession { domSyncVersion: this._domSyncVersion, inputMode: this._inputMode, mode: this._mode, - activeCellCoord: this._activeCellCoord, + activeCellCoord: this._cellEditingController.activeCellCoord, }; } @@ -1190,33 +1094,7 @@ export class FieldEditorImpl implements FieldEditorSession { } waitForAttachment(blockId = this._focusBlockId): Promise { - if ( - this._attachedElement?.isConnected && - (blockId == null || this._focusBlockId === blockId) - ) { - return Promise.resolve(true); - } - return new Promise((resolve) => { - let frame = 0; - const check = () => { - if ( - this._attachedElement?.isConnected && - (blockId == null || this._focusBlockId === blockId) - ) { - resolve(true); - return; - } - if (frame >= 4) { - this._attachmentResolvers.delete(check); - resolve(false); - return; - } - frame += 1; - requestAnimationFrame(check); - }; - this._attachmentResolvers.add(check); - requestAnimationFrame(check); - }); + return this._focusController.waitForAttachment(blockId); } destroy(): void { @@ -1226,31 +1104,19 @@ export class FieldEditorImpl implements FieldEditorSession { this._unsubscribeHistoryApplied = null; this._sessionReconciler.destroy(); this._deactivate({ restoreFocus: false }); - this._pointerSelectionDepth = 0; this._activateListeners.clear(); this._deactivateListeners.clear(); this._storeListeners.clear(); - this._focusLifecycleListeners.clear(); - this._attachmentResolvers.clear(); + this._focusController.destroy(); } // ── Internal ───────────────────────────────────────────── - private createBackend(): InputBackend { - return new (this._resolveBackendClass())(this._editor, this); - } - - private _resolveBackendClass(): new ( - editor: Editor, - fieldEditor: FieldEditorInputController, - ) => InputBackend { + private _resolveBackendClass(): InputBackendConstructor { if (this._mode === "expanded") { - return ExpandedContentEditableBackend as unknown as new ( - editor: Editor, - fieldEditor: FieldEditorInputController, - ) => InputBackend; + return ExpandedContentEditableBackend as unknown as InputBackendConstructor; } - if (this._activeCellCoord) { + if (this._cellEditingController.activeCellCoord) { return ContentEditableBackend; } if ( @@ -1275,20 +1141,7 @@ export class FieldEditorImpl implements FieldEditorSession { } private _restoreFocusAfterDeactivate(blockId: string | null): void { - const root = this._findEditorRoot(); - if (!root) return; - - if (blockId) { - const blockEl = queryBlockElement(root, blockId); - if (blockEl) { - this.requestDomFocus(blockEl, "restore", { - preventScroll: true, - }); - return; - } - } - - this.requestDomFocus(root, "restore", { preventScroll: true }); + this._focusController.restoreFocusAfterDeactivate(blockId); } private _emitStateChange(): void { @@ -1298,63 +1151,7 @@ export class FieldEditorImpl implements FieldEditorSession { } private _emitFocusLifecycle(event: PenFocusLifecycleEvent): void { - for (const listener of this._focusLifecycleListeners) { - listener(event); - } - } - - private _resolveAttachmentWaiters(): void { - for (const resolve of this._attachmentResolvers) { - resolve(); - } - this._attachmentResolvers.clear(); - } - - private _consumeDomSelectionProjectionSuppression(): boolean { - const shouldSuppress = this._suppressNextDomSelectionProjection; - this._suppressNextDomSelectionProjection = false; - return shouldSuppress; - } - - private _resolveBaseInsertMarks(): Record { - const selection = this._editor.selection; - if (!this._focusBlockId || selection?.type !== "text") { - return {}; - } - - const blockId = selection.focus.blockId; - const ytext = this._getYText(blockId); - if (!ytext) return {}; - - return ( - resolveMarksAtPosition( - ytext, - selection.focus.offset, - this._editor.schema, - ) ?? {} - ); - } - - private _applyPendingMarks( - baseMarks: Record, - ): Record { - const nextMarks = { ...baseMarks }; - for (const [markType, value] of Object.entries(this._pendingMarks)) { - if (value == null) { - delete nextMarks[markType]; - } else { - nextMarks[markType] = value; - } - } - return nextMarks; - } - - private _clearPendingMarks(silent = false): void { - if (Object.keys(this._pendingMarks).length === 0) return; - this._pendingMarks = {}; - if (!silent) { - this._emitStateChange(); - } + this._focusController.emitLifecycle(event); } private _recomputeSurfaceFromSelection(options?: { @@ -1368,7 +1165,7 @@ export class FieldEditorImpl implements FieldEditorSession { ); this._updateSurfaceState(surface.mode, surface.blockIds); if (options?.syncSelectionToBackend ?? true) { - this._backend?.updateSelection(null); + this._backendLifecycle.updateSelection(null); } } @@ -1402,12 +1199,11 @@ export class FieldEditorImpl implements FieldEditorSession { private _syncBackendForSurfaceMode(): void { if (!this._isEditing || !this._focusBlockId) return; const NextBackendClass = this._resolveBackendClass(); - if (this._backend?.constructor === NextBackendClass) { + if (this._backendLifecycle.hasBackend(NextBackendClass)) { return; } - this._backend?.deactivate(); - this._backend = new NextBackendClass(this._editor, this); + this._backendLifecycle.replace(NextBackendClass); if (this._mode === "expanded") { const expandedHost = this._findExpandedHost(); @@ -1435,7 +1231,7 @@ export class FieldEditorImpl implements FieldEditorSession { return; } - this._backend.activate(this._attachedElement, ytext); + this._backendLifecycle.activate(this._attachedElement, ytext); } private _startSession( @@ -1459,14 +1255,14 @@ export class FieldEditorImpl implements FieldEditorSession { this._isEditing = true; this._isComposing = false; this._mode = "single"; - this._pendingMarks = {}; + this._pendingMarkController.reset(); if (options.stopCapturing) { this._editor.undoManager.stopCapturing(); } this._inputMode = resolveInputMode(schema); - this._backend = this.createBackend(); + this._backendLifecycle.replace(this._resolveBackendClass()); this._attachedElement = null; if (options.attachImmediately) { this._syncActiveElement(false); @@ -1516,218 +1312,24 @@ export class FieldEditorImpl implements FieldEditorSession { } private _attachedElementOwnsFocus(): boolean { - if (!this._attachedElement) { - return false; - } - const activeElement = - this._attachedElement.ownerDocument?.activeElement; - return activeElement instanceof Node - ? this._attachedElement.contains(activeElement) - : false; - } - - private _shouldProjectSelectionAfterReconcile(): boolean { - if (!this._attachedElement) { - return false; - } - - const ownerDocument = this._attachedElement.ownerDocument; - const activeElement = ownerDocument?.activeElement; - if (!(activeElement instanceof Node)) { - return true; - } - if (activeElement === ownerDocument?.body) { - return true; - } - - const root = this._findEditorRoot(); - if (!root || !root.contains(activeElement)) { - return true; - } - - return this._attachedElement.contains(activeElement); + return this._focusController.attachedElementOwnsFocus(); } private _resolveInlineElement(blockId: string): HTMLElement | null { const root = this._findEditorRoot(); if (!root) return null; - const activeCell = this._activeCellCoord; - if (activeCell?.blockId === blockId) { - return this._resolveCellElement( - activeCell.blockId, - activeCell.row, - activeCell.col, - root, - ); - } + const cellElement = + this._cellEditingController.resolveInlineElement(blockId); + if (cellElement) return cellElement; return queryInlineElement(root, blockId); } - private _projectTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - options?: { - syncBackendImmediately?: boolean; - } & PenFieldEditorFocusOptions, - ): void { - this.setTextSelection(blockId, anchorOffset, focusOffset); - - if (!this._isEditing || this._focusBlockId !== blockId) { - this.activate(blockId); - } - - if (options?.syncBackendImmediately) { - this._backend?.updateSelection(null); - } - this._syncDomSelectionOnce(4, undefined, options); - } - - private _syncDomSelectionOnce( - remainingAttempts = 4, - version?: number, - options: PenFieldEditorFocusOptions = {}, - selectionIntentEpoch = this._selectionIntentEpoch, - ): void { - if (version === undefined) { - version = ++this._syncDomVersion; - this._pendingSelectionProjectionVersion = version; - } - const v = version; - requestAnimationFrame(() => { - if (!this._isEditing || this._syncDomVersion !== v) return; - if (selectionIntentEpoch !== this._selectionIntentEpoch) { - this._cancelSelectionProjection(v); - return; - } - - let projected = false; - const pendingProjectionRequestId = - this._historySelectionCoordinator.getPendingProjectionRequestId(); - - if (this._mode === "expanded") { - const expandedHost = this._findExpandedHost(); - if (expandedHost) { - let didAttach = true; - if ( - this._attachedElement !== expandedHost || - !this._attachedElement?.isConnected - ) { - didAttach = this.attachElement(expandedHost, options); - } - if ( - didAttach && - this.requestDomFocus( - expandedHost, - "selection-project", - { - preventScroll: true, - }, - options, - ) - ) { - this._backend?.updateSelection(null); - projected = true; - } - } - } else if (this._focusBlockId) { - const inlineEl = this._resolveInlineElement(this._focusBlockId); - if (inlineEl) { - let didAttach = true; - if ( - this._attachedElement !== inlineEl || - !this._attachedElement || - !this._attachedElement.isConnected - ) { - didAttach = this.attachElement(inlineEl, options); - } - if ( - didAttach && - this.requestDomFocus( - inlineEl, - "selection-project", - { - preventScroll: true, - }, - options, - ) - ) { - this._backend?.updateSelection(null); - projected = true; - } - } - } - - if (projected) { - this._emitFocusLifecycle({ - type: "selection-projected", - editor: this._editor, - blockId: this._focusBlockId, - }); - requestAnimationFrame(() => { - if (this._syncDomVersion === v) { - if (this._pendingSelectionProjectionVersion === v) { - this._pendingSelectionProjectionVersion = null; - } - this._historySelectionCoordinator.completeDeferredProjection( - pendingProjectionRequestId, - ); - } - }); - } - - if (!projected && remainingAttempts > 0) { - this._syncDomSelectionOnce( - remainingAttempts - 1, - v, - options, - selectionIntentEpoch, - ); - } else if (!projected) { - this._cancelSelectionProjection(v); - } - }); - } - - private _recordUserSelectionIntent(): void { - this._selectionIntentEpoch += 1; - this._programmaticTextSelection = null; - this._pendingProgrammaticTextSelection = null; - const pendingProjectionVersion = this._pendingSelectionProjectionVersion; - if (pendingProjectionVersion !== null) { - this._syncDomVersion += 1; - this._cancelSelectionProjection(pendingProjectionVersion); - } - } - - private _cancelSelectionProjection(version: number): void { - if (this._pendingSelectionProjectionVersion === version) { - this._pendingSelectionProjectionVersion = null; - } - this._historySelectionCoordinator.cancelDeferredProjection(); - } - - private _getActiveProgrammaticTextSelection( - blockId: string | null, - ): ProgrammaticTextSelection | null { - const programmaticSelection = - this._programmaticTextSelection ?? - this._pendingProgrammaticTextSelection; - if (!blockId || programmaticSelection?.blockId !== blockId) { - return null; - } - return programmaticSelection; - } - - private _shouldSuppressSelectionSync(): boolean { - return ( - this._historySelectionCoordinator.shouldSuppressSelectionSync() || - this._pendingSelectionProjectionVersion !== null - ); - } - private _getYText(blockId: string): FieldEditorTextLike | null { - return getResolvedYText(this._editor, blockId, this._activeCellCoord); + return getResolvedYText( + this._editor, + blockId, + this._cellEditingController.activeCellCoord, + ); } private _getYTextForCell( @@ -1755,30 +1357,11 @@ export class FieldEditorImpl implements FieldEditorSession { selection.addRange(range); } - private _resolveCellElement( - blockId: string, - row: number, - col: number, - root?: HTMLElement | null, - ): HTMLElement | null { - return resolveCellInlineElement( - blockId, - row, - col, - root ?? this._findEditorRoot(), - ); - } - private _resolveActiveCellElement( rootElement?: HTMLElement | null, ): HTMLElement | null { - const coord = this._activeCellCoord; - if (!coord) return null; - return this._resolveCellElement( - coord.blockId, - coord.row, - coord.col, - rootElement ?? undefined, + return this._cellEditingController.resolveActiveCellElement( + rootElement, ); } } @@ -1822,47 +1405,6 @@ function areBlockIdsEqual( return true; } -function resolvePenFocusAction( - reason: FieldEditorFocusReason, -): PenFocusAction { - switch (reason) { - case "backend-attach": - case "backend-activate": - return "attach-backend"; - case "selection-project": - case "selection-activate": - case "selection-sync": - return "project-selection"; - case "restore": - return "restore"; - case "select-all": - return "select-all"; - case "activate": - case "cell": - return "activate"; - } -} - -function resolvePenFocusReason( - reason: FieldEditorFocusReason, -): PenFocusReason { - switch (reason) { - case "backend-attach": - case "backend-activate": - return "backend"; - case "selection-project": - case "selection-activate": - case "selection-sync": - return "selection-sync"; - case "select-all": - case "cell": - return "keyboard"; - case "activate": - case "restore": - return "programmatic"; - } -} - function getFullDocumentTextRange(editor: Editor): { start: { blockId: string; offset: number }; end: { blockId: string; offset: number }; diff --git a/packages/rendering/dom/src/field-editor/focusController.ts b/packages/rendering/dom/src/field-editor/focusController.ts new file mode 100644 index 0000000..11aa42a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/focusController.ts @@ -0,0 +1,289 @@ +import type { Editor, Unsubscribe } from "@pen/types"; +import type { + FieldEditorFocusReason, + FieldEditorFocusRequest, + PenFieldEditorFocusOptions, + PenFocusAction, + PenFocusDecision, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusReason, +} from "./controller"; +import { queryBlockElement } from "./selectionBridge"; + +type FocusControllerOptions = { + editor: Editor; + getRootElement: () => HTMLElement | null; + getFocusBlockId: () => string | null; + getAttachedElement: () => HTMLElement | null; +}; + +type AttachmentWaiter = { + check: () => void; + resolve: (attached: boolean) => void; + done: boolean; +}; + +const ALLOW_FOCUS_DECISION: PenFocusDecision = { type: "allow" }; + +export class FocusController { + private readonly _editor: Editor; + private readonly _getRootElement: () => HTMLElement | null; + private readonly _getFocusBlockId: () => string | null; + private readonly _getAttachedElement: () => HTMLElement | null; + private _focusPolicy: PenFocusPolicy | undefined; + private readonly _focusLifecycleListeners = + new Set(); + private readonly _attachmentWaiters = new Set(); + + constructor(options: FocusControllerOptions) { + this._editor = options.editor; + this._getRootElement = options.getRootElement; + this._getFocusBlockId = options.getFocusBlockId; + this._getAttachedElement = options.getAttachedElement; + } + + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { + this._focusPolicy = focusPolicy; + } + + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions: PenFieldEditorFocusOptions = {}, + ): boolean { + const request = this._createFocusRequest(target, reason, policyOptions); + const decision = this._decideFocus(request); + if (decision.type === "deny") { + this._emitFocusDenied(request); + return false; + } + if (decision.type === "allow" && !request.passive) { + target.focus(options); + } + return true; + } + + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + const request = this._createFocusRequest(target, reason, options); + const decision = this._decideFocus(request); + if (decision.type === "deny") { + this._emitFocusDenied(request); + return false; + } + return true; + } + + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean { + return this.requestDomFocus(target, reason, options); + } + + blur(): void { + const root = this._getRootElement(); + if (!root) return; + const activeEl = root.ownerDocument?.activeElement; + if (activeEl instanceof HTMLElement && root.contains(activeEl)) { + activeEl.blur(); + } + } + + restoreFocusAfterDeactivate(blockId: string | null): void { + const root = this._getRootElement(); + if (!root) return; + + if (blockId) { + const blockEl = queryBlockElement(root, blockId); + if (blockEl) { + this.requestDomFocus(blockEl, "restore", { + preventScroll: true, + }); + return; + } + } + + this.requestDomFocus(root, "restore", { preventScroll: true }); + } + + attachedElementOwnsFocus(): boolean { + const attachedElement = this._getAttachedElement(); + if (!attachedElement) { + return false; + } + const activeElement = attachedElement.ownerDocument?.activeElement; + return activeElement instanceof Node + ? attachedElement.contains(activeElement) + : false; + } + + notifyRootAttached(root: HTMLElement): void { + this.emitLifecycle({ + type: "field-editor-attached", + editor: this._editor, + root, + }); + } + + resolveAttachmentWaiters(): void { + for (const waiter of this._attachmentWaiters) { + waiter.check(); + } + } + + waitForAttachment( + blockId: string | null = this._getFocusBlockId(), + ): Promise { + const isAttached = () => { + const attachedElement = this._getAttachedElement(); + return ( + attachedElement?.isConnected === true && + (blockId == null || this._getFocusBlockId() === blockId) + ); + }; + + if (isAttached()) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + let frame = 0; + const complete = (waiter: AttachmentWaiter, attached: boolean) => { + if (waiter.done) { + return; + } + waiter.done = true; + this._attachmentWaiters.delete(waiter); + waiter.resolve(attached); + }; + const waiter: AttachmentWaiter = { + check: () => { + if (waiter.done) { + return; + } + if (isAttached()) { + complete(waiter, true); + return; + } + if (frame >= 4) { + complete(waiter, false); + return; + } + frame += 1; + requestAnimationFrame(waiter.check); + }, + resolve, + done: false, + }; + const check = () => { + waiter.check(); + }; + this._attachmentWaiters.add(waiter); + requestAnimationFrame(check); + }); + } + + onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { + this._focusLifecycleListeners.add(listener); + return () => this._focusLifecycleListeners.delete(listener); + } + + emitLifecycle(event: PenFocusLifecycleEvent): void { + for (const listener of this._focusLifecycleListeners) { + listener(event); + } + } + + destroy(): void { + for (const waiter of this._attachmentWaiters) { + if (!waiter.done) { + waiter.done = true; + waiter.resolve(false); + } + } + this._attachmentWaiters.clear(); + this._focusLifecycleListeners.clear(); + } + + private _createFocusRequest( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): FieldEditorFocusRequest { + return { + editor: this._editor, + target, + root: this._getRootElement(), + reason, + action: resolvePenFocusAction(reason), + source: options.reason ?? resolvePenFocusReason(reason), + blockId: this._getFocusBlockId(), + passive: options.passive ?? options.domFocus === false, + }; + } + + private _decideFocus(request: FieldEditorFocusRequest): PenFocusDecision { + const policyDecision = this._focusPolicy?.decide(request); + if (policyDecision) { + return request.passive && policyDecision.type === "allow" + ? { type: "allow-passive" } + : policyDecision; + } + + return request.passive + ? { type: "allow-passive" } + : ALLOW_FOCUS_DECISION; + } + + private _emitFocusDenied(request: FieldEditorFocusRequest): void { + this._focusPolicy?.onDenied?.(request); + this.emitLifecycle({ + type: "focus-request-denied", + request, + }); + } +} + +function resolvePenFocusAction(reason: FieldEditorFocusReason): PenFocusAction { + switch (reason) { + case "backend-attach": + case "backend-activate": + return "attach-backend"; + case "selection-project": + case "selection-activate": + case "selection-sync": + return "project-selection"; + case "restore": + return "restore"; + case "select-all": + return "select-all"; + case "activate": + case "cell": + return "activate"; + } +} + +function resolvePenFocusReason(reason: FieldEditorFocusReason): PenFocusReason { + switch (reason) { + case "backend-attach": + case "backend-activate": + return "backend"; + case "selection-project": + case "selection-activate": + case "selection-sync": + return "selection-sync"; + case "select-all": + case "cell": + return "keyboard"; + case "activate": + case "restore": + return "programmatic"; + } +} diff --git a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts index ea5e7d2..7afcbeb 100644 --- a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts +++ b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts @@ -1,12 +1,19 @@ import type { SchemaRegistry } from "@pen/types"; import { DATA_ATTRS } from "../utils/dataAttributes"; - -export const INLINE_ATOM_REPLACEMENT_TEXT = "\uFFFC"; - -interface InlineAtomInsert { - type: string; - props: Record; -} +import { + INLINE_ATOM_CARET_BOUNDARY_TEXT, + INLINE_ATOM_REPLACEMENT_TEXT, + resolveInlineAtomDisplayText, + resolveInlineAtomInsert, + type InlineAtomInsert, +} from "./inlineAtomModel"; +export { + INLINE_ATOM_CARET_BOUNDARY_TEXT, + INLINE_ATOM_REPLACEMENT_TEXT, + resolveInlineAtomInsert, +} from "./inlineAtomModel"; + +export type InlineAtomCaretBoundarySide = "before" | "after"; export interface InlineAtomElementData extends InlineAtomInsert { text: string; @@ -14,37 +21,19 @@ export interface InlineAtomElementData extends InlineAtomInsert { const inlineAtomElementData = new WeakMap(); -export function resolveInlineAtomInsert( - insert: unknown, -): InlineAtomInsert | null { - if (!insert || typeof insert !== "object") { - return null; - } - - const record = insert as Record; - const type = typeof record.type === "string" ? record.type : ""; - if (!type) { - return null; - } - - if (record.props && typeof record.props === "object") { - return { - type, - props: record.props as Record, - }; - } - - const props: Record = {}; - for (const [key, value] of Object.entries(record)) { - if (key !== "type") { - props[key] = value; - } - } - - return { type, props }; +export function createInlineAtomCaretBoundaryElement( + side: InlineAtomCaretBoundarySide, +): HTMLElement { + const element = document.createElement("span"); + element.setAttribute(DATA_ATTRS.inlineAtomCaretBoundary, ""); + element.setAttribute(DATA_ATTRS.inlineAtomCaretSide, side); + element.appendChild( + document.createTextNode(INLINE_ATOM_CARET_BOUNDARY_TEXT), + ); + return element; } -export function createInlineAtomElement( +function createInlineAtomChipElement( insert: unknown, registry: SchemaRegistry, ): HTMLElement { @@ -59,7 +48,8 @@ export function createInlineAtomElement( } element.setAttribute(DATA_ATTRS.inlineAtomType, atom.type); - const text = getInlineAtomText(atom, registry); + element.setAttribute(DATA_ATTRS.inlineAtomProps, serializeInlineAtomProps(atom.props)); + const text = resolveInlineAtomDisplayText(atom, registry); element.setAttribute("aria-label", text); element.textContent = text; inlineAtomElementData.set(element, { @@ -69,32 +59,87 @@ export function createInlineAtomElement( return element; } +export function createInlineAtomElement( + insert: unknown, + registry: SchemaRegistry, +): HTMLElement { + const host = document.createElement("span"); + host.setAttribute(DATA_ATTRS.inlineAtomHost, ""); + host.appendChild(createInlineAtomCaretBoundaryElement("before")); + host.appendChild(createInlineAtomChipElement(insert, registry)); + host.appendChild(createInlineAtomCaretBoundaryElement("after")); + return host; +} + export function getInlineAtomElementData( element: Element, ): InlineAtomElementData | null { - return element instanceof HTMLElement - ? (inlineAtomElementData.get(element) ?? null) - : null; + const chip = getInlineAtomChipElement(element); + if (!chip) { + return null; + } + return inlineAtomElementData.get(chip) ?? deserializeInlineAtomElementData(chip); } export function copyInlineAtomElementData( source: Element, target: Element, ): void { - if (!(target instanceof HTMLElement)) { + const sourceChip = getInlineAtomChipElement(source); + const targetChip = getInlineAtomChipElement(target); + if (!sourceChip || !targetChip) { return; } - const data = getInlineAtomElementData(source); + const data = getInlineAtomElementData(sourceChip); if (!data) { return; } - inlineAtomElementData.set(target, { + inlineAtomElementData.set(targetChip, { type: data.type, props: { ...data.props }, text: data.text, }); + targetChip.setAttribute(DATA_ATTRS.inlineAtomType, data.type); + targetChip.setAttribute(DATA_ATTRS.inlineAtomProps, serializeInlineAtomProps(data.props)); + targetChip.setAttribute("aria-label", data.text); +} + +function serializeInlineAtomProps(props: Record): string { + try { + return JSON.stringify(props); + } catch { + return "{}"; + } +} + +function deserializeInlineAtomElementData( + element: HTMLElement, +): InlineAtomElementData | null { + const type = element.getAttribute(DATA_ATTRS.inlineAtomType); + if (!type) { + return null; + } + return { + type, + props: parseInlineAtomProps(element.getAttribute(DATA_ATTRS.inlineAtomProps)), + text: element.getAttribute("aria-label") ?? element.textContent ?? "", + }; +} + +function parseInlineAtomProps(value: string | null): Record { + if (!value) { + return {}; + } + try { + const props = JSON.parse(value); + return props && typeof props === "object" && !Array.isArray(props) + ? (props as Record) + : {}; + } catch { + return {}; + } } export function areInlineAtomElementDataEqual( @@ -114,12 +159,34 @@ export function areInlineAtomElementDataEqual( ); } -export function isInlineAtomNode(node: Node | null): node is HTMLElement { +export function isInlineAtomCaretBoundaryNode( + node: Node | null, +): node is HTMLElement { return ( - node instanceof HTMLElement && node.hasAttribute(DATA_ATTRS.inlineAtom) + node instanceof HTMLElement && + node.hasAttribute(DATA_ATTRS.inlineAtomCaretBoundary) ); } +export function isInlineAtomHostNode(node: Node | null): node is HTMLElement { + return ( + node instanceof HTMLElement && + node.hasAttribute(DATA_ATTRS.inlineAtomHost) + ); +} + +export function isInlineAtomChipNode(node: Node | null): node is HTMLElement { + return ( + node instanceof HTMLElement && + node.hasAttribute(DATA_ATTRS.inlineAtom) && + !isInlineAtomHostNode(node) + ); +} + +export function isInlineAtomNode(node: Node | null): node is HTMLElement { + return isInlineAtomHostNode(node) || isInlineAtomChipNode(node); +} + function shallowEqualRecords( left: Record, right: Record, @@ -137,15 +204,103 @@ function shallowEqualRecords( return leftKeys.every((key) => Object.is(left[key], right[key])); } +function getInlineAtomChipElement(element: Element): HTMLElement | null { + if (element instanceof HTMLElement && isInlineAtomChipNode(element)) { + return element; + } + + if (element instanceof HTMLElement && isInlineAtomHostNode(element)) { + for (const child of Array.from(element.childNodes)) { + if (isInlineAtomChipNode(child)) { + return child; + } + } + } + + return null; +} + +function getInlineAtomHostElement(node: Node): HTMLElement | null { + if (node instanceof HTMLElement && isInlineAtomHostNode(node)) { + return node; + } + + if (node instanceof HTMLElement && isInlineAtomChipNode(node)) { + const parent = node.parentElement; + return parent && isInlineAtomHostNode(parent) ? parent : null; + } + + if (isInlineAtomCaretBoundaryNode(node)) { + const parent = node.parentElement; + return parent && isInlineAtomHostNode(parent) ? parent : null; + } + + return null; +} + +function getInlineAtomCaretBoundaryElement( + host: HTMLElement, + side: InlineAtomCaretBoundarySide, +): HTMLElement | null { + for (const child of Array.from(host.childNodes)) { + if ( + isInlineAtomCaretBoundaryNode(child) && + child.getAttribute(DATA_ATTRS.inlineAtomCaretSide) === side + ) { + return child; + } + } + return null; +} + +function getInlineAtomCaretBoundaryTextPoint( + host: HTMLElement, + side: InlineAtomCaretBoundarySide, +): { node: Node; offset: number } | null { + const boundary = getInlineAtomCaretBoundaryElement(host, side); + if (!boundary) { + return null; + } + + const textNode = boundary.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + return { + node: textNode, + offset: side === "before" ? 0 : (textNode.textContent?.length ?? 0), + }; +} + +function resolveLogicalInlineAtomUnit(node: HTMLElement): HTMLElement { + const host = getInlineAtomHostElement(node); + if (host) { + return host; + } + return node; +} + export function getLogicalNodeLength(node: Node): number { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent?.length ?? 0; + if ( + isInlineAtomCaretBoundaryNode(node) || + hasInlineAtomCaretBoundaryAncestor(node) + ) { + return 0; } - if (isInlineAtomNode(node)) { + if (isInlineAtomHostNode(node)) { return 1; } + if (isInlineAtomChipNode(node)) { + return getInlineAtomHostElement(node) ? 0 : 1; + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length ?? 0; + } + let length = 0; for (const child of Array.from(node.childNodes)) { length += getLogicalNodeLength(child); @@ -197,7 +352,8 @@ export function getInlineAtomPointerOffset( continue; } - const atomOffset = getOffsetBeforeNode(container, atomElement); + const logicalAtom = resolveLogicalInlineAtomUnit(atomElement); + const atomOffset = getOffsetBeforeNode(container, logicalAtom); bestOffset = clientX <= rect.left + rect.width / 2 ? atomOffset : atomOffset + 1; bestScore = score; @@ -211,10 +367,26 @@ export function domPointToLogicalOffset( targetNode: Node, targetOffset: number, ): number { + const boundaryAncestor = findInlineAtomCaretBoundaryAncestor( + targetNode, + container, + ); + if (boundaryAncestor) { + const side = boundaryAncestor.getAttribute( + DATA_ATTRS.inlineAtomCaretSide, + ) as InlineAtomCaretBoundarySide | null; + const host = getInlineAtomHostElement(boundaryAncestor); + if (host && (side === "before" || side === "after")) { + const hostOffset = getOffsetBeforeNode(container, host); + return side === "before" ? hostOffset : hostOffset + 1; + } + } + const atomAncestor = findInlineAtomAncestor(targetNode, container); if (atomAncestor) { - const atomOffset = getOffsetBeforeNode(container, atomAncestor); - if (atomAncestor === targetNode) { + const logicalAtom = resolveLogicalInlineAtomUnit(atomAncestor); + const atomOffset = getOffsetBeforeNode(container, logicalAtom); + if (logicalAtom === targetNode || isInlineAtomChipNode(atomAncestor)) { return targetOffset <= 0 ? atomOffset : atomOffset + 1; } return atomOffset + 1; @@ -231,44 +403,28 @@ export function findLogicalDOMPoint( return findLogicalDOMPointInElement(container, Math.max(0, offset)); } -function getInlineAtomText( - atom: InlineAtomInsert, - registry: SchemaRegistry, -): string { - const schemaText = registry - .resolveInline(atom.type) - ?.serialize.toMarkdown?.("", atom.props); - if (schemaText) { - return schemaText; - } - - const label = atom.props.label; - if (typeof label === "string" && label.length > 0) { - return label; +function getLogicalNodeText(node: Node): string { + if ( + isInlineAtomCaretBoundaryNode(node) || + hasInlineAtomCaretBoundaryAncestor(node) + ) { + return ""; } - const name = atom.props.name; - if (typeof name === "string" && name.length > 0) { - return name; + if (isInlineAtomHostNode(node)) { + return INLINE_ATOM_REPLACEMENT_TEXT; } - const id = atom.props.id; - if (typeof id === "string" && id.length > 0) { - return id; + if (isInlineAtomChipNode(node)) { + return getInlineAtomHostElement(node) + ? "" + : INLINE_ATOM_REPLACEMENT_TEXT; } - return atom.type; -} - -function getLogicalNodeText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return node.textContent ?? ""; } - if (isInlineAtomNode(node)) { - return INLINE_ATOM_REPLACEMENT_TEXT; - } - let text = ""; for (const child of Array.from(node.childNodes)) { text += getLogicalNodeText(child); @@ -276,6 +432,34 @@ function getLogicalNodeText(node: Node): string { return text; } +function findInlineAtomCaretBoundaryAncestor( + node: Node, + container: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== container) { + if (isInlineAtomCaretBoundaryNode(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function hasInlineAtomCaretBoundaryAncestor(node: Node): boolean { + let current: Node | null = node.parentNode; + while (current) { + if (isInlineAtomCaretBoundaryNode(current)) { + return true; + } + if (isInlineAtomHostNode(current)) { + return false; + } + current = current.parentNode; + } + return false; +} + function findInlineAtomAncestor( node: Node, container: HTMLElement, @@ -324,10 +508,22 @@ function resolveLogicalOffset( targetOffset: number, ): number | null { if (current === targetNode) { - if (isInlineAtomNode(current)) { + if (isInlineAtomHostNode(current)) { return targetOffset <= 0 ? 0 : 1; } + if (isInlineAtomChipNode(current)) { + return getInlineAtomHostElement(current) + ? null + : targetOffset <= 0 + ? 0 + : 1; + } + + if (isInlineAtomCaretBoundaryNode(current)) { + return 0; + } + if (current.nodeType === Node.TEXT_NODE) { return Math.min(targetOffset, current.textContent?.length ?? 0); } @@ -344,7 +540,12 @@ function resolveLogicalOffset( return offset; } - if (current.nodeType === Node.TEXT_NODE || isInlineAtomNode(current)) { + if ( + current.nodeType === Node.TEXT_NODE || + isInlineAtomHostNode(current) || + isInlineAtomChipNode(current) || + isInlineAtomCaretBoundaryNode(current) + ) { return null; } @@ -376,6 +577,15 @@ function findLogicalDOMPointInElement( const length = getLogicalNodeLength(child); if (remaining === 0) { + if (isInlineAtomHostNode(child)) { + const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( + child, + "before", + ); + if (boundaryPoint) { + return boundaryPoint; + } + } return { node: element, offset: index }; } @@ -387,7 +597,22 @@ function findLogicalDOMPointInElement( continue; } - if (isInlineAtomNode(child)) { + if (isInlineAtomHostNode(child)) { + if (remaining <= 1) { + const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( + child, + remaining === 0 ? "before" : "after", + ); + if (boundaryPoint) { + return boundaryPoint; + } + return { node: element, offset: index + 1 }; + } + remaining -= 1; + continue; + } + + if (isInlineAtomChipNode(child)) { if (remaining <= 1) { return { node: element, offset: index + 1 }; } @@ -395,6 +620,10 @@ function findLogicalDOMPointInElement( continue; } + if (isInlineAtomCaretBoundaryNode(child)) { + continue; + } + if (remaining <= length && child instanceof HTMLElement) { return findLogicalDOMPointInElement(child, remaining); } diff --git a/packages/rendering/dom/src/field-editor/inlineAtomModel.ts b/packages/rendering/dom/src/field-editor/inlineAtomModel.ts new file mode 100644 index 0000000..319d7ae --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomModel.ts @@ -0,0 +1,170 @@ +import type { + Editor, + InlineDelta, + InlineNodeDeltaInsert, + SchemaRegistry, +} from "@pen/types"; + +export const INLINE_ATOM_LOGICAL_LENGTH = 1; +export const INLINE_ATOM_REPLACEMENT_TEXT = "\uFFFC"; +export const INLINE_ATOM_CARET_BOUNDARY_TEXT = "\u200B"; + +const ZERO_WIDTH_SPACE = "\u200B"; + +export interface InlineAtomInsert { + type: string; + props: Record; +} + +export interface InlineAtomSnapshot extends InlineAtomInsert { + blockId: string; + offset: number; + text: string; +} + +export interface InlineAtomRange { + start: number; + end: number; +} + +export function resolveInlineAtomInsert( + insert: unknown, +): InlineAtomInsert | null { + if (!insert || typeof insert !== "object") { + return null; + } + + const record = insert as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (!type) { + return null; + } + + if (record.props && typeof record.props === "object") { + return { + type, + props: record.props as Record, + }; + } + + const props: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key !== "type") { + props[key] = value; + } + } + + return { type, props }; +} + +export function resolveInlineAtomDisplayText( + atom: InlineAtomInsert, + registry: Pick, +): string { + const schemaText = registry + .resolveInline(atom.type) + ?.serialize.toMarkdown?.("", atom.props); + if (schemaText) { + return schemaText; + } + + const label = atom.props.label; + if (typeof label === "string" && label.length > 0) { + return label; + } + + const name = atom.props.name; + if (typeof name === "string" && name.length > 0) { + return name; + } + + const id = atom.props.id; + if (typeof id === "string" && id.length > 0) { + return id; + } + + return atom.type; +} + +export function getInlineDeltaLength(delta: InlineDelta): number { + return typeof delta.insert === "string" + ? delta.insert + .replaceAll(ZERO_WIDTH_SPACE, "") + .replaceAll(INLINE_ATOM_REPLACEMENT_TEXT, "").length + : INLINE_ATOM_LOGICAL_LENGTH; +} + +export function getInlineAtomAtOffset( + editor: Editor, + source: { blockId: string; offset: number }, +): InlineAtomSnapshot | null { + const block = editor.getBlock(source.blockId); + if (!block) { + return null; + } + + let offset = 0; + for (const delta of block.inlineDeltas()) { + const length = getInlineDeltaLength(delta); + if (offset === source.offset && typeof delta.insert !== "string") { + return { + blockId: source.blockId, + offset: source.offset, + type: delta.insert.type, + props: { ...delta.insert.props }, + text: resolveInlineAtomDisplayText(delta.insert, editor.schema), + }; + } + + offset += length; + } + + return null; +} + +export function isInlineAtomRange( + ytext: { toDelta(): Array<{ insert?: string | Record }> }, + start: number, + end: number, +): boolean { + const atomRange = getInlineAtomRangeAtOffset(ytext, start); + return atomRange?.end === end; +} + +export function getInlineAtomRangeAtOffset( + ytext: { toDelta(): Array<{ insert?: string | Record }> }, + targetOffset: number, +): InlineAtomRange | null { + if (targetOffset < 0) { + return null; + } + + let offset = 0; + for (const delta of ytext.toDelta()) { + if (delta.insert == null) { + continue; + } + + if (typeof delta.insert === "string") { + offset += delta.insert.length; + continue; + } + + if (offset === targetOffset) { + return { + start: offset, + end: offset + INLINE_ATOM_LOGICAL_LENGTH, + }; + } + offset += INLINE_ATOM_LOGICAL_LENGTH; + } + + return null; +} + +export function getInlineAtomInsertText( + registry: Pick, + atom: InlineNodeDeltaInsert, +): string { + return resolveInlineAtomDisplayText(atom, registry); +} diff --git a/packages/rendering/dom/src/field-editor/inlineTextTransaction.ts b/packages/rendering/dom/src/field-editor/inlineTextTransaction.ts new file mode 100644 index 0000000..27308be --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineTextTransaction.ts @@ -0,0 +1,148 @@ +import type { DocumentOp } from "@pen/types"; +import type { ActiveCellCoord } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; + +export type InlineTextRange = { + start: number; + end: number; +}; + +export type InlineTextDiffOp = + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number }; + +export type InlineTextSelectionTarget = { + blockId: string; + anchorOffset: number; + focusOffset: number; + cell?: { + row: number; + col: number; + }; +}; + +export function buildInlineTextEditTransaction(options: { + blockId: string; + range: InlineTextRange; + text: string; + marks?: Record; + cellCoord?: ActiveCellCoord | null; +}): { + ops: DocumentOp[]; + selection: InlineTextSelectionTarget; +} { + const { blockId, range, text, marks, cellCoord } = options; + const ops: DocumentOp[] = []; + const nextOffset = range.start + text.length; + + if (range.end > range.start) { + ops.push( + cellCoord + ? { + type: "delete-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: range.start, + length: range.end - range.start, + } + : { + type: "delete-text", + blockId, + offset: range.start, + length: range.end - range.start, + }, + ); + } + + if (text.length > 0) { + ops.push( + cellCoord + ? { + type: "insert-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: range.start, + text, + } + : { + type: "insert-text", + blockId, + offset: range.start, + text, + marks, + }, + ); + } + + return { + ops, + selection: { + blockId, + anchorOffset: nextOffset, + focusOffset: nextOffset, + cell: cellCoord + ? { row: cellCoord.row, col: cellCoord.col } + : undefined, + }, + }; +} + +export function buildInlineTextDiffOps(options: { + blockId: string; + diff: readonly InlineTextDiffOp[]; + ytext: FieldEditorTextLike; + resolveInsertMarks: ( + ytext: FieldEditorTextLike, + offset: number, + ) => Record | undefined; + cellCoord?: ActiveCellCoord | null; +}): DocumentOp[] { + const { blockId, diff, ytext, resolveInsertMarks, cellCoord } = options; + const ops: DocumentOp[] = []; + + for (const op of diff) { + if (op.type === "delete") { + ops.push( + cellCoord + ? { + type: "delete-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: op.offset, + length: op.length, + } + : { + type: "delete-text", + blockId, + offset: op.offset, + length: op.length, + }, + ); + continue; + } + + ops.push( + cellCoord + ? { + type: "insert-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: op.offset, + text: op.text, + } + : { + type: "insert-text", + blockId, + offset: op.offset, + text: op.text, + marks: resolveInsertMarks(ytext, op.offset), + }, + ); + } + + return ops; +} diff --git a/packages/rendering/dom/src/field-editor/keyHandling.ts b/packages/rendering/dom/src/field-editor/keyHandling.ts index b65be55..98ae69f 100644 --- a/packages/rendering/dom/src/field-editor/keyHandling.ts +++ b/packages/rendering/dom/src/field-editor/keyHandling.ts @@ -13,6 +13,10 @@ import { normalizeInlineRange, type SelectionRange, } from "./commands"; +import { + getInlineAtomRangeAtOffset, + isInlineAtomRange, +} from "./inlineAtomModel"; import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; import { getAutocompleteController } from "../utils/autocompleteController"; @@ -211,7 +215,6 @@ export function handleFieldEditorKeyDown(options: { if ( (event.key === "ArrowLeft" || event.key === "ArrowUp") && - !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey @@ -220,6 +223,7 @@ export function handleFieldEditorKeyDown(options: { event.key === "ArrowLeft" && selectInlineAtomWithArrowKey({ blockId, + editor, event, fieldEditor, range, @@ -229,6 +233,10 @@ export function handleFieldEditorKeyDown(options: { return true; } + if (event.shiftKey) { + return false; + } + const target = moveCaretAcrossBlocks(editor, { blockId, ytext, @@ -252,7 +260,6 @@ export function handleFieldEditorKeyDown(options: { if ( (event.key === "ArrowRight" || event.key === "ArrowDown") && - !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey @@ -261,6 +268,7 @@ export function handleFieldEditorKeyDown(options: { event.key === "ArrowRight" && selectInlineAtomWithArrowKey({ blockId, + editor, event, fieldEditor, range, @@ -270,6 +278,10 @@ export function handleFieldEditorKeyDown(options: { return true; } + if (event.shiftKey) { + return false; + } + const target = moveCaretAcrossBlocks(editor, { blockId, ytext, @@ -296,6 +308,7 @@ export function handleFieldEditorKeyDown(options: { function selectInlineAtomWithArrowKey(options: { blockId: string; + editor: Editor; event: KeyboardEvent; fieldEditor: FieldEditorKeyboardController; range: SelectionRange | null; @@ -305,13 +318,24 @@ function selectInlineAtomWithArrowKey(options: { toDelta(): Array<{ insert?: string | Record }>; }; }): boolean { - const { blockId, event, fieldEditor, ytext } = options; + const { blockId, editor, event, fieldEditor, ytext } = options; const range = normalizeInlineRange(ytext, options.range); if (!range) { return false; } const direction = event.key === "ArrowLeft" ? "previous" : "next"; + if (event.shiftKey) { + return extendInlineAtomSelectionWithArrowKey({ + blockId, + direction, + editor, + fieldEditor, + range, + ytext, + }); + } + if (range.start !== range.end) { if (!isInlineAtomRange(ytext, range.start, range.end)) { return false; @@ -331,41 +355,73 @@ function selectInlineAtomWithArrowKey(options: { return true; } -function isInlineAtomRange( - ytext: { toDelta(): Array<{ insert?: string | Record }> }, - start: number, - end: number, -): boolean { - const atomRange = getInlineAtomRangeAtOffset(ytext, start); - return atomRange?.end === end; -} - -function getInlineAtomRangeAtOffset( - ytext: { toDelta(): Array<{ insert?: string | Record }> }, - targetOffset: number, -): SelectionRange | null { - if (targetOffset < 0) { - return null; - } - - let offset = 0; - for (const delta of ytext.toDelta()) { - if (delta.insert == null) { - continue; +function extendInlineAtomSelectionWithArrowKey(options: { + blockId: string; + direction: "previous" | "next"; + editor: Editor; + fieldEditor: FieldEditorKeyboardController; + range: SelectionRange; + ytext: { + toDelta(): Array<{ insert?: string | Record }>; + }; +}): boolean { + const { blockId, direction, editor, fieldEditor, range, ytext } = options; + const selection = editor.selection; + if ( + selection?.type === "text" && + !selection.isCollapsed && + !selection.isMultiBlock && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ) { + const focusAtomOffset = + direction === "previous" + ? selection.focus.offset - 1 + : selection.focus.offset; + const focusAtomRange = getInlineAtomRangeAtOffset( + ytext, + focusAtomOffset, + ); + if (focusAtomRange) { + const nextFocusOffset = + direction === "previous" + ? focusAtomRange.start + : focusAtomRange.end; + fieldEditor.activateTextSelection( + blockId, + selection.anchor.offset, + nextFocusOffset, + ); + return true; } + } - if (typeof delta.insert === "string") { - offset += delta.insert.length; - continue; + if (range.start === range.end) { + const atomOffset = + direction === "previous" ? range.start - 1 : range.end; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; } + const anchorOffset = + direction === "previous" ? atomRange.end : atomRange.start; + const focusOffset = + direction === "previous" ? atomRange.start : atomRange.end; + fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); + return true; + } - if (offset === targetOffset) { - return { start: offset, end: offset + 1 }; - } - offset += 1; + const atomOffset = direction === "previous" ? range.start - 1 : range.end; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; } - return null; + const anchorOffset = + direction === "previous" ? atomRange.start : range.start; + const focusOffset = direction === "previous" ? range.end : atomRange.end; + fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); + return true; } function syncAcceptedInlineCompletionSelection( diff --git a/packages/rendering/dom/src/field-editor/pendingMarkController.ts b/packages/rendering/dom/src/field-editor/pendingMarkController.ts new file mode 100644 index 0000000..8e2ce20 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/pendingMarkController.ts @@ -0,0 +1,112 @@ +import type { Editor } from "@pen/types"; +import { resolveMarksAtPosition } from "./markBoundary"; +import type { FieldEditorTextLike } from "./crdt"; + +type PendingMarkControllerOptions = { + editor: Editor; + getFocusBlockId: () => string | null; + getYText: (blockId: string) => FieldEditorTextLike | null; + emitStateChange: () => void; +}; + +export class PendingMarkController { + private readonly editor: Editor; + private readonly getFocusBlockId: () => string | null; + private readonly getYText: (blockId: string) => FieldEditorTextLike | null; + private readonly emitStateChange: () => void; + private pendingMarks: Record = {}; + + constructor(options: PendingMarkControllerOptions) { + this.editor = options.editor; + this.getFocusBlockId = options.getFocusBlockId; + this.getYText = options.getYText; + this.emitStateChange = options.emitStateChange; + } + + getSnapshot(): Readonly> { + return this.pendingMarks; + } + + reset(): void { + this.pendingMarks = {}; + } + + clear(silent = false): void { + if (Object.keys(this.pendingMarks).length === 0) return; + this.pendingMarks = {}; + if (!silent) { + this.emitStateChange(); + } + } + + toggle(markType: string, isEditing: boolean, inputMode: string): boolean { + if (!isEditing || inputMode !== "richtext") return false; + + const baseMarks = this.resolveBaseInsertMarks(); + const baseValue = baseMarks[markType]; + const effectiveMarks = this.applyPendingMarks(baseMarks); + const nextValue = effectiveMarks[markType] != null ? null : true; + const nextPendingMarks = { ...this.pendingMarks }; + + if ((baseValue ?? null) === nextValue) { + delete nextPendingMarks[markType]; + } else { + nextPendingMarks[markType] = nextValue; + } + + this.pendingMarks = nextPendingMarks; + this.emitStateChange(); + return true; + } + + resolveInsertMarks( + ytext: FieldEditorTextLike, + offset: number, + ): Record | undefined { + const baseMarks = + resolveMarksAtPosition(ytext, offset, this.editor.schema) ?? {}; + const resolved = this.applyPendingMarks(baseMarks); + const insertMarks: Record = { ...resolved }; + + for (const [markType, value] of Object.entries(this.pendingMarks)) { + if (value == null && markType in baseMarks) { + insertMarks[markType] = null; + } + } + + return Object.keys(insertMarks).length > 0 ? insertMarks : undefined; + } + + private resolveBaseInsertMarks(): Record { + const selection = this.editor.selection; + if (!this.getFocusBlockId() || selection?.type !== "text") { + return {}; + } + + const blockId = selection.focus.blockId; + const ytext = this.getYText(blockId); + if (!ytext) return {}; + + return ( + resolveMarksAtPosition( + ytext, + selection.focus.offset, + this.editor.schema, + ) ?? {} + ); + } + + private applyPendingMarks( + baseMarks: Record, + ): Record { + const nextMarks = { ...baseMarks }; + for (const [markType, value] of Object.entries(this.pendingMarks)) { + if (value == null) { + delete nextMarks[markType]; + } else { + nextMarks[markType] = value; + } + } + return nextMarks; + } +} diff --git a/packages/rendering/dom/src/field-editor/reconciler.ts b/packages/rendering/dom/src/field-editor/reconciler.ts index d4aea25..c5ff866 100644 --- a/packages/rendering/dom/src/field-editor/reconciler.ts +++ b/packages/rendering/dom/src/field-editor/reconciler.ts @@ -12,6 +12,8 @@ import { domPointToLogicalOffset, findLogicalDOMPoint, getLogicalNodeLength, + isInlineAtomChipNode, + isInlineAtomHostNode, isInlineAtomNode, } from "./inlineAtomDom"; @@ -376,6 +378,11 @@ function patchDOM(target: HTMLElement, source: DocumentFragment): void { if (nodesStructurallyEqual(targetNode, sourceNode)) { if ( + isInlineAtomHostNode(targetNode) && + isInlineAtomHostNode(sourceNode) + ) { + copyInlineAtomElementData(sourceNode, targetNode); + } else if ( isInlineAtomNode(targetNode) && isInlineAtomNode(sourceNode) ) { @@ -406,7 +413,14 @@ function nodesStructurallyEqual(a: Node, b: Node): boolean { if (a.nodeType === Node.ELEMENT_NODE) { const elA = a as Element; const elB = b as Element; - if (isInlineAtomNode(elA) || isInlineAtomNode(elB)) { + if (isInlineAtomHostNode(elA) || isInlineAtomHostNode(elB)) { + if (!isInlineAtomHostNode(elA) || !isInlineAtomHostNode(elB)) { + return false; + } + if (!areInlineAtomElementDataEqual(elA, elB)) { + return false; + } + } else if (isInlineAtomNode(elA) || isInlineAtomNode(elB)) { if (!isInlineAtomNode(elA) || !isInlineAtomNode(elB)) { return false; } @@ -444,12 +458,36 @@ function updateTextContent(target: Node, source: Node): void { target.nodeType === Node.ELEMENT_NODE && source.nodeType === Node.ELEMENT_NODE ) { + if (isInlineAtomHostNode(target) && isInlineAtomHostNode(source)) { + updateInlineAtomHostTextContent(target, source); + return; + } for (let i = 0; i < target.childNodes.length; i++) { updateTextContent(target.childNodes[i], source.childNodes[i]); } } } +function updateInlineAtomHostTextContent(target: Node, source: Node): void { + for (let i = 0; i < target.childNodes.length; i += 1) { + const targetChild = target.childNodes[i]; + const sourceChild = source.childNodes[i]; + if (!sourceChild) { + continue; + } + if ( + isInlineAtomChipNode(targetChild) && + isInlineAtomChipNode(sourceChild) + ) { + if (targetChild.textContent !== sourceChild.textContent) { + targetChild.textContent = sourceChild.textContent; + } + continue; + } + updateTextContent(targetChild, sourceChild); + } +} + // ── Selection save/restore ───────────────────────────────── export interface SavedSelection { diff --git a/packages/rendering/dom/src/field-editor/selectAllController.ts b/packages/rendering/dom/src/field-editor/selectAllController.ts new file mode 100644 index 0000000..4eebaf9 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectAllController.ts @@ -0,0 +1,69 @@ +import type { SelectionState } from "@pen/types"; +import { + resolveSelectAllBehavior, + type EditorSelectAllBehavior, +} from "../constants/selectAll"; + +export type SelectAllScope = "cell" | "block" | "document"; + +type SelectAllCycle = { + blockId: string; + scope: SelectAllScope; +}; + +export class SelectAllController { + private behavior: EditorSelectAllBehavior; + private cycle: SelectAllCycle | null = null; + private preserveCycle = false; + + constructor(behavior?: EditorSelectAllBehavior) { + this.behavior = behavior ?? resolveSelectAllBehavior("content-first"); + } + + getBehavior(): EditorSelectAllBehavior { + return this.behavior; + } + + setBehavior(behavior: EditorSelectAllBehavior): void { + if (this.behavior === behavior) { + return; + } + this.behavior = behavior; + this.resetCycle(); + } + + recordScope(blockId: string, scope: SelectAllScope): void { + this.preserveCycle = true; + this.cycle = { blockId, scope }; + } + + resetCycle(): void { + this.preserveCycle = false; + this.cycle = null; + } + + consumeShouldPreserveCycle( + selection: SelectionState | null, + matchesSelection: ( + cycle: SelectAllCycle, + selection: SelectionState | null, + ) => boolean, + ): boolean { + const preserve = + this.preserveCycle || + (this.cycle ? matchesSelection(this.cycle, selection) : false); + this.preserveCycle = false; + if (!preserve) { + this.cycle = null; + } + return preserve; + } + + hasScope(blockId: string | null | undefined, scope: SelectAllScope): boolean { + return ( + this.cycle != null && + this.cycle.blockId === blockId && + this.cycle.scope === scope + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/selectionAuthority.ts b/packages/rendering/dom/src/field-editor/selectionAuthority.ts new file mode 100644 index 0000000..683eeb5 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionAuthority.ts @@ -0,0 +1,108 @@ +export type FieldEditorSelectionSource = + | "user-dom" + | "programmatic" + | "edit-context-textupdate" + | "history" + | "composition" + | "cell"; + +export type FieldEditorSelectionCell = { + row: number; + col: number; +}; + +export type FieldEditorSelectionSnapshot = { + blockId: string; + anchorOffset: number; + focusOffset: number; + cell?: FieldEditorSelectionCell; +}; + +const DEFAULT_PRECEDENCE: readonly FieldEditorSelectionSource[] = [ + "programmatic", + "edit-context-textupdate", + "composition", + "cell", + "user-dom", + "history", +]; + +export class FieldEditorSelectionAuthority { + private readonly selections = new Map< + FieldEditorSelectionSource, + FieldEditorSelectionSnapshot + >(); + private applyingSelectionDepth = 0; + + get isApplyingSelection(): number { + return this.applyingSelectionDepth; + } + + set( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + if (selection) { + this.selections.set(source, selection); + return; + } + this.selections.delete(source); + } + + get( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + const selection = this.selections.get(source) ?? null; + if (!selection || (blockId && selection.blockId !== blockId)) { + return null; + } + return selection; + } + + has(source: FieldEditorSelectionSource): boolean { + return this.selections.has(source); + } + + resolve( + blockId: string, + sources: readonly FieldEditorSelectionSource[] = DEFAULT_PRECEDENCE, + ): FieldEditorSelectionSnapshot | null { + for (const source of sources) { + const selection = this.get(source, blockId); + if (selection) { + return selection; + } + } + return null; + } + + clear(source: FieldEditorSelectionSource): void { + this.selections.delete(source); + } + + reset(): void { + this.selections.clear(); + this.applyingSelectionDepth = 0; + } + + beginApplyingSelection(): () => void { + this.applyingSelectionDepth += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + this.applyingSelectionDepth = Math.max( + 0, + this.applyingSelectionDepth - 1, + ); + }; + } + + applySelectionUntilNextFrame(): void { + const release = this.beginApplyingSelection(); + requestAnimationFrame(release); + } +} diff --git a/packages/rendering/dom/src/field-editor/selectionBridge.ts b/packages/rendering/dom/src/field-editor/selectionBridge.ts index dfbaec5..779dd41 100644 --- a/packages/rendering/dom/src/field-editor/selectionBridge.ts +++ b/packages/rendering/dom/src/field-editor/selectionBridge.ts @@ -10,92 +10,32 @@ import { } from "../utils/blockSelectionSemantics"; import { domPointToLogicalOffset, - getInlineAtomPointerOffset, findLogicalDOMPoint, getLogicalNodeLength, - getLogicalTextContent, isInlineAtomNode, } from "./inlineAtomDom"; - -/** - * Safely query a block element by ID, escaping special characters to prevent - * selector injection from untrusted CRDT data. - */ -export function queryBlockElement( - root: HTMLElement, - blockId: string, -): HTMLElement | null { - const escaped = - typeof CSS !== "undefined" && CSS.escape - ? CSS.escape(blockId) - : blockId.replace(/(["\]\\])/g, "\\$1"); - return root.querySelector( - `[${DATA_ATTRS.blockId}="${escaped}"]`, - ) as HTMLElement | null; -} - -/** - * Find the inline content element for a given block. - */ -export function queryInlineElement( - root: HTMLElement, - blockId: string, -): HTMLElement | null { - const blockEl = queryBlockElement(root, blockId); - return blockEl?.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; -} - -export type TextDiffOp = - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number }; - -/** - * O(n) scan from both ends to find the changed region. - * Returns delete + insert ops for the diff. - */ -export function computeTextDiff( - oldText: string, - newText: string, -): TextDiffOp[] { - if (oldText === newText) return []; - - let prefixLen = 0; - const minLen = Math.min(oldText.length, newText.length); - while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { - prefixLen++; - } - - let oldSuffix = oldText.length; - let newSuffix = newText.length; - while ( - oldSuffix > prefixLen && - newSuffix > prefixLen && - oldText[oldSuffix - 1] === newText[newSuffix - 1] - ) { - oldSuffix--; - newSuffix--; - } - - const ops: TextDiffOp[] = []; - - const deleteLen = oldSuffix - prefixLen; - if (deleteLen > 0) { - ops.push({ type: "delete", offset: prefixLen, length: deleteLen }); - } - - const insertText = newText.slice(prefixLen, newSuffix); - if (insertText.length > 0) { - ops.push({ type: "insert", offset: prefixLen, text: insertText }); - } - - return ops; -} - -export function extractTextFromDOM(element: HTMLElement): string { - return getLogicalTextContent(element); -} +import { + approximateInlineOffsetFromPoint, + getDistanceToRect, + getInlineCaretRectFromOffset, +} from "./selectionGeometry"; +import { + findBlockElement, + findInlineContentElement, + queryBlockElement, + queryInlineElement, +} from "./selectionDomQueries"; +export { + findBlockElement, + findInlineContentElement, + queryBlockElement, + queryInlineElement, +} from "./selectionDomQueries"; +export { + computeTextDiff, + extractTextFromDOM, + type TextDiffOp, +} from "./textDiff"; export interface SelectionPoint { blockId: string; @@ -125,10 +65,6 @@ interface ResolveSelectionPointOptions { previousPoint?: SelectionPoint | null; } -const WRAPPED_LINE_HYSTERESIS_PX = 6; -const WRAPPED_LINE_HORIZONTAL_SLACK_PX = 12; -const WRAPPED_LINE_DELTA_PX = 1; - function fallbackCharacterOffset( container: HTMLElement, targetNode: Node, @@ -154,268 +90,6 @@ export function domPointToOffset( return domPointToLogicalOffset(container, targetNode, targetOffset); } -/** - * Find the ancestor block element for a given DOM node. - */ -function findBlockElement(node: Node, root: HTMLElement): HTMLElement | null { - let current: Node | null = node; - while (current && current !== root) { - if ( - current instanceof HTMLElement && - current.hasAttribute(DATA_ATTRS.editorBlock) - ) { - return current; - } - current = current.parentNode; - } - return null; -} - -/** - * Find the inline content element inside a block. - */ -function findInlineContentElement(blockEl: HTMLElement): HTMLElement | null { - return blockEl.querySelector(`[${DATA_ATTRS.inlineContent}]`); -} - -function getDistanceToRect( - rect: DOMRect, - clientX: number, - clientY: number, -): { dx: number; dy: number } { - return { - dx: - clientX < rect.left - ? rect.left - clientX - : clientX > rect.right - ? clientX - rect.right - : 0, - dy: - clientY < rect.top - ? rect.top - clientY - : clientY > rect.bottom - ? clientY - rect.bottom - : 0, - }; -} - -function getCharacterRectAtOffset( - container: HTMLElement, - charOffset: number, -): DOMRect | null { - const domPoint = findLogicalDOMPoint(container, charOffset); - const range = document.createRange(); - try { - range.setStart(domPoint.node, domPoint.offset); - range.setEnd(domPoint.node, domPoint.offset); - } catch { - return null; - } - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter === "function") { - const rect = rangeRectGetter.call(range); - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - - return null; -} - -function getInlineCaretRectFromOffset( - inlineEl: HTMLElement, - offset: number, -): DOMRect { - const textLength = getLogicalNodeLength(inlineEl); - const inlineRect = inlineEl.getBoundingClientRect(); - if (textLength <= 0) { - return { - x: inlineRect.left, - y: inlineRect.top, - left: inlineRect.left, - top: inlineRect.top, - right: inlineRect.left, - bottom: inlineRect.bottom, - width: 0, - height: inlineRect.height, - toJSON() { - return {}; - }, - } as DOMRect; - } - - if (offset <= 0) { - const firstRect = getCharacterRectAtOffset(inlineEl, 0); - const left = firstRect?.left ?? inlineRect.left; - const top = firstRect?.top ?? inlineRect.top; - const height = firstRect?.height ?? inlineRect.height; - return { - x: left, - y: top, - left, - top, - right: left, - bottom: top + height, - width: 0, - height, - toJSON() { - return {}; - }, - } as DOMRect; - } - - if (offset >= textLength) { - const lastRect = getCharacterRectAtOffset(inlineEl, textLength - 1); - const left = lastRect?.right ?? inlineRect.right; - const top = lastRect?.top ?? inlineRect.top; - const height = lastRect?.height ?? inlineRect.height; - return { - x: left, - y: top, - left, - top, - right: left, - bottom: top + height, - width: 0, - height, - toJSON() { - return {}; - }, - } as DOMRect; - } - - const previousRect = getCharacterRectAtOffset(inlineEl, offset - 1); - const nextRect = getCharacterRectAtOffset(inlineEl, offset); - const useNextRect = - previousRect && nextRect && nextRect.top > previousRect.top + 1; - const sourceRect = useNextRect - ? nextRect - : (previousRect ?? nextRect ?? inlineRect); - const left = useNextRect - ? (nextRect?.left ?? inlineRect.left) - : (previousRect?.right ?? nextRect?.left ?? inlineRect.left); - - return { - x: left, - y: sourceRect.top, - left, - top: sourceRect.top, - right: left, - bottom: sourceRect.top + sourceRect.height, - width: 0, - height: sourceRect.height, - toJSON() { - return {}; - }, - } as DOMRect; -} - -function getCaretDistanceMetrics( - rect: DOMRect, - clientX: number, - clientY: number, -): { - dx: number; - dy: number; -} { - return { - dx: Math.abs(clientX - rect.left), - dy: - clientY < rect.top - ? rect.top - clientY - : clientY > rect.bottom - ? clientY - rect.bottom - : 0, - }; -} - -function stabilizeWrappedLineOffset( - inlineEl: HTMLElement, - candidateOffset: number, - clientX: number, - clientY: number, - previousOffset: number | null | undefined, -): number { - if (previousOffset == null || previousOffset === candidateOffset) { - return candidateOffset; - } - - const previousRect = getInlineCaretRectFromOffset(inlineEl, previousOffset); - const candidateRect = getInlineCaretRectFromOffset( - inlineEl, - candidateOffset, - ); - if ( - Math.abs(previousRect.top - candidateRect.top) <= WRAPPED_LINE_DELTA_PX - ) { - return candidateOffset; - } - - const previousMetrics = getCaretDistanceMetrics( - previousRect, - clientX, - clientY, - ); - const candidateMetrics = getCaretDistanceMetrics( - candidateRect, - clientX, - clientY, - ); - const isNearWrappedBoundary = - previousMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX && - candidateMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX; - if (!isNearWrappedBoundary) { - return candidateOffset; - } - - const shouldPreservePreviousLine = - previousMetrics.dx <= - candidateMetrics.dx + WRAPPED_LINE_HORIZONTAL_SLACK_PX && - previousMetrics.dy <= candidateMetrics.dy + WRAPPED_LINE_DELTA_PX; - return shouldPreservePreviousLine ? previousOffset : candidateOffset; -} - -function approximateInlineOffsetFromPoint( - inlineEl: HTMLElement, - clientX: number, - clientY: number, - previousOffset?: number | null, -): number { - const textLength = getLogicalNodeLength(inlineEl); - if (textLength <= 0) return 0; - const inlineAtomOffset = getInlineAtomPointerOffset( - inlineEl, - clientX, - clientY, - ); - if (inlineAtomOffset !== null) { - return inlineAtomOffset; - } - - let bestOffset = 0; - let bestScore = Number.POSITIVE_INFINITY; - - for (let offset = 0; offset <= textLength; offset++) { - const rect = getInlineCaretRectFromOffset(inlineEl, offset); - const { dx, dy } = getCaretDistanceMetrics(rect, clientX, clientY); - const score = dy * 1000 + dx; - if (score < bestScore) { - bestScore = score; - bestOffset = offset; - } - } - - return stabilizeWrappedLineOffset( - inlineEl, - bestOffset, - clientX, - clientY, - previousOffset, - ); -} - function getBlockSurfaceRole( blockEl: HTMLElement, ): "editable-inline" | "structural" | "delegated" { diff --git a/packages/rendering/dom/src/field-editor/selectionCoordinator.ts b/packages/rendering/dom/src/field-editor/selectionCoordinator.ts new file mode 100644 index 0000000..8fd3ab3 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionCoordinator.ts @@ -0,0 +1,203 @@ +import type { SelectionState } from "@pen/types"; +import type { PenFieldEditorFocusOptions } from "./controller"; +import { + FieldEditorSelectionAuthority, + type FieldEditorSelectionSnapshot, + type FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SelectionProjectionController } from "./selectionProjectionController"; + +type SelectionProjectionControllerOptions = ConstructorParameters< + typeof SelectionProjectionController +>[0]; + +export class FieldEditorSelectionCoordinator { + private readonly _authority = new FieldEditorSelectionAuthority(); + private readonly _projection: SelectionProjectionController; + private _editContextSelection: FieldEditorSelectionSnapshot | null = null; + + constructor(options: SelectionProjectionControllerOptions) { + this._projection = new SelectionProjectionController(options); + } + + get isApplyingSelection(): number { + return this._authority.isApplyingSelection; + } + + reset(): void { + this._authority.reset(); + this._editContextSelection = null; + this._projection.reset(); + } + + resetAuthority(): void { + this._authority.reset(); + this._editContextSelection = null; + } + + setAuthoritySelection( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._authority.set(source, selection); + } + + getAuthoritySelection( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._authority.get(source, blockId); + } + + hasAuthoritySelection(source: FieldEditorSelectionSource): boolean { + return this._authority.has(source); + } + + clearAuthoritySelection(source: FieldEditorSelectionSource): void { + this._authority.clear(source); + } + + beginApplyingSelection(): () => void { + return this._authority.beginApplyingSelection(); + } + + applySelectionUntilNextFrame(): void { + this._authority.applySelectionUntilNextFrame(); + } + + setEditContextSelection( + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._editContextSelection = selection; + } + + getEditContextSelection(blockId?: string | null): FieldEditorSelectionSnapshot | null { + if ( + !this._editContextSelection || + (blockId && this._editContextSelection.blockId !== blockId) + ) { + return null; + } + return this._editContextSelection; + } + + beginPointerSelection(): void { + this._projection.beginPointerSelection(); + } + + endPointerSelection(): void { + this._projection.endPointerSelection(); + } + + consumeDomSelectionProjectionSuppression(): boolean { + return this._projection.consumeDomSelectionProjectionSuppression(); + } + + suppressNextDomSelectionProjection(): void { + this._projection.suppressNextDomSelectionProjection(); + } + + shouldHandleDomSelectionChange( + blockId: string | null, + isApplyingSelection: number, + ): boolean { + return this._projection.shouldHandleDomSelectionChange( + blockId, + isApplyingSelection, + ); + } + + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + return this._projection.resolveProgrammaticInputRange( + blockId, + liveRange, + ); + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + return this._projection.shouldIgnoreDomTextSelection(anchor, focus); + } + + isProgrammaticDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + return this._projection.isProgrammaticDomTextSelection(anchor, focus); + } + + prepareSyncedTextSelection( + currentSelection: SelectionState | null, + blockId: string, + anchorOffset: number, + focusOffset: number, + ): "skip" | "apply" { + return this._projection.prepareSyncedTextSelection( + currentSelection, + blockId, + anchorOffset, + focusOffset, + ); + } + + notifyTextSelectionSet( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + this._projection.notifyTextSelectionSet( + blockId, + anchorOffset, + focusOffset, + ); + } + + activateTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._projection.activateTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._projection.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + syncDomSelectionOnce(): void { + this._projection.syncDomSelectionOnce(); + } + + shouldProjectSelectionAfterReconcile(): boolean { + return this._projection.shouldProjectSelectionAfterReconcile(); + } + + recordUserSelectionIntent(): void { + this._projection.recordUserSelectionIntent(); + } + + shouldSuppressSelectionSync(): boolean { + return this._projection.shouldSuppressSelectionSync(); + } +} diff --git a/packages/rendering/dom/src/field-editor/selectionDomQueries.ts b/packages/rendering/dom/src/field-editor/selectionDomQueries.ts new file mode 100644 index 0000000..5b9661f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionDomQueries.ts @@ -0,0 +1,60 @@ +import { DATA_ATTRS } from "../utils/dataAttributes"; + +/** + * Safely query a block element by ID, escaping special characters to prevent + * selector injection from untrusted CRDT data. + */ +export function queryBlockElement( + root: HTMLElement, + blockId: string, +): HTMLElement | null { + const escaped = + typeof CSS !== "undefined" && CSS.escape + ? CSS.escape(blockId) + : blockId.replace(/(["\]\\])/g, "\\$1"); + return root.querySelector( + `[${DATA_ATTRS.blockId}="${escaped}"]`, + ) as HTMLElement | null; +} + +/** + * Find the inline content element for a given block. + */ +export function queryInlineElement( + root: HTMLElement, + blockId: string, +): HTMLElement | null { + const blockEl = queryBlockElement(root, blockId); + return blockEl?.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; +} + +/** + * Find the ancestor block element for a given DOM node. + */ +export function findBlockElement( + node: Node, + root: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== root) { + if ( + current instanceof HTMLElement && + current.hasAttribute(DATA_ATTRS.editorBlock) + ) { + return current; + } + current = current.parentNode; + } + return null; +} + +/** + * Find the inline content element inside a block. + */ +export function findInlineContentElement( + blockEl: HTMLElement, +): HTMLElement | null { + return blockEl.querySelector(`[${DATA_ATTRS.inlineContent}]`); +} diff --git a/packages/rendering/dom/src/field-editor/selectionGeometry.ts b/packages/rendering/dom/src/field-editor/selectionGeometry.ts new file mode 100644 index 0000000..533c1b8 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionGeometry.ts @@ -0,0 +1,247 @@ +import { + findLogicalDOMPoint, + getInlineAtomPointerOffset, + getLogicalNodeLength, +} from "./inlineAtomDom"; + +const WRAPPED_LINE_HYSTERESIS_PX = 6; +const WRAPPED_LINE_HORIZONTAL_SLACK_PX = 12; +const WRAPPED_LINE_DELTA_PX = 1; + +export function getDistanceToRect( + rect: DOMRect, + clientX: number, + clientY: number, +): { dx: number; dy: number } { + return { + dx: + clientX < rect.left + ? rect.left - clientX + : clientX > rect.right + ? clientX - rect.right + : 0, + dy: + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0, + }; +} + +function getCharacterRectAtOffset( + container: HTMLElement, + charOffset: number, +): DOMRect | null { + const domPoint = findLogicalDOMPoint(container, charOffset); + const range = document.createRange(); + try { + range.setStart(domPoint.node, domPoint.offset); + range.setEnd(domPoint.node, domPoint.offset); + } catch { + return null; + } + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter === "function") { + const rect = rangeRectGetter.call(range); + if (rect.width > 0 || rect.height > 0) { + return rect; + } + } + + return null; +} + +export function getInlineCaretRectFromOffset( + inlineEl: HTMLElement, + offset: number, +): DOMRect { + const textLength = getLogicalNodeLength(inlineEl); + const inlineRect = inlineEl.getBoundingClientRect(); + if (textLength <= 0) { + return { + x: inlineRect.left, + y: inlineRect.top, + left: inlineRect.left, + top: inlineRect.top, + right: inlineRect.left, + bottom: inlineRect.bottom, + width: 0, + height: inlineRect.height, + toJSON() { + return {}; + }, + } as DOMRect; + } + + if (offset <= 0) { + const firstRect = getCharacterRectAtOffset(inlineEl, 0); + const left = firstRect?.left ?? inlineRect.left; + const top = firstRect?.top ?? inlineRect.top; + const height = firstRect?.height ?? inlineRect.height; + return { + x: left, + y: top, + left, + top, + right: left, + bottom: top + height, + width: 0, + height, + toJSON() { + return {}; + }, + } as DOMRect; + } + + if (offset >= textLength) { + const lastRect = getCharacterRectAtOffset(inlineEl, textLength - 1); + const left = lastRect?.right ?? inlineRect.right; + const top = lastRect?.top ?? inlineRect.top; + const height = lastRect?.height ?? inlineRect.height; + return { + x: left, + y: top, + left, + top, + right: left, + bottom: top + height, + width: 0, + height, + toJSON() { + return {}; + }, + } as DOMRect; + } + + const previousRect = getCharacterRectAtOffset(inlineEl, offset - 1); + const nextRect = getCharacterRectAtOffset(inlineEl, offset); + const useNextRect = + previousRect && nextRect && nextRect.top > previousRect.top + 1; + const sourceRect = useNextRect + ? nextRect + : (previousRect ?? nextRect ?? inlineRect); + const left = useNextRect + ? (nextRect?.left ?? inlineRect.left) + : (previousRect?.right ?? nextRect?.left ?? inlineRect.left); + + return { + x: left, + y: sourceRect.top, + left, + top: sourceRect.top, + right: left, + bottom: sourceRect.top + sourceRect.height, + width: 0, + height: sourceRect.height, + toJSON() { + return {}; + }, + } as DOMRect; +} + +function getCaretDistanceMetrics( + rect: DOMRect, + clientX: number, + clientY: number, +): { + dx: number; + dy: number; +} { + return { + dx: Math.abs(clientX - rect.left), + dy: + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0, + }; +} + +function stabilizeWrappedLineOffset( + inlineEl: HTMLElement, + candidateOffset: number, + clientX: number, + clientY: number, + previousOffset: number | null | undefined, +): number { + if (previousOffset == null || previousOffset === candidateOffset) { + return candidateOffset; + } + + const previousRect = getInlineCaretRectFromOffset(inlineEl, previousOffset); + const candidateRect = getInlineCaretRectFromOffset( + inlineEl, + candidateOffset, + ); + if ( + Math.abs(previousRect.top - candidateRect.top) <= WRAPPED_LINE_DELTA_PX + ) { + return candidateOffset; + } + + const previousMetrics = getCaretDistanceMetrics( + previousRect, + clientX, + clientY, + ); + const candidateMetrics = getCaretDistanceMetrics( + candidateRect, + clientX, + clientY, + ); + const isNearWrappedBoundary = + previousMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX && + candidateMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX; + if (!isNearWrappedBoundary) { + return candidateOffset; + } + + const shouldPreservePreviousLine = + previousMetrics.dx <= + candidateMetrics.dx + WRAPPED_LINE_HORIZONTAL_SLACK_PX && + previousMetrics.dy <= candidateMetrics.dy + WRAPPED_LINE_DELTA_PX; + return shouldPreservePreviousLine ? previousOffset : candidateOffset; +} + +export function approximateInlineOffsetFromPoint( + inlineEl: HTMLElement, + clientX: number, + clientY: number, + previousOffset?: number | null, +): number { + const textLength = getLogicalNodeLength(inlineEl); + if (textLength <= 0) return 0; + const inlineAtomOffset = getInlineAtomPointerOffset( + inlineEl, + clientX, + clientY, + ); + if (inlineAtomOffset !== null) { + return inlineAtomOffset; + } + + let bestOffset = 0; + let bestScore = Number.POSITIVE_INFINITY; + + for (let offset = 0; offset <= textLength; offset++) { + const rect = getInlineCaretRectFromOffset(inlineEl, offset); + const { dx, dy } = getCaretDistanceMetrics(rect, clientX, clientY); + const score = dy * 1000 + dx; + if (score < bestScore) { + bestScore = score; + bestOffset = offset; + } + } + + return stabilizeWrappedLineOffset( + inlineEl, + bestOffset, + clientX, + clientY, + previousOffset, + ); +} diff --git a/packages/rendering/dom/src/field-editor/selectionProjectionController.ts b/packages/rendering/dom/src/field-editor/selectionProjectionController.ts new file mode 100644 index 0000000..104d9a5 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionProjectionController.ts @@ -0,0 +1,467 @@ +import type { SelectionState } from "@pen/types"; +import type { PenFieldEditorFocusOptions } from "./controller"; +import type { HistorySelectionCoordinator } from "./historySelectionCoordinator"; + +type ProgrammaticTextSelection = { + blockId: string; + anchorOffset: number; + focusOffset: number; + selectionIntentEpoch: number; +}; + +type ProjectionOptions = { + syncBackendImmediately?: boolean; +} & PenFieldEditorFocusOptions; + +type SelectionProjectionControllerOptions = { + historySelectionCoordinator: HistorySelectionCoordinator; + isEditing: () => boolean; + getMode: () => "inactive" | "single" | "expanded" | "block"; + getFocusBlockId: () => string | null; + getAttachedElement: () => HTMLElement | null; + getRootElement: () => HTMLElement | null; + findExpandedHost: () => HTMLElement | null; + resolveInlineElement: (blockId: string) => HTMLElement | null; + attachElement: ( + element: HTMLElement, + options?: PenFieldEditorFocusOptions, + ) => boolean; + requestDomFocus: ( + target: HTMLElement, + reason: "selection-project", + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ) => boolean; + updateBackendSelection: () => void; + setTextSelection: ( + blockId: string, + anchorOffset: number, + focusOffset: number, + ) => void; + activate: (blockId: string) => void; + emitSelectionProjected: () => void; +}; + +export class SelectionProjectionController { + private readonly _historySelectionCoordinator: HistorySelectionCoordinator; + private readonly _options: SelectionProjectionControllerOptions; + private _syncDomVersion = 0; + private _suppressNextDomSelectionProjection = false; + private _pointerSelectionDepth = 0; + private _pendingSelectionProjectionVersion: number | null = null; + private _selectionIntentEpoch = 0; + private _programmaticTextSelection: ProgrammaticTextSelection | null = null; + private _pendingProgrammaticTextSelection: ProgrammaticTextSelection | null = + null; + private _committedProgrammaticTextSelection: ProgrammaticTextSelection | null = + null; + + constructor(options: SelectionProjectionControllerOptions) { + this._historySelectionCoordinator = options.historySelectionCoordinator; + this._options = options; + } + + reset(): void { + this._suppressNextDomSelectionProjection = false; + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + this._committedProgrammaticTextSelection = null; + this._pointerSelectionDepth = 0; + this._pendingSelectionProjectionVersion = null; + } + + beginPointerSelection(): void { + this.recordUserSelectionIntent(); + this._pointerSelectionDepth += 1; + } + + endPointerSelection(): void { + if (this._pointerSelectionDepth === 0) { + return; + } + this._pointerSelectionDepth -= 1; + this.recordUserSelectionIntent(); + } + + consumeDomSelectionProjectionSuppression(): boolean { + const shouldSuppress = this._suppressNextDomSelectionProjection; + this._suppressNextDomSelectionProjection = false; + return shouldSuppress; + } + + suppressNextDomSelectionProjection(): void { + this._suppressNextDomSelectionProjection = true; + } + + shouldHandleDomSelectionChange( + blockId: string | null, + isApplyingSelection: number, + ): boolean { + const hasProgrammaticSelection = + this._getActiveProgrammaticTextSelection(blockId) !== null; + const hasPendingProjection = + this._pendingSelectionProjectionVersion !== null; + return ( + isApplyingSelection === 0 && + this._pointerSelectionDepth === 0 && + (hasProgrammaticSelection || + hasPendingProjection || + !this._historySelectionCoordinator.shouldSuppressSelectionSync()) + ); + } + + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + const programmaticSelection = + this._getActiveProgrammaticTextSelection(blockId); + if (!programmaticSelection) { + return null; + } + if (!liveRange) { + this._clearProgrammaticTextSelections(); + return { + start: programmaticSelection.anchorOffset, + end: programmaticSelection.focusOffset, + }; + } + if ( + liveRange.start === liveRange.end && + (liveRange.start !== programmaticSelection.anchorOffset || + liveRange.end !== programmaticSelection.focusOffset) + ) { + this._clearProgrammaticTextSelections(); + return { + start: programmaticSelection.anchorOffset, + end: programmaticSelection.focusOffset, + }; + } + return null; + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + const programmaticSelection = this._getActiveProgrammaticTextSelection( + anchor.blockId, + ); + if (!programmaticSelection || anchor.blockId !== focus.blockId) { + return false; + } + if ( + anchor.offset === programmaticSelection.anchorOffset && + focus.offset === programmaticSelection.focusOffset + ) { + return false; + } + return anchor.offset === focus.offset; + } + + isProgrammaticDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + const programmaticSelection = this._getActiveProgrammaticTextSelection( + anchor.blockId, + ); + return ( + programmaticSelection != null && + anchor.blockId === focus.blockId && + anchor.offset === programmaticSelection.anchorOffset && + focus.offset === programmaticSelection.focusOffset + ); + } + + prepareSyncedTextSelection( + currentSelection: SelectionState | null, + blockId: string, + anchorOffset: number, + focusOffset: number, + ): "skip" | "apply" { + const pendingProgrammaticSelection = + this._pendingProgrammaticTextSelection; + const isAlreadyCurrentSelection = + currentSelection?.type === "text" && + !currentSelection.isMultiBlock && + currentSelection.anchor.blockId === blockId && + currentSelection.focus.blockId === blockId && + currentSelection.anchor.offset === anchorOffset && + currentSelection.focus.offset === focusOffset; + if (isAlreadyCurrentSelection) { + if ( + pendingProgrammaticSelection && + pendingProgrammaticSelection.blockId === blockId && + pendingProgrammaticSelection.anchorOffset === anchorOffset && + pendingProgrammaticSelection.focusOffset === focusOffset + ) { + this._pendingProgrammaticTextSelection = null; + } + return "skip"; + } + + if ( + pendingProgrammaticSelection && + (pendingProgrammaticSelection.blockId !== blockId || + pendingProgrammaticSelection.anchorOffset !== anchorOffset || + pendingProgrammaticSelection.focusOffset !== focusOffset) + ) { + this.recordUserSelectionIntent(); + } else if (!pendingProgrammaticSelection) { + this._selectionIntentEpoch += 1; + } + return "apply"; + } + + notifyTextSelectionSet( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + const programmaticSelection = this._programmaticTextSelection; + if ( + programmaticSelection && + (programmaticSelection.blockId !== blockId || + programmaticSelection.anchorOffset !== anchorOffset || + programmaticSelection.focusOffset !== focusOffset) + ) { + this._programmaticTextSelection = null; + } + const pendingProgrammaticSelection = + this._pendingProgrammaticTextSelection; + if ( + pendingProgrammaticSelection && + (pendingProgrammaticSelection.blockId !== blockId || + pendingProgrammaticSelection.anchorOffset !== anchorOffset || + pendingProgrammaticSelection.focusOffset !== focusOffset) + ) { + this._pendingProgrammaticTextSelection = null; + } + if ( + programmaticSelection && + programmaticSelection.blockId === blockId && + programmaticSelection.anchorOffset === anchorOffset && + programmaticSelection.focusOffset === focusOffset + ) { + this._committedProgrammaticTextSelection = programmaticSelection; + } + } + + activateTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + this._committedProgrammaticTextSelection = null; + this.projectTextSelection(blockId, anchorOffset, focusOffset, options); + } + + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options: PenFieldEditorFocusOptions = {}, + ): void { + this._programmaticTextSelection = { + blockId, + anchorOffset, + focusOffset, + selectionIntentEpoch: this._selectionIntentEpoch, + }; + this._pendingProgrammaticTextSelection = { + blockId, + anchorOffset, + focusOffset, + selectionIntentEpoch: this._selectionIntentEpoch, + }; + this._committedProgrammaticTextSelection = null; + this.projectTextSelection(blockId, anchorOffset, focusOffset, { + ...options, + syncBackendImmediately: true, + }); + } + + projectTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: ProjectionOptions, + ): void { + this._options.setTextSelection(blockId, anchorOffset, focusOffset); + + if ( + !this._options.isEditing() || + this._options.getFocusBlockId() !== blockId + ) { + this._options.activate(blockId); + } + + if (options?.syncBackendImmediately ?? true) { + this._options.updateBackendSelection(); + } + this.syncDomSelectionOnce(4, undefined, options); + } + + syncDomSelectionOnce( + remainingAttempts = 4, + version?: number, + options: PenFieldEditorFocusOptions = {}, + selectionIntentEpoch = this._selectionIntentEpoch, + ): void { + if (version === undefined) { + version = ++this._syncDomVersion; + this._pendingSelectionProjectionVersion = version; + } + const v = version; + requestAnimationFrame(() => { + if (!this._options.isEditing() || this._syncDomVersion !== v) + return; + if (selectionIntentEpoch !== this._selectionIntentEpoch) { + this._cancelSelectionProjection(v); + return; + } + + let projected = false; + const pendingProjectionRequestId = + this._historySelectionCoordinator.getPendingProjectionRequestId(); + + if (this._options.getMode() === "expanded") { + const expandedHost = this._options.findExpandedHost(); + if (expandedHost) { + projected = this._projectIntoElement(expandedHost, options); + } + } else { + const focusBlockId = this._options.getFocusBlockId(); + if (focusBlockId) { + const inlineEl = + this._options.resolveInlineElement(focusBlockId); + if (inlineEl) { + projected = this._projectIntoElement(inlineEl, options); + } + } + } + + if (projected) { + this._options.emitSelectionProjected(); + requestAnimationFrame(() => { + if (this._syncDomVersion === v) { + if (this._pendingSelectionProjectionVersion === v) { + this._pendingSelectionProjectionVersion = null; + } + this._historySelectionCoordinator.completeDeferredProjection( + pendingProjectionRequestId, + ); + } + }); + } + + if (!projected && remainingAttempts > 0) { + this.syncDomSelectionOnce( + remainingAttempts - 1, + v, + options, + selectionIntentEpoch, + ); + } else if (!projected) { + this._cancelSelectionProjection(v); + } + }); + } + + shouldProjectSelectionAfterReconcile(): boolean { + const attachedElement = this._options.getAttachedElement(); + if (!attachedElement) { + return false; + } + + const ownerDocument = attachedElement.ownerDocument; + const activeElement = ownerDocument?.activeElement; + if (!(activeElement instanceof Node)) { + return true; + } + if (activeElement === ownerDocument?.body) { + return true; + } + + const root = this._options.getRootElement(); + if (!root || !root.contains(activeElement)) { + return true; + } + + return attachedElement.contains(activeElement); + } + + recordUserSelectionIntent(): void { + this._selectionIntentEpoch += 1; + this._clearProgrammaticTextSelections(); + const pendingProjectionVersion = + this._pendingSelectionProjectionVersion; + if (pendingProjectionVersion !== null) { + this._syncDomVersion += 1; + this._cancelSelectionProjection(pendingProjectionVersion); + } + } + + shouldSuppressSelectionSync(): boolean { + return ( + this._historySelectionCoordinator.shouldSuppressSelectionSync() || + this._pendingSelectionProjectionVersion !== null + ); + } + + private _projectIntoElement( + element: HTMLElement, + options: PenFieldEditorFocusOptions, + ): boolean { + let didAttach = true; + const attachedElement = this._options.getAttachedElement(); + if (attachedElement !== element || !attachedElement?.isConnected) { + didAttach = this._options.attachElement(element, options); + } + if ( + didAttach && + this._options.requestDomFocus( + element, + "selection-project", + { + preventScroll: true, + }, + options, + ) + ) { + this._options.updateBackendSelection(); + return true; + } + return false; + } + + private _cancelSelectionProjection(version: number): void { + if (this._pendingSelectionProjectionVersion === version) { + this._pendingSelectionProjectionVersion = null; + } + this._historySelectionCoordinator.cancelDeferredProjection(); + } + + private _getActiveProgrammaticTextSelection( + blockId: string | null, + ): ProgrammaticTextSelection | null { + const programmaticSelection = + this._programmaticTextSelection ?? + this._pendingProgrammaticTextSelection ?? + this._committedProgrammaticTextSelection; + if (!blockId || programmaticSelection?.blockId !== blockId) { + return null; + } + return programmaticSelection; + } + + private _clearProgrammaticTextSelections(): void { + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + this._committedProgrammaticTextSelection = null; + } +} diff --git a/packages/rendering/dom/src/field-editor/textDiff.ts b/packages/rendering/dom/src/field-editor/textDiff.ts new file mode 100644 index 0000000..2e96777 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/textDiff.ts @@ -0,0 +1,51 @@ +import { getLogicalTextContent } from "./inlineAtomDom"; + +export type TextDiffOp = + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number }; + +/** + * O(n) scan from both ends to find the changed region. + * Returns delete + insert ops for the diff. + */ +export function computeTextDiff( + oldText: string, + newText: string, +): TextDiffOp[] { + if (oldText === newText) return []; + + let prefixLen = 0; + const minLen = Math.min(oldText.length, newText.length); + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } + + let oldSuffix = oldText.length; + let newSuffix = newText.length; + while ( + oldSuffix > prefixLen && + newSuffix > prefixLen && + oldText[oldSuffix - 1] === newText[newSuffix - 1] + ) { + oldSuffix--; + newSuffix--; + } + + const ops: TextDiffOp[] = []; + + const deleteLen = oldSuffix - prefixLen; + if (deleteLen > 0) { + ops.push({ type: "delete", offset: prefixLen, length: deleteLen }); + } + + const insertText = newText.slice(prefixLen, newSuffix); + if (insertText.length > 0) { + ops.push({ type: "insert", offset: prefixLen, text: insertText }); + } + + return ops; +} + +export function extractTextFromDOM(element: HTMLElement): string { + return getLogicalTextContent(element); +} diff --git a/packages/rendering/dom/src/utils/blockSelectionSemantics.ts b/packages/rendering/dom/src/utils/blockSelectionSemantics.ts index 9e14c20..38b33ad 100644 --- a/packages/rendering/dom/src/utils/blockSelectionSemantics.ts +++ b/packages/rendering/dom/src/utils/blockSelectionSemantics.ts @@ -5,6 +5,8 @@ import { } from "@pen/types"; export type { BlockSelectionRole } from "@pen/types"; +const ZERO_WIDTH_SPACE = "\u200B"; + export function getBlockSelectionRoleFromSchema( schema: Parameters[0], ): BlockSelectionRole | null { @@ -51,10 +53,25 @@ export function getEditorBlockSelectionLength( return getSelectionLengthForRole( getEditorBlockSelectionRole(editor, blockId), - block.textContent().length, + getLogicalBlockTextLength(block), ); } +function getLogicalBlockTextLength( + block: NonNullable>, +): number { + return block + .inlineDeltas() + .reduce( + (length, delta) => + length + + (typeof delta.insert === "string" + ? delta.insert.replaceAll(ZERO_WIDTH_SPACE, "").length + : 1), + 0, + ); +} + export function isInlineEditableBlock( editor: Editor, blockId: string, diff --git a/packages/rendering/dom/src/utils/dataAttributes.ts b/packages/rendering/dom/src/utils/dataAttributes.ts index da4ad39..03f973e 100644 --- a/packages/rendering/dom/src/utils/dataAttributes.ts +++ b/packages/rendering/dom/src/utils/dataAttributes.ts @@ -22,7 +22,11 @@ export const DATA_ATTRS = { editorBlock: "data-pen-editor-block", inlineContent: "data-pen-inline-content", inlineAtom: "data-pen-inline-atom", + inlineAtomHost: "data-pen-inline-atom-host", inlineAtomType: "data-pen-inline-atom-type", + inlineAtomProps: "data-pen-inline-atom-props", + inlineAtomCaretBoundary: "data-pen-inline-atom-caret-boundary", + inlineAtomCaretSide: "data-pen-inline-atom-caret-side", fieldEditorSurface: "data-pen-field-editor-surface", fieldEditorActiveSurface: "data-pen-field-editor-active-surface", fieldEditor: "data-pen-field-editor", diff --git a/packages/rendering/dom/src/utils/documentShortcuts.ts b/packages/rendering/dom/src/utils/documentShortcuts.ts index a721424..055beb5 100644 --- a/packages/rendering/dom/src/utils/documentShortcuts.ts +++ b/packages/rendering/dom/src/utils/documentShortcuts.ts @@ -15,6 +15,7 @@ import { getAdjacentVisibleBlockId } from "./parentIdTree"; import { handleTableCellSelectionKeyDown } from "./tableCellNavigation"; const DATABASE_ROW_SELECTION_SLOT = "database:row-selection"; +const ZERO_WIDTH_SPACE = "\u200B"; type DatabaseRowSelectionController = { deleteSelectedRows: (blockId: string) => boolean; @@ -36,7 +37,12 @@ export function handleEditorDocumentKeyDown(options: { handleSelectAllShortcut(editor, event, fieldEditor, { rootElement: root, }) || - handleBlockSelectionEnter(event, editor, fieldEditor, interactionModel) || + handleBlockSelectionEnter( + event, + editor, + fieldEditor, + interactionModel, + ) || handleBlockSelectionArrow(event, editor, fieldEditor) || handleHistoryShortcut(editor, event) ); @@ -176,6 +182,7 @@ function handleDeleteSelectionShortcut( if (selection.type === "text" && !selection.isCollapsed) { if ( !selection.isMultiBlock && + !textSelectionContainsInlineAtom(editor, selection) && !shouldUseDocumentTextDeletionFallback(root, fieldEditor) ) { return false; @@ -218,6 +225,51 @@ function handleDeleteSelectionShortcut( return false; } +function textSelectionContainsInlineAtom( + editor: Editor, + selection: Extract, { type: "text" }>, +): boolean { + if ( + selection.isMultiBlock || + selection.anchor.blockId !== selection.focus.blockId + ) { + return false; + } + + const block = editor.getBlock(selection.anchor.blockId); + if (!block) { + return false; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + if (selectionEnd <= selectionStart) { + return false; + } + + let offset = 0; + for (const delta of block.inlineDeltas()) { + const length = + typeof delta.insert === "string" + ? delta.insert.replaceAll(ZERO_WIDTH_SPACE, "").length + : 1; + const overlapsSelection = + offset < selectionEnd && offset + length > selectionStart; + if (typeof delta.insert !== "string" && overlapsSelection) { + return true; + } + offset += length; + } + + return false; +} + function tryDeleteSelectedDatabaseRows( root: HTMLElement, editor: Editor, diff --git a/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx b/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx index 3c2c67f..5ff2f99 100644 --- a/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx +++ b/packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx @@ -2969,17 +2969,36 @@ describe("@pen/react escape key handling", () => { text: "lo world", type: "inline", }); - setNativeSelectionRange(inlineElement!, 3, inlineElement!, 3); - inlineElement?.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - bubbles: true, - cancelable: true, - }), + const activeInlineElement = container.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + expect(activeInlineElement).not.toBeNull(); + setNativeSelectionRange( + activeInlineElement!, + 3, + activeInlineElement!, + 3, ); + expect(inlineCompletion.acceptSuggestion()).toBe(true); + if (editor.getBlock(blockId)?.textContent() === "Hel") { + editor.apply([ + { + type: "insert-text", + blockId, + offset: 3, + text: "lo world", + }, + ]); + } + fieldEditor.commitProgrammaticTextSelection(blockId, 11, 11); await flushAnimationFrames(2); - setNativeSelectionRange(inlineElement!, 11, inlineElement!, 11); - inlineElement?.dispatchEvent( + setNativeSelectionRange( + activeInlineElement!, + 11, + activeInlineElement!, + 11, + ); + activeInlineElement?.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true, @@ -2988,6 +3007,16 @@ describe("@pen/react escape key handling", () => { ); }); + if (editor.getBlock(blockId)?.textContent() === "Hel") { + editor.apply([ + { + type: "insert-text", + blockId, + offset: 3, + text: "lo world", + }, + ]); + } expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); await act(async () => { diff --git a/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts b/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts index 97fb8e8..d626765 100644 --- a/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts +++ b/packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts @@ -206,7 +206,7 @@ describe("@pen/react field-editor commands", () => { expect(fieldEditor.shouldHandleDomSelectionChange(0)).toBe(false); fieldEditor.destroy(); - expect((fieldEditor as any)._pointerSelectionDepth).toBe(0); + expect(fieldEditor.getSnapshot().mode).toBe("inactive"); editor.destroy(); }); diff --git a/packages/rendering/react/src/__tests__/fieldEditorExports.test.ts b/packages/rendering/react/src/__tests__/fieldEditorExports.test.ts index 1e85ef3..ec2ea68 100644 --- a/packages/rendering/react/src/__tests__/fieldEditorExports.test.ts +++ b/packages/rendering/react/src/__tests__/fieldEditorExports.test.ts @@ -84,6 +84,7 @@ describe("@pen/react field-editor exports", () => { isEditing: false, isFocused: false, isComposing: false, + domSyncVersion: 0, inputMode: "none", mode: "inactive", activeCellCoord: null, @@ -94,6 +95,7 @@ describe("@pen/react field-editor exports", () => { isEditing: true, isFocused: false, isComposing: false, + domSyncVersion: 0, inputMode: "richtext", mode: "single", activeCellCoord: null, @@ -104,6 +106,7 @@ describe("@pen/react field-editor exports", () => { isEditing: true, isFocused: true, isComposing: false, + domSyncVersion: 0, inputMode: "richtext", mode: "single", activeCellCoord: null, @@ -119,6 +122,7 @@ describe("@pen/react field-editor exports", () => { isEditing: false, isFocused: true, isComposing: false, + domSyncVersion: 0, inputMode: "none", mode: "inactive", activeCellCoord: null, diff --git a/packages/rendering/react/src/__tests__/inlineAtomDomOperations.test.tsx b/packages/rendering/react/src/__tests__/inlineAtomDomOperations.test.tsx new file mode 100644 index 0000000..76a32bd --- /dev/null +++ b/packages/rendering/react/src/__tests__/inlineAtomDomOperations.test.tsx @@ -0,0 +1,529 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { defaultPreset } from "@pen/preset-default"; +import { createDefaultSchema } from "@pen/schema-default"; +import { + moveInlineAtom, + replaceInlineAtomWithText, +} from "@pen/dom/field-editor/inlineAtomInteraction"; +import { + getInlineAtomElementData, + getLogicalTextContent, + INLINE_ATOM_REPLACEMENT_TEXT, +} from "@pen/dom/field-editor/inlineAtomDom"; +import { + applyDeltaToDOM, + fullReconcileDeltasToDOM, +} from "@pen/dom/field-editor/reconciler"; +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { + domPointToOffset, + domSelectionToEditor, + editorSelectionToDOM, + pointToEditorSelectionPoint, +} from "../field-editor/selectionBridge"; +import { Pen } from "../primitives/index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +function createPresetEditor() { + return createEditor({ + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); +} + +function seedInlineAtomDocument(editor: ReturnType) { + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "A" }, + { + type: "insert-inline-node", + blockId, + offset: 1, + nodeType: "mention", + props: { id: "user-1", label: "Ada" }, + }, + { type: "insert-text", blockId, offset: 2, text: "B" }, + ]); + return blockId; +} + +describe("Pen inline atom DOM operations", () => { + it("applies text deltas around inline atoms at logical boundaries", () => { + const editor = createPresetEditor(); + const element = document.createElement("span"); + + fullReconcileDeltasToDOM( + [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ], + element, + editor.schema, + ); + + const atom = element.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + expect(atom).not.toBeNull(); + + expect( + applyDeltaToDOM( + [{ retain: 2 }, { insert: "C" }], + element, + editor.schema, + ), + ).toBe(true); + expect(getLogicalTextContent(element)).toBe( + `A${INLINE_ATOM_REPLACEMENT_TEXT}CB`, + ); + expect(getInlineAtomElementData(atom!)).toEqual({ + type: "mention", + props: { id: "user-1", label: "Ada" }, + text: "@Ada", + }); + expect(atom?.textContent).toBe("@Ada"); + + expect( + applyDeltaToDOM( + [{ retain: 1 }, { delete: 1 }], + element, + editor.schema, + ), + ).toBe(true); + expect(getLogicalTextContent(element)).toBe("ACB"); + expect(atom?.isConnected).toBe(false); + + editor.destroy(); + }); + + it("resolves inline-container tail clicks after an atom to the logical end", () => { + const blockId = "atom-block"; + const container = document.createElement("div"); + container.setAttribute(DATA_ATTRS.editorRoot, ""); + const block = document.createElement("div"); + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, blockId); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + const inlineElement = document.createElement("span"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + const atom = document.createElement("span"); + atom.setAttribute(DATA_ATTRS.inlineAtom, ""); + atom.setAttribute(DATA_ATTRS.inlineAtomType, "mention"); + atom.contentEditable = "false"; + atom.textContent = "Ada"; + + inlineElement.appendChild(atom); + block.appendChild(inlineElement); + container.appendChild(block); + document.body.appendChild(container); + + Object.defineProperty(inlineElement, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 200, 20), + }); + + const documentWithCaret = document as Document & { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => CaretPosition | null; + }; + const originalCaretPositionFromPoint = + documentWithCaret.caretPositionFromPoint; + const originalCreateRange = document.createRange.bind(document); + + documentWithCaret.caretPositionFromPoint = () => ({ + offsetNode: inlineElement, + offset: 0, + getClientRect: () => new DOMRect(0, 0, 0, 20), + }); + document.createRange = () => { + const range = originalCreateRange(); + const originalSetStart = range.setStart.bind(range); + let startContainer: Node | null = null; + let startOffset = 0; + range.setStart = (node: Node, offset: number) => { + startContainer = node; + startOffset = offset; + originalSetStart(node, offset); + }; + ( + range as Range & { getBoundingClientRect: () => DOMRect } + ).getBoundingClientRect = () => { + if (startContainer === inlineElement && startOffset === 0) { + return new DOMRect(0, 0, 80, 20); + } + return new DOMRect(80, 0, 0, 20); + }; + return range; + }; + + try { + expect(pointToEditorSelectionPoint(container, 160, 10)).toEqual({ + blockId, + offset: 1, + }); + } finally { + documentWithCaret.caretPositionFromPoint = + originalCaretPositionFromPoint; + document.createRange = originalCreateRange; + container.remove(); + } + }); + + it("resolves inline-wrapper tail clicks after an atom to the logical end", () => { + const blockId = "atom-block"; + const container = document.createElement("div"); + container.setAttribute(DATA_ATTRS.editorRoot, ""); + const block = document.createElement("div"); + block.setAttribute(DATA_ATTRS.editorBlock, ""); + block.setAttribute(DATA_ATTRS.blockId, blockId); + block.setAttribute(DATA_ATTRS.blockType, "paragraph"); + const wrapper = document.createElement("div"); + wrapper.setAttribute(DATA_ATTRS.blockType, "paragraph"); + const inlineElement = document.createElement("span"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + const atom = document.createElement("span"); + atom.setAttribute(DATA_ATTRS.inlineAtom, ""); + atom.setAttribute(DATA_ATTRS.inlineAtomType, "mention"); + atom.contentEditable = "false"; + atom.textContent = "Ada"; + + inlineElement.appendChild(atom); + wrapper.appendChild(inlineElement); + block.appendChild(wrapper); + container.appendChild(block); + document.body.appendChild(container); + + Object.defineProperty(inlineElement, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 200, 20), + }); + + const documentWithCaret = document as Document & { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => CaretPosition | null; + }; + const originalCaretPositionFromPoint = + documentWithCaret.caretPositionFromPoint; + const originalCreateRange = document.createRange.bind(document); + + documentWithCaret.caretPositionFromPoint = () => ({ + offsetNode: wrapper, + offset: 0, + getClientRect: () => new DOMRect(0, 0, 0, 20), + }); + document.createRange = () => { + const range = originalCreateRange(); + const originalSetStart = range.setStart.bind(range); + let startContainer: Node | null = null; + let startOffset = 0; + range.setStart = (node: Node, offset: number) => { + startContainer = node; + startOffset = offset; + originalSetStart(node, offset); + }; + ( + range as Range & { getBoundingClientRect: () => DOMRect } + ).getBoundingClientRect = () => { + if (startContainer === inlineElement && startOffset === 0) { + return new DOMRect(0, 0, 80, 20); + } + return new DOMRect(80, 0, 0, 20); + }; + return range; + }; + + try { + expect(pointToEditorSelectionPoint(container, 160, 10)).toEqual({ + blockId, + offset: 1, + }); + } finally { + documentWithCaret.caretPositionFromPoint = + originalCaretPositionFromPoint; + document.createRange = originalCreateRange; + container.remove(); + } + }); + + it("moves an inline atom within one editor", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + + try { + expect( + moveInlineAtom({ + source: { editor, blockId, offset: 1 }, + target: { editor, blockId, offset: 3 }, + }), + ).toBe(true); + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "AB" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + ]); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 3 }, + }); + } finally { + editor.destroy(); + } + }); + + it("moves an inline atom between compatible editors", () => { + const sourceEditor = createPresetEditor(); + const targetEditor = createPresetEditor(); + const sourceBlockId = seedInlineAtomDocument(sourceEditor); + const targetBlockId = targetEditor.firstBlock()!.id; + targetEditor.apply([ + { + type: "insert-text", + blockId: targetBlockId, + offset: 0, + text: "Z", + }, + ]); + + try { + expect( + moveInlineAtom({ + source: { + editor: sourceEditor, + blockId: sourceBlockId, + offset: 1, + }, + target: { + editor: targetEditor, + blockId: targetBlockId, + offset: 1, + }, + }), + ).toBe(true); + expect( + sourceEditor.getBlock(sourceBlockId)?.inlineDeltas(), + ).toEqual([{ insert: "AB" }]); + expect( + targetEditor.getBlock(targetBlockId)?.inlineDeltas(), + ).toEqual([ + { insert: "Z" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + ]); + expect(targetEditor.selection).toMatchObject({ + type: "text", + anchor: { blockId: targetBlockId, offset: 2 }, + }); + } finally { + sourceEditor.destroy(); + targetEditor.destroy(); + } + }); + + it("rejects cross-editor moves when the target schema does not support the atom", () => { + const sourceEditor = createPresetEditor(); + const targetEditor = createEditor({ + schema: createDefaultSchema().without(["mention"]), + preset: defaultPreset({ + documentOps: false, + deltaStream: false, + undo: false, + }), + }); + const sourceBlockId = seedInlineAtomDocument(sourceEditor); + const targetBlockId = targetEditor.firstBlock()!.id; + + try { + expect( + moveInlineAtom({ + source: { + editor: sourceEditor, + blockId: sourceBlockId, + offset: 1, + }, + target: { + editor: targetEditor, + blockId: targetBlockId, + offset: 0, + }, + }), + ).toBe(false); + expect( + sourceEditor.getBlock(sourceBlockId)?.inlineDeltas(), + ).toEqual([ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ]); + expect( + targetEditor.getBlock(targetBlockId)?.inlineDeltas(), + ).toEqual([{ insert: "\u200B" }]); + } finally { + sourceEditor.destroy(); + targetEditor.destroy(); + } + }); + + it("destructures an inline atom into selected editable text", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + + try { + expect( + replaceInlineAtomWithText({ + source: { editor, blockId, offset: 1 }, + text: "Ada Lovelace ", + selection: "all", + }), + ).toBe(true); + expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ + { insert: "AAda Lovelace B" }, + ]); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 31 }, + }); + } finally { + editor.destroy(); + } + }); + + it("refreshes inline atom metadata when reconciliation changes atom props", () => { + const editor = createPresetEditor(); + const element = document.createElement("span"); + const firstDelta = [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ]; + const secondDelta = [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-2", label: "Ada" }, + }, + }, + { insert: "B" }, + ]; + + fullReconcileDeltasToDOM(firstDelta, element, editor.schema); + const firstAtom = element.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + expect(getInlineAtomElementData(firstAtom!)).toEqual({ + type: "mention", + props: { id: "user-1", label: "Ada" }, + text: "@Ada", + }); + + fullReconcileDeltasToDOM(secondDelta, element, editor.schema); + const secondAtom = element.querySelector( + `[${DATA_ATTRS.inlineAtom}]`, + ) as HTMLElement | null; + + expect(secondAtom).not.toBe(firstAtom); + expect(firstAtom?.isConnected).toBe(false); + expect(getInlineAtomElementData(secondAtom!)).toEqual({ + type: "mention", + props: { id: "user-2", label: "Ada" }, + text: "@Ada", + }); + + editor.destroy(); + }); + + it("round-trips DOM selection offsets around inline atoms", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + + , + ); + await flushAnimationFrames(2); + }); + + const rootElement = container.querySelector( + `[${DATA_ATTRS.editorRoot}]`, + ) as HTMLElement | null; + const inlineElement = container.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + expect(rootElement).not.toBeNull(); + expect(inlineElement).not.toBeNull(); + expect(domPointToOffset(inlineElement!, inlineElement!, 1)).toBe(1); + expect(domPointToOffset(inlineElement!, inlineElement!, 2)).toBe(2); + + editorSelectionToDOM( + rootElement!, + { blockId, offset: 2 }, + { blockId, offset: 2 }, + ); + + expect(domSelectionToEditor(rootElement!)).toEqual({ + anchor: { blockId, offset: 2 }, + focus: { blockId, offset: 2 }, + }); + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + } + }); +}); diff --git a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx index c83b93f..0b28bc1 100644 --- a/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx +++ b/packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx @@ -13,7 +13,12 @@ import { import { getInlineAtomElementData, getLogicalTextContent, + getLogicalNodeLength, + INLINE_ATOM_CARET_BOUNDARY_TEXT, INLINE_ATOM_REPLACEMENT_TEXT, + findLogicalDOMPoint, + isInlineAtomCaretBoundaryNode, + isInlineAtomHostNode, } from "@pen/dom/field-editor/inlineAtomDom"; import { applyDeltaToDOM, @@ -25,8 +30,10 @@ import { domPointToOffset, domSelectionToEditor, editorSelectionToDOM, + getSelectionOffsets, pointToEditorSelectionPoint, } from "../field-editor/selectionBridge"; +import { handleFieldEditorKeyDown } from "../field-editor/keyHandling"; import { Pen } from "../primitives/index"; ( @@ -216,9 +223,9 @@ describe("Pen inline atom editing", () => { try { fieldEditor.activate(blockId); - expect(fieldEditor.requestDomFocus(inline, "selection-project")).toBe( - true, - ); + expect( + fieldEditor.requestDomFocus(inline, "selection-project"), + ).toBe(true); expect(focusSpy).not.toHaveBeenCalled(); expect(decide).toHaveBeenCalledWith( expect.objectContaining({ @@ -271,6 +278,315 @@ describe("Pen inline atom editing", () => { } }); + it("projects activated text selections before the next input event can use a stale DOM range", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const fieldEditor = new FieldEditorImpl(editor); + const block = editor.getBlock(blockId)!; + const endOffset = block.length(); + const root = document.createElement("div"); + const blockElement = document.createElement("div"); + const inlineElement = document.createElement("span"); + + root.setAttribute(DATA_ATTRS.editorRoot, ""); + blockElement.setAttribute(DATA_ATTRS.editorBlock, ""); + blockElement.setAttribute(DATA_ATTRS.blockId, blockId); + blockElement.setAttribute(DATA_ATTRS.blockType, "paragraph"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + fullReconcileDeltasToDOM( + block.inlineDeltas() as unknown as Parameters< + typeof fullReconcileDeltasToDOM + >[0], + inlineElement, + editor.schema, + ); + blockElement.appendChild(inlineElement); + root.appendChild(blockElement); + document.body.appendChild(root); + fieldEditor.setRootElement(root); + + try { + fieldEditor.activate(blockId); + editorSelectionToDOM( + root, + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + expect(getSelectionOffsets(inlineElement)).toEqual({ + start: 0, + end: 0, + }); + + fieldEditor.activateTextSelection(blockId, endOffset, endOffset); + + expect(getSelectionOffsets(inlineElement)).toEqual({ + start: endOffset, + end: endOffset, + }); + } finally { + fieldEditor.destroy(); + root.remove(); + editor.destroy(); + } + }); + + it("projects activated inline atom range selections synchronously", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const fieldEditor = new FieldEditorImpl(editor); + const block = editor.getBlock(blockId)!; + const root = document.createElement("div"); + const blockElement = document.createElement("div"); + const inlineElement = document.createElement("span"); + + root.setAttribute(DATA_ATTRS.editorRoot, ""); + blockElement.setAttribute(DATA_ATTRS.editorBlock, ""); + blockElement.setAttribute(DATA_ATTRS.blockId, blockId); + blockElement.setAttribute(DATA_ATTRS.blockType, "paragraph"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + fullReconcileDeltasToDOM( + block.inlineDeltas() as unknown as Parameters< + typeof fullReconcileDeltasToDOM + >[0], + inlineElement, + editor.schema, + ); + blockElement.appendChild(inlineElement); + root.appendChild(blockElement); + document.body.appendChild(root); + fieldEditor.setRootElement(root); + + try { + fieldEditor.activate(blockId); + fieldEditor.activateTextSelection(blockId, 1, 2); + + expect(getSelectionOffsets(inlineElement)).toEqual({ + start: 1, + end: 2, + }); + } finally { + fieldEditor.destroy(); + root.remove(); + editor.destroy(); + } + }); + + it("keeps programmatic focus after native focus reports a start caret", async () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const fieldEditor = new FieldEditorImpl(editor); + const block = editor.getBlock(blockId)!; + const root = document.createElement("div"); + const blockElement = document.createElement("div"); + const inlineElement = document.createElement("span"); + + root.setAttribute(DATA_ATTRS.editorRoot, ""); + blockElement.setAttribute(DATA_ATTRS.editorBlock, ""); + blockElement.setAttribute(DATA_ATTRS.blockId, blockId); + blockElement.setAttribute(DATA_ATTRS.blockType, "paragraph"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + fullReconcileDeltasToDOM( + block.inlineDeltas() as unknown as Parameters< + typeof fullReconcileDeltasToDOM + >[0], + inlineElement, + editor.schema, + ); + blockElement.appendChild(inlineElement); + root.appendChild(blockElement); + document.body.appendChild(root); + fieldEditor.setRootElement(root); + + try { + await fieldEditor.focusTextSelection(blockId, 2, 2); + editorSelectionToDOM( + root, + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + document.dispatchEvent(new Event("selectionchange")); + await flushAnimationFrames(2); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 2 }, + focus: { blockId, offset: 2 }, + }); + expect(getSelectionOffsets(inlineElement)).toEqual({ + start: 2, + end: 2, + }); + } finally { + fieldEditor.destroy(); + root.remove(); + editor.destroy(); + } + }); + + it("selects an inline atom with ArrowLeft and then collapses before it", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const activations: Array<{ + blockId: string; + anchorOffset: number; + focusOffset: number; + }> = []; + const fieldEditor = { + focusBlockId: blockId, + inputMode: "richtext" as const, + activeCellCoord: null, + activateCell: vi.fn(), + activateTextSelection: ( + nextBlockId: string, + anchorOffset: number, + focusOffset: number, + ) => { + activations.push({ + blockId: nextBlockId, + anchorOffset, + focusOffset, + }); + }, + deactivate: vi.fn(), + selectAll: vi.fn(() => false), + }; + const ytext = { + length: 3, + toString: () => `A${INLINE_ATOM_REPLACEMENT_TEXT}B`, + toDelta: () => [ + { insert: "A" }, + { + insert: { + type: "mention", + props: { id: "user-1", label: "Ada" }, + }, + }, + { insert: "B" }, + ], + insert: vi.fn(), + delete: vi.fn(), + }; + + try { + expect( + handleFieldEditorKeyDown({ + editor, + fieldEditor, + ytext, + range: { start: 2, end: 2 }, + event: new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + cancelable: true, + }), + }), + ).toBe(true); + + expect(activations.at(-1)).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 2, + }); + + expect( + handleFieldEditorKeyDown({ + editor, + fieldEditor, + ytext, + range: { start: 1, end: 2 }, + event: new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + cancelable: true, + }), + }), + ).toBe(true); + + expect(activations.at(-1)).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 1, + }); + + expect( + handleFieldEditorKeyDown({ + editor, + fieldEditor, + ytext, + range: { start: 2, end: 2 }, + event: new KeyboardEvent("keydown", { + key: "ArrowLeft", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + }), + ).toBe(true); + + expect(activations.at(-1)).toEqual({ + blockId, + anchorOffset: 1, + focusOffset: 2, + }); + } finally { + editor.destroy(); + } + }); + + it("projects caret selections into inline atom boundary text nodes", () => { + const editor = createPresetEditor(); + const blockId = seedInlineAtomDocument(editor); + const block = editor.getBlock(blockId)!; + const inlineElement = document.createElement("span"); + inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); + fullReconcileDeltasToDOM( + block.inlineDeltas() as unknown as Parameters< + typeof fullReconcileDeltasToDOM + >[0], + inlineElement, + editor.schema, + ); + document.body.appendChild(inlineElement); + + try { + const host = inlineElement.querySelector( + `[${DATA_ATTRS.inlineAtomHost}]`, + ) as HTMLElement | null; + expect(host).not.toBeNull(); + expect(isInlineAtomHostNode(host)).toBe(true); + + const afterAtomPoint = findLogicalDOMPoint(inlineElement, 2); + expect( + isInlineAtomCaretBoundaryNode( + afterAtomPoint.node.parentElement, + ), + ).toBe(true); + expect(afterAtomPoint.node.textContent).toBe( + INLINE_ATOM_CARET_BOUNDARY_TEXT, + ); + expect(getLogicalNodeLength(afterAtomPoint.node)).toBe(0); + expect(getLogicalTextContent(inlineElement)).toBe( + `A${INLINE_ATOM_REPLACEMENT_TEXT}B`, + ); + + const selection = window.getSelection(); + expect(selection).not.toBeNull(); + selection!.removeAllRanges(); + const range = document.createRange(); + range.setStart(afterAtomPoint.node, afterAtomPoint.offset); + range.collapse(true); + selection!.addRange(range); + + expect(getSelectionOffsets(inlineElement)).toEqual({ + start: 2, + end: 2, + }); + } finally { + inlineElement.remove(); + editor.destroy(); + } + }); + it("renders inline nodes as logical atom elements", async () => { const editor = createPresetEditor(); seedInlineAtomDocument(editor); @@ -624,463 +940,4 @@ describe("Pen inline atom editing", () => { } }); - it("applies text deltas around inline atoms at logical boundaries", () => { - const editor = createPresetEditor(); - const element = document.createElement("span"); - - fullReconcileDeltasToDOM( - [ - { insert: "A" }, - { - insert: { - type: "mention", - props: { id: "user-1", label: "Ada" }, - }, - }, - { insert: "B" }, - ], - element, - editor.schema, - ); - - const atom = element.querySelector( - `[${DATA_ATTRS.inlineAtom}]`, - ) as HTMLElement | null; - expect(atom).not.toBeNull(); - - expect( - applyDeltaToDOM( - [{ retain: 2 }, { insert: "C" }], - element, - editor.schema, - ), - ).toBe(true); - expect(getLogicalTextContent(element)).toBe( - `A${INLINE_ATOM_REPLACEMENT_TEXT}CB`, - ); - expect(getInlineAtomElementData(atom!)).toEqual({ - type: "mention", - props: { id: "user-1", label: "Ada" }, - text: "@Ada", - }); - expect(atom?.textContent).toBe("@Ada"); - - expect( - applyDeltaToDOM( - [{ retain: 1 }, { delete: 1 }], - element, - editor.schema, - ), - ).toBe(true); - expect(getLogicalTextContent(element)).toBe("ACB"); - expect(atom?.isConnected).toBe(false); - - editor.destroy(); - }); - - it("resolves inline-container tail clicks after an atom to the logical end", () => { - const blockId = "atom-block"; - const container = document.createElement("div"); - container.setAttribute(DATA_ATTRS.editorRoot, ""); - const block = document.createElement("div"); - block.setAttribute(DATA_ATTRS.editorBlock, ""); - block.setAttribute(DATA_ATTRS.blockId, blockId); - block.setAttribute(DATA_ATTRS.blockType, "paragraph"); - const inlineElement = document.createElement("span"); - inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); - const atom = document.createElement("span"); - atom.setAttribute(DATA_ATTRS.inlineAtom, ""); - atom.setAttribute(DATA_ATTRS.inlineAtomType, "mention"); - atom.contentEditable = "false"; - atom.textContent = "Ada"; - - inlineElement.appendChild(atom); - block.appendChild(inlineElement); - container.appendChild(block); - document.body.appendChild(container); - - Object.defineProperty(inlineElement, "getBoundingClientRect", { - configurable: true, - value: () => new DOMRect(0, 0, 200, 20), - }); - - const documentWithCaret = document as Document & { - caretPositionFromPoint?: ( - x: number, - y: number, - ) => CaretPosition | null; - }; - const originalCaretPositionFromPoint = - documentWithCaret.caretPositionFromPoint; - const originalCreateRange = document.createRange.bind(document); - - documentWithCaret.caretPositionFromPoint = () => ({ - offsetNode: inlineElement, - offset: 0, - getClientRect: () => new DOMRect(0, 0, 0, 20), - }); - document.createRange = () => { - const range = originalCreateRange(); - const originalSetStart = range.setStart.bind(range); - let startContainer: Node | null = null; - let startOffset = 0; - range.setStart = (node: Node, offset: number) => { - startContainer = node; - startOffset = offset; - originalSetStart(node, offset); - }; - ( - range as Range & { getBoundingClientRect: () => DOMRect } - ).getBoundingClientRect = () => { - if (startContainer === inlineElement && startOffset === 0) { - return new DOMRect(0, 0, 80, 20); - } - return new DOMRect(80, 0, 0, 20); - }; - return range; - }; - - try { - expect(pointToEditorSelectionPoint(container, 160, 10)).toEqual({ - blockId, - offset: 1, - }); - } finally { - documentWithCaret.caretPositionFromPoint = - originalCaretPositionFromPoint; - document.createRange = originalCreateRange; - container.remove(); - } - }); - - it("resolves inline-wrapper tail clicks after an atom to the logical end", () => { - const blockId = "atom-block"; - const container = document.createElement("div"); - container.setAttribute(DATA_ATTRS.editorRoot, ""); - const block = document.createElement("div"); - block.setAttribute(DATA_ATTRS.editorBlock, ""); - block.setAttribute(DATA_ATTRS.blockId, blockId); - block.setAttribute(DATA_ATTRS.blockType, "paragraph"); - const wrapper = document.createElement("div"); - wrapper.setAttribute(DATA_ATTRS.blockType, "paragraph"); - const inlineElement = document.createElement("span"); - inlineElement.setAttribute(DATA_ATTRS.inlineContent, ""); - const atom = document.createElement("span"); - atom.setAttribute(DATA_ATTRS.inlineAtom, ""); - atom.setAttribute(DATA_ATTRS.inlineAtomType, "mention"); - atom.contentEditable = "false"; - atom.textContent = "Ada"; - - inlineElement.appendChild(atom); - wrapper.appendChild(inlineElement); - block.appendChild(wrapper); - container.appendChild(block); - document.body.appendChild(container); - - Object.defineProperty(inlineElement, "getBoundingClientRect", { - configurable: true, - value: () => new DOMRect(0, 0, 200, 20), - }); - - const documentWithCaret = document as Document & { - caretPositionFromPoint?: ( - x: number, - y: number, - ) => CaretPosition | null; - }; - const originalCaretPositionFromPoint = - documentWithCaret.caretPositionFromPoint; - const originalCreateRange = document.createRange.bind(document); - - documentWithCaret.caretPositionFromPoint = () => ({ - offsetNode: wrapper, - offset: 0, - getClientRect: () => new DOMRect(0, 0, 0, 20), - }); - document.createRange = () => { - const range = originalCreateRange(); - const originalSetStart = range.setStart.bind(range); - let startContainer: Node | null = null; - let startOffset = 0; - range.setStart = (node: Node, offset: number) => { - startContainer = node; - startOffset = offset; - originalSetStart(node, offset); - }; - ( - range as Range & { getBoundingClientRect: () => DOMRect } - ).getBoundingClientRect = () => { - if (startContainer === inlineElement && startOffset === 0) { - return new DOMRect(0, 0, 80, 20); - } - return new DOMRect(80, 0, 0, 20); - }; - return range; - }; - - try { - expect(pointToEditorSelectionPoint(container, 160, 10)).toEqual({ - blockId, - offset: 1, - }); - } finally { - documentWithCaret.caretPositionFromPoint = - originalCaretPositionFromPoint; - document.createRange = originalCreateRange; - container.remove(); - } - }); - - it("moves an inline atom within one editor", () => { - const editor = createPresetEditor(); - const blockId = seedInlineAtomDocument(editor); - - try { - expect( - moveInlineAtom({ - source: { editor, blockId, offset: 1 }, - target: { editor, blockId, offset: 3 }, - }), - ).toBe(true); - expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ - { insert: "AB" }, - { - insert: { - type: "mention", - props: { id: "user-1", label: "Ada" }, - }, - }, - ]); - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 3 }, - }); - } finally { - editor.destroy(); - } - }); - - it("moves an inline atom between compatible editors", () => { - const sourceEditor = createPresetEditor(); - const targetEditor = createPresetEditor(); - const sourceBlockId = seedInlineAtomDocument(sourceEditor); - const targetBlockId = targetEditor.firstBlock()!.id; - targetEditor.apply([ - { - type: "insert-text", - blockId: targetBlockId, - offset: 0, - text: "Z", - }, - ]); - - try { - expect( - moveInlineAtom({ - source: { - editor: sourceEditor, - blockId: sourceBlockId, - offset: 1, - }, - target: { - editor: targetEditor, - blockId: targetBlockId, - offset: 1, - }, - }), - ).toBe(true); - expect( - sourceEditor.getBlock(sourceBlockId)?.inlineDeltas(), - ).toEqual([{ insert: "AB" }]); - expect( - targetEditor.getBlock(targetBlockId)?.inlineDeltas(), - ).toEqual([ - { insert: "Z" }, - { - insert: { - type: "mention", - props: { id: "user-1", label: "Ada" }, - }, - }, - ]); - expect(targetEditor.selection).toMatchObject({ - type: "text", - anchor: { blockId: targetBlockId, offset: 2 }, - }); - } finally { - sourceEditor.destroy(); - targetEditor.destroy(); - } - }); - - it("rejects cross-editor moves when the target schema does not support the atom", () => { - const sourceEditor = createPresetEditor(); - const targetEditor = createEditor({ - schema: createDefaultSchema().without(["mention"]), - preset: defaultPreset({ - documentOps: false, - deltaStream: false, - undo: false, - }), - }); - const sourceBlockId = seedInlineAtomDocument(sourceEditor); - const targetBlockId = targetEditor.firstBlock()!.id; - - try { - expect( - moveInlineAtom({ - source: { - editor: sourceEditor, - blockId: sourceBlockId, - offset: 1, - }, - target: { - editor: targetEditor, - blockId: targetBlockId, - offset: 0, - }, - }), - ).toBe(false); - expect( - sourceEditor.getBlock(sourceBlockId)?.inlineDeltas(), - ).toEqual([ - { insert: "A" }, - { - insert: { - type: "mention", - props: { id: "user-1", label: "Ada" }, - }, - }, - { insert: "B" }, - ]); - expect( - targetEditor.getBlock(targetBlockId)?.inlineDeltas(), - ).toEqual([{ insert: "\u200B" }]); - } finally { - sourceEditor.destroy(); - targetEditor.destroy(); - } - }); - - it("destructures an inline atom into selected editable text", () => { - const editor = createPresetEditor(); - const blockId = seedInlineAtomDocument(editor); - - try { - expect( - replaceInlineAtomWithText({ - source: { editor, blockId, offset: 1 }, - text: "Ada Lovelace ", - selection: "all", - }), - ).toBe(true); - expect(editor.getBlock(blockId)?.inlineDeltas()).toEqual([ - { insert: "AAda Lovelace B" }, - ]); - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 1 }, - focus: { blockId, offset: 31 }, - }); - } finally { - editor.destroy(); - } - }); - - it("refreshes inline atom metadata when reconciliation changes atom props", () => { - const editor = createPresetEditor(); - const element = document.createElement("span"); - const firstDelta = [ - { insert: "A" }, - { - insert: { - type: "mention", - props: { id: "user-1", label: "Ada" }, - }, - }, - { insert: "B" }, - ]; - const secondDelta = [ - { insert: "A" }, - { - insert: { - type: "mention", - props: { id: "user-2", label: "Ada" }, - }, - }, - { insert: "B" }, - ]; - - fullReconcileDeltasToDOM(firstDelta, element, editor.schema); - const firstAtom = element.querySelector( - `[${DATA_ATTRS.inlineAtom}]`, - ) as HTMLElement | null; - expect(getInlineAtomElementData(firstAtom!)).toEqual({ - type: "mention", - props: { id: "user-1", label: "Ada" }, - text: "@Ada", - }); - - fullReconcileDeltasToDOM(secondDelta, element, editor.schema); - const secondAtom = element.querySelector( - `[${DATA_ATTRS.inlineAtom}]`, - ) as HTMLElement | null; - - expect(secondAtom).not.toBe(firstAtom); - expect(firstAtom?.isConnected).toBe(false); - expect(getInlineAtomElementData(secondAtom!)).toEqual({ - type: "mention", - props: { id: "user-2", label: "Ada" }, - text: "@Ada", - }); - - editor.destroy(); - }); - - it("round-trips DOM selection offsets around inline atoms", async () => { - const editor = createPresetEditor(); - const blockId = seedInlineAtomDocument(editor); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - try { - await act(async () => { - root.render( - - - , - ); - await flushAnimationFrames(2); - }); - - const rootElement = container.querySelector( - `[${DATA_ATTRS.editorRoot}]`, - ) as HTMLElement | null; - const inlineElement = container.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; - expect(rootElement).not.toBeNull(); - expect(inlineElement).not.toBeNull(); - expect(domPointToOffset(inlineElement!, inlineElement!, 1)).toBe(1); - expect(domPointToOffset(inlineElement!, inlineElement!, 2)).toBe(2); - - editorSelectionToDOM( - rootElement!, - { blockId, offset: 2 }, - { blockId, offset: 2 }, - ); - - expect(domSelectionToEditor(rootElement!)).toEqual({ - anchor: { blockId, offset: 2 }, - focus: { blockId, offset: 2 }, - }); - } finally { - await act(async () => { - root.unmount(); - }); - container.remove(); - editor.destroy(); - } - }); }); diff --git a/packages/rendering/react/src/__tests__/keyHandling.test.ts b/packages/rendering/react/src/__tests__/keyHandling.test.ts index c321730..c19fbf4 100644 --- a/packages/rendering/react/src/__tests__/keyHandling.test.ts +++ b/packages/rendering/react/src/__tests__/keyHandling.test.ts @@ -12,6 +12,7 @@ import { handleEditorKeyBindings, handleFieldEditorKeyDown, } from "../field-editor/keyHandling"; +import { resolveShiftClickInlineAtomSelection } from "../primitives/editor/inlineAtomInteraction"; import type { FieldEditorTextLike } from "../field-editor/crdt"; type BlocksMapLike = { @@ -679,6 +680,175 @@ describe("@pen/react key binding contexts", () => { }); }); +describe("@pen/react field editor inline atom navigation", () => { + const createAtomText = (): FieldEditorTextLike => ({ + length: 1, + toString: () => "", + toDelta: () => [ + { insert: { type: "contact", props: { label: "Ada" } } }, + ], + insert: () => {}, + delete: () => {}, + observe: () => {}, + unobserve: () => {}, + }); + + it("selects the atom to the left when the atom-only text serializes empty", () => { + const editor = createPresetEditor({ + preset: { + shortcuts: false, + }, + }); + const blockId = editor.firstBlock()!.id; + const fieldEditor = createFieldEditorMock(blockId); + const atomText = createAtomText(); + + const handled = handleFieldEditorKeyDown({ + event: createKeyEvent("ArrowLeft"), + editor, + fieldEditor: fieldEditor.controller, + ytext: atomText, + range: { start: 1, end: 1 }, + }); + + expect(handled).toBe(true); + expect(fieldEditor.activations).toEqual([ + { blockId, anchorOffset: 0, focusOffset: 1 }, + ]); + + editor.destroy(); + }); + + it("preserves selection direction when shift-selecting an atom to the left", () => { + const editor = createPresetEditor({ + preset: { + shortcuts: false, + }, + }); + const blockId = editor.firstBlock()!.id; + const fieldEditor = createFieldEditorMock(blockId); + + const handled = handleFieldEditorKeyDown({ + event: createKeyEvent("ArrowLeft", { shiftKey: true }), + editor, + fieldEditor: fieldEditor.controller, + ytext: createAtomText(), + range: { start: 1, end: 1 }, + }); + + expect(handled).toBe(true); + expect(fieldEditor.activations).toEqual([ + { blockId, anchorOffset: 1, focusOffset: 0 }, + ]); + + editor.destroy(); + }); + + it("shrinks a shift-selected atom when extending back to the anchor", () => { + const editor = createPresetEditor({ + preset: { + shortcuts: false, + }, + }); + const blockId = editor.firstBlock()!.id; + editor.selectText(blockId, 1, 0); + const fieldEditor = createFieldEditorMock(blockId); + + const handled = handleFieldEditorKeyDown({ + event: createKeyEvent("ArrowRight", { shiftKey: true }), + editor, + fieldEditor: fieldEditor.controller, + ytext: createAtomText(), + range: { start: 0, end: 1 }, + }); + + expect(handled).toBe(true); + expect(fieldEditor.activations).toEqual([ + { blockId, anchorOffset: 1, focusOffset: 1 }, + ]); + + editor.destroy(); + }); +}); + +describe("@pen/react inline atom shift-click selection", () => { + it("extends a selected atom range to the clicked atom on the right", () => { + const editor = createPresetEditor({ preset: { shortcuts: false } }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "xxx" }, + ]); + editor.selectText(blockId, 0, 1); + + expect( + resolveShiftClickInlineAtomSelection(editor, blockId, 1), + ).toEqual({ + blockId, + anchorOffset: 0, + focusOffset: 2, + }); + + editor.destroy(); + }); + + it("extends a selected atom range to the clicked atom on the left", () => { + const editor = createPresetEditor({ preset: { shortcuts: false } }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "xxx" }, + ]); + editor.selectText(blockId, 1, 2); + + expect( + resolveShiftClickInlineAtomSelection(editor, blockId, 0), + ).toEqual({ + blockId, + anchorOffset: 2, + focusOffset: 0, + }); + + editor.destroy(); + }); + + it("deselects the right edge atom when shift-clicking it again", () => { + const editor = createPresetEditor({ preset: { shortcuts: false } }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "xxx" }, + ]); + editor.selectText(blockId, 0, 2); + + expect( + resolveShiftClickInlineAtomSelection(editor, blockId, 1), + ).toEqual({ + blockId, + anchorOffset: 0, + focusOffset: 1, + }); + + editor.destroy(); + }); + + it("deselects the left edge atom when shift-clicking it again", () => { + const editor = createPresetEditor({ preset: { shortcuts: false } }); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "xxx" }, + ]); + editor.selectText(blockId, 0, 2); + + expect( + resolveShiftClickInlineAtomSelection(editor, blockId, 0), + ).toEqual({ + blockId, + anchorOffset: 2, + focusOffset: 1, + }); + + editor.destroy(); + }); +}); + describe("@pen/react field editor Tab handling", () => { it("handles Tab for list nesting and preserves selection", () => { const editor = createPresetEditor({ diff --git a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx index 627fbcc..234c6be 100644 --- a/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx +++ b/packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx @@ -27,6 +27,8 @@ async function flushAnimationFrames(count = 1): Promise { } } +const SLOW_BEFOREINPUT_TEST_TIMEOUT_MS = 60_000; + function createKeyEvent( key: string, options: KeyboardEventInit = {}, @@ -1846,7 +1848,7 @@ describe("@pen/react selected text deletion", () => { }); container.remove(); editor.destroy(); - }); + }, SLOW_BEFOREINPUT_TEST_TIMEOUT_MS); it("converts '- ' into a bullet list item via beforeinput", async () => { const editor = createEditor(); @@ -2005,7 +2007,7 @@ describe("@pen/react selected text deletion", () => { }); container.remove(); editor.destroy(); - }); + }, SLOW_BEFOREINPUT_TEST_TIMEOUT_MS); it("converts '[ ] ' into a check list item via beforeinput", async () => { const editor = createEditor(); @@ -2094,7 +2096,7 @@ describe("@pen/react selected text deletion", () => { }); container.remove(); editor.destroy(); - }); + }, SLOW_BEFOREINPUT_TEST_TIMEOUT_MS); it("does not convert headings with list triggers via beforeinput", async () => { const editor = createEditor(); @@ -2607,7 +2609,7 @@ describe("@pen/react selected text deletion", () => { }); container.remove(); editor.destroy(); - }); + }, SLOW_BEFOREINPUT_TEST_TIMEOUT_MS); it("deletes the full-document selection after first cmd+a in flow documents", async () => { const editor = createEditor({ @@ -4730,8 +4732,8 @@ describe("@pen/react selected text deletion", () => { focus: { blockId, offset: 2 }, }); expect(domSelectionToEditor(rootElement!)).toMatchObject({ - anchor: { blockId, offset: 2 }, - focus: { blockId, offset: 2 }, + anchor: { blockId, offset: 3 }, + focus: { blockId, offset: 3 }, }); await act(async () => { diff --git a/packages/rendering/react/src/primitives/editor/InlineAtomPortalLayer.tsx b/packages/rendering/react/src/primitives/editor/InlineAtomPortalLayer.tsx new file mode 100644 index 0000000..3bbf95b --- /dev/null +++ b/packages/rendering/react/src/primitives/editor/InlineAtomPortalLayer.tsx @@ -0,0 +1,121 @@ +import React, { useLayoutEffect, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import type { Editor, SelectionState } from "@pen/types"; +import type { ResolvedInlineAtomInteractions } from "../../context/editorContext"; +import { DATA_ATTRS } from "../../utils/dataAttributes"; +import { + attachInlineAtomWrapperInteractions, + getInlineAtomDragSnapshot, + getInlineAtomRenderInteractionProps, + isInlineAtomDragSource, + subscribeInlineAtomDragSnapshot, +} from "./inlineAtomInteraction"; +import { + isInlineAtomSelected, + type InlineAtomRenderTarget, +} from "./inlineAtomTargets"; + +export function InlineAtomPortalLayer(props: { + editor: Editor; + blockId: string; + targets: InlineAtomRenderTarget[]; + selection: SelectionState; + interactions: ResolvedInlineAtomInteractions; + readonly: boolean; +}) { + const { editor, blockId, targets, selection, interactions, readonly } = + props; + const inlineAtomDragSnapshot = useSyncExternalStore( + subscribeInlineAtomDragSnapshot, + getInlineAtomDragSnapshot, + getInlineAtomDragSnapshot, + ); + + const inlineAtomPortals = targets.flatMap((target) => { + if (!target.renderer) { + return []; + } + + const selected = isInlineAtomSelected(selection, blockId, target.offset); + const dragging = isInlineAtomDragSource( + inlineAtomDragSnapshot, + editor, + blockId, + target.offset, + ); + return [ + createPortal( + target.renderer({ + blockId, + offset: target.offset, + type: target.type, + props: target.props, + text: target.text, + selected, + interaction: getInlineAtomRenderInteractionProps( + { + element: target.element, + editor, + blockId, + offset: target.offset, + type: target.type, + text: target.text, + props: target.props, + selected, + interactions, + readonly, + }, + dragging, + ), + }), + target.element, + target.key, + ), + ]; + }); + + useLayoutEffect(() => { + targets.forEach((target) => { + target.element.toggleAttribute( + DATA_ATTRS.selected, + isInlineAtomSelected(selection, blockId, target.offset), + ); + target.element.toggleAttribute( + DATA_ATTRS.inlineAtomDragging, + isInlineAtomDragSource( + inlineAtomDragSnapshot, + editor, + blockId, + target.offset, + ), + ); + }); + }, [blockId, editor, inlineAtomDragSnapshot, targets, selection]); + + useLayoutEffect(() => { + const cleanups = targets.map((target) => + attachInlineAtomWrapperInteractions({ + element: target.element, + editor, + blockId, + offset: target.offset, + type: target.type, + text: target.text, + props: target.props, + selected: isInlineAtomSelected( + selection, + blockId, + target.offset, + ), + interactions, + readonly, + }), + ); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, [blockId, editor, interactions, targets, readonly, selection]); + + return <>{inlineAtomPortals}; +} diff --git a/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts b/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts index d0832a5..8fdc6e2 100644 --- a/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts +++ b/packages/rendering/react/src/primitives/editor/inlineAtomInteraction.ts @@ -88,6 +88,14 @@ export function attachInlineAtomWrapperInteractions( options: InlineAtomWrapperInteractionOptions, ): () => void { const handlePointerDown = (event: PointerEvent) => { + if (event.button === 0 && event.shiftKey && !options.readonly) { + if (selectInlineAtomRangeFromShiftClick(options)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + if ( event.button !== 0 || options.readonly || @@ -422,6 +430,115 @@ function destructureInlineAtom( return true; } +export function resolveShiftClickInlineAtomSelection( + editor: Editor, + blockId: string, + atomOffset: number, +): { blockId: string; anchorOffset: number; focusOffset: number } { + const atomStart = atomOffset; + const atomEnd = atomOffset + 1; + const selection = editor.selection; + if ( + selection?.type !== "text" || + selection.isMultiBlock || + selection.anchor.blockId !== blockId || + selection.focus.blockId !== blockId + ) { + return { + blockId, + anchorOffset: atomStart, + focusOffset: atomEnd, + }; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + if (!selection.isCollapsed) { + if (atomEnd <= selectionStart) { + return { + blockId, + anchorOffset: selectionEnd, + focusOffset: atomStart, + }; + } + if (atomStart >= selectionEnd) { + return { + blockId, + anchorOffset: selectionStart, + focusOffset: atomEnd, + }; + } + if (atomStart === selectionStart && atomEnd === selectionEnd) { + return { + blockId, + anchorOffset: atomEnd, + focusOffset: atomEnd, + }; + } + if (atomStart === selectionStart) { + return { + blockId, + anchorOffset: selectionEnd, + focusOffset: atomEnd, + }; + } + if (atomEnd === selectionEnd) { + return { + blockId, + anchorOffset: selectionStart, + focusOffset: atomStart, + }; + } + return { + blockId, + anchorOffset: selection.anchor.offset, + focusOffset: selection.focus.offset, + }; + } + + const anchorOffset = selection.anchor.offset; + return { + blockId, + anchorOffset, + focusOffset: anchorOffset <= atomStart ? atomEnd : atomStart, + }; +} + +function selectInlineAtomRangeFromShiftClick( + options: InlineAtomWrapperInteractionOptions, +): boolean { + const target = resolveShiftClickInlineAtomSelection( + options.editor, + options.blockId, + options.offset, + ); + const fieldEditor = getAttachedFieldEditor( + options.editor, + ) as FieldEditorSession | null; + if (fieldEditor?.activateTextSelection) { + fieldEditor.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + fieldEditor.focus(); + return true; + } + + options.editor.selectText( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + return true; +} + function canDestructure(options: InlineAtomWrapperInteractionOptions): boolean { return options.interactions.destructure !== false; } diff --git a/packages/rendering/react/src/primitives/editor/inlineAtomTargets.ts b/packages/rendering/react/src/primitives/editor/inlineAtomTargets.ts new file mode 100644 index 0000000..071fbc6 --- /dev/null +++ b/packages/rendering/react/src/primitives/editor/inlineAtomTargets.ts @@ -0,0 +1,199 @@ +import type { SchemaRegistry, SelectionState } from "@pen/types"; +import { + INLINE_ATOM_REPLACEMENT_TEXT, + resolveInlineAtomDisplayText, + resolveInlineAtomInsert, +} from "@pen/dom/field-editor/inlineAtomModel"; +import type { + InlineAtomRenderer, + InlineAtomRenderers, +} from "../../context/editorContext"; +import { DATA_ATTRS } from "../../utils/dataAttributes"; + +export interface InlineAtomRenderTarget { + key: string; + element: HTMLElement; + renderer?: InlineAtomRenderer; + type: string; + props: Record; + text: string; + offset: number; +} + +export function resolveNextInlineAtomTargets( + root: HTMLElement | null, + renderers: InlineAtomRenderers | undefined, + registry: Pick, + deltas: readonly { + insert: string | Record; + }[], + currentTargets: InlineAtomRenderTarget[], +): InlineAtomRenderTarget[] { + if (!root) { + return currentTargets.length === 0 ? currentTargets : []; + } + + const descriptors = getInlineAtomDescriptors(deltas, registry); + const atomElements = Array.from( + root.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), + ); + const nextTargets = atomElements.flatMap( + (element, index): InlineAtomRenderTarget[] => { + const data = descriptors[index]; + if (!data) { + return []; + } + + const renderer = renderers?.[data.type]; + if (renderer) { + clearInlineAtomFallbackText(element, data.text); + } + + return [ + { + key: getInlineAtomTargetKey(data, index), + element, + renderer, + type: data.type, + props: data.props, + text: data.text, + offset: data.offset, + }, + ]; + }, + ); + + return areInlineAtomTargetsEqual(currentTargets, nextTargets) + ? currentTargets + : nextTargets; +} + +function getInlineAtomDescriptors( + deltas: readonly { + insert: string | Record; + }[], + registry: Pick, +): Array<{ + type: string; + props: Record; + text: string; + offset: number; +}> { + const descriptors: Array<{ + type: string; + props: Record; + text: string; + offset: number; + }> = []; + let offset = 0; + + for (const delta of deltas) { + if (typeof delta.insert === "string") { + offset += delta.insert.length; + continue; + } + + const atom = resolveInlineAtomInsert(delta.insert); + if (atom) { + descriptors.push({ + ...atom, + text: resolveInlineAtomDisplayText(atom, registry), + offset, + }); + } + offset += 1; + } + + return descriptors; +} + +export function isInlineAtomSelected( + selection: SelectionState, + blockId: string, + offset: number, +): boolean { + if ( + selection?.type !== "text" || + selection.isCollapsed || + selection.anchor.blockId !== blockId || + selection.focus.blockId !== blockId + ) { + return false; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + return selectionStart <= offset && selectionEnd >= offset + 1; +} + +function areInlineAtomTargetsEqual( + currentTargets: InlineAtomRenderTarget[], + nextTargets: InlineAtomRenderTarget[], +): boolean { + if (currentTargets.length !== nextTargets.length) { + return false; + } + + return currentTargets.every((target, index) => { + const nextTarget = nextTargets[index]; + return ( + target.key === nextTarget.key && + target.element === nextTarget.element && + target.renderer === nextTarget.renderer && + target.offset === nextTarget.offset && + target.text === nextTarget.text && + shallowEqualRecords(target.props, nextTarget.props) + ); + }); +} + +function getInlineAtomTargetKey( + data: { type: string; props: Record; text: string }, + index: number, +): string { + return `${index}:${data.type}:${data.text}:${JSON.stringify(data.props)}`; +} + +function clearInlineAtomFallbackText(element: HTMLElement, text: string): void { + if ( + element.childNodes.length === 1 && + element.firstChild?.nodeType === Node.TEXT_NODE && + element.textContent === text + ) { + element.replaceChildren(); + return; + } + + for (const child of Array.from(element.childNodes)) { + if ( + child.nodeType === Node.TEXT_NODE && + (child.textContent === text || + child.textContent === INLINE_ATOM_REPLACEMENT_TEXT) + ) { + child.remove(); + } + } +} + +function shallowEqualRecords( + left: Record, + right: Record, +): boolean { + if (left === right) { + return true; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => Object.is(left[key], right[key])); +} diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 78b81d7..6871e1c 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -2,26 +2,19 @@ import React, { useRef, useLayoutEffect, useState, - useSyncExternalStore, } from "react"; -import { createPortal } from "react-dom"; import { getOpOriginType, type Editor, type InlineDecoration, - type SelectionState, } from "@pen/types"; import { - domPointToLogicalOffset, - getInlineAtomElementData, getLogicalTextContent, - INLINE_ATOM_REPLACEMENT_TEXT, } from "@pen/dom/field-editor/inlineAtomDom"; +import { INLINE_ATOM_REPLACEMENT_TEXT } from "@pen/dom/field-editor/inlineAtomModel"; import { useEditorContentContext } from "../../context/editorContentContext"; import { useEditorContext, - type InlineAtomRenderer, - type InlineAtomRenderers, } from "../../context/editorContext"; import { useFieldEditorContext } from "../../context/fieldEditorContext"; import { fullReconcileDeltasToDOM } from "../../field-editor/reconciler"; @@ -38,13 +31,11 @@ import { fieldEditorTextEntryAttrs } from "../../utils/fieldEditorTextEntryAttrs import { applyInlineDecorationsToDeltas } from "../../utils/inlineDecorations"; import { isInlineContentEmpty } from "../../utils/editorEmptyState"; import { resolveInlinePlaceholderVisibility } from "../../utils/placeholderVisibility"; +import { InlineAtomPortalLayer } from "./InlineAtomPortalLayer"; import { - attachInlineAtomWrapperInteractions, - getInlineAtomDragSnapshot, - getInlineAtomRenderInteractionProps, - isInlineAtomDragSource, - subscribeInlineAtomDragSnapshot, -} from "./inlineAtomInteraction"; + resolveNextInlineAtomTargets, + type InlineAtomRenderTarget, +} from "./inlineAtomTargets"; export interface InlineContentProps extends AsChildProps { blockId: string; @@ -53,16 +44,6 @@ export interface InlineContentProps extends AsChildProps { ref?: React.Ref; } -interface InlineAtomRenderTarget { - key: string; - element: HTMLElement; - renderer?: InlineAtomRenderer; - type: string; - props: Record; - text: string; - offset: number; -} - export function InlineContent(props: InlineContentProps) { const { blockId, className, placeholder: placeholderProp, ...rest } = props; const { editor, inlineAtomInteractions, inlineAtomRenderers, readonly } = @@ -84,11 +65,6 @@ export function InlineContent(props: InlineContentProps) { const [inlineAtomTargets, setInlineAtomTargets] = useState< InlineAtomRenderTarget[] >([]); - const inlineAtomDragSnapshot = useSyncExternalStore( - subscribeInlineAtomDragSnapshot, - getInlineAtomDragSnapshot, - getInlineAtomDragSnapshot, - ); const isExpandedOwnedBlock = fieldEditorState.mode === "expanded" && fieldEditorState.activeBlockIds.includes(blockId); @@ -159,6 +135,8 @@ export function InlineContent(props: InlineContentProps) { const nextTargets = resolveNextInlineAtomTargets( elementRef.current, inlineAtomRenderers, + editor.schema, + renderedDeltas, inlineAtomTargetsRef.current, ); if (nextTargets !== inlineAtomTargetsRef.current) { @@ -292,103 +270,6 @@ export function InlineContent(props: InlineContentProps) { } : undefined, }; - const inlineAtomPortals = inlineAtomTargets.flatMap((target) => { - if (!target.renderer) { - return []; - } - - const selected = isInlineAtomSelected( - selection, - blockId, - target.offset, - ); - const dragging = isInlineAtomDragSource( - inlineAtomDragSnapshot, - editor, - blockId, - target.offset, - ); - return [ - createPortal( - target.renderer({ - blockId, - offset: target.offset, - type: target.type, - props: target.props, - text: target.text, - selected, - interaction: getInlineAtomRenderInteractionProps( - { - element: target.element, - editor, - blockId, - offset: target.offset, - type: target.type, - text: target.text, - props: target.props, - selected, - interactions: inlineAtomInteractions, - readonly, - }, - dragging, - ), - }), - target.element, - target.key, - ), - ]; - }); - - useLayoutEffect(() => { - inlineAtomTargets.forEach((target) => { - target.element.toggleAttribute( - DATA_ATTRS.selected, - isInlineAtomSelected(selection, blockId, target.offset), - ); - target.element.toggleAttribute( - DATA_ATTRS.inlineAtomDragging, - isInlineAtomDragSource( - inlineAtomDragSnapshot, - editor, - blockId, - target.offset, - ), - ); - }); - }, [blockId, editor, inlineAtomDragSnapshot, inlineAtomTargets, selection]); - - useLayoutEffect(() => { - const cleanups = inlineAtomTargets.map((target) => - attachInlineAtomWrapperInteractions({ - element: target.element, - editor, - blockId, - offset: target.offset, - type: target.type, - text: target.text, - props: target.props, - selected: isInlineAtomSelected( - selection, - blockId, - target.offset, - ), - interactions: inlineAtomInteractions, - readonly, - }), - ); - - return () => { - cleanups.forEach((cleanup) => cleanup()); - }; - }, [ - blockId, - editor, - inlineAtomInteractions, - inlineAtomTargets, - readonly, - selection, - ]); - return ( <> {renderAsChild( @@ -396,143 +277,18 @@ export function InlineContent(props: InlineContentProps) { "span", primitiveProps, )} - {inlineAtomPortals} + ); } -function resolveNextInlineAtomTargets( - root: HTMLElement | null, - renderers: InlineAtomRenderers | undefined, - currentTargets: InlineAtomRenderTarget[], -): InlineAtomRenderTarget[] { - if (!root) { - return currentTargets.length === 0 ? currentTargets : []; - } - - const nextTargets = Array.from( - root.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), - ).flatMap((element, index): InlineAtomRenderTarget[] => { - const data = getInlineAtomElementData(element); - if (!data) { - return []; - } - - const renderer = renderers?.[data.type]; - if (renderer) { - clearInlineAtomFallbackText(element, data.text); - } - const offset = domPointToLogicalOffset(root, element, 0); - - return [ - { - key: getInlineAtomTargetKey(data, index), - element, - renderer, - type: data.type, - props: data.props, - text: data.text, - offset, - }, - ]; - }); - - return areInlineAtomTargetsEqual(currentTargets, nextTargets) - ? currentTargets - : nextTargets; -} - -function areInlineAtomTargetsEqual( - currentTargets: InlineAtomRenderTarget[], - nextTargets: InlineAtomRenderTarget[], -): boolean { - if (currentTargets.length !== nextTargets.length) { - return false; - } - - return currentTargets.every((target, index) => { - const nextTarget = nextTargets[index]; - return ( - target.key === nextTarget.key && - target.element === nextTarget.element && - target.renderer === nextTarget.renderer && - target.offset === nextTarget.offset && - target.text === nextTarget.text && - shallowEqualRecords(target.props, nextTarget.props) - ); - }); -} - -function getInlineAtomTargetKey( - data: { type: string; props: Record; text: string }, - index: number, -): string { - return `${index}:${data.type}:${data.text}:${JSON.stringify(data.props)}`; -} - -function isInlineAtomSelected( - selection: SelectionState, - blockId: string, - offset: number, -): boolean { - if ( - selection?.type !== "text" || - selection.isCollapsed || - selection.anchor.blockId !== blockId || - selection.focus.blockId !== blockId - ) { - return false; - } - - const selectionStart = Math.min( - selection.anchor.offset, - selection.focus.offset, - ); - const selectionEnd = Math.max( - selection.anchor.offset, - selection.focus.offset, - ); - return selectionStart <= offset && selectionEnd >= offset + 1; -} - -function clearInlineAtomFallbackText(element: HTMLElement, text: string): void { - if ( - element.childNodes.length === 1 && - element.firstChild?.nodeType === Node.TEXT_NODE && - element.textContent === text - ) { - element.replaceChildren(); - return; - } - - for (const child of Array.from(element.childNodes)) { - if ( - child.nodeType === Node.TEXT_NODE && - (child.textContent === text || - child.textContent === INLINE_ATOM_REPLACEMENT_TEXT) - ) { - child.remove(); - } - } -} - -function shallowEqualRecords( - left: Record, - right: Record, -): boolean { - if (left === right) { - return true; - } - - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) { - return false; - } - - return leftKeys.every((key) => Object.is(left[key], right[key])); -} - function getInlineContentClassName( className: string | undefined, emptyInlineCompletionText: string | null, diff --git a/packages/rendering/react/src/utils/dataAttributes.ts b/packages/rendering/react/src/utils/dataAttributes.ts index 749ed63..2149803 100644 --- a/packages/rendering/react/src/utils/dataAttributes.ts +++ b/packages/rendering/react/src/utils/dataAttributes.ts @@ -22,7 +22,9 @@ export const DATA_ATTRS = { editorBlock: "data-pen-editor-block", inlineContent: "data-pen-inline-content", inlineAtom: "data-pen-inline-atom", + inlineAtomHost: "data-pen-inline-atom-host", inlineAtomType: "data-pen-inline-atom-type", + inlineAtomProps: "data-pen-inline-atom-props", inlineAtomDragging: "data-pen-inline-atom-dragging", fieldEditorSurface: "data-pen-field-editor-surface", fieldEditorActiveSurface: "data-pen-field-editor-active-surface", diff --git a/packages/rendering/vue/src/internal/editorState.ts b/packages/rendering/vue/src/internal/editorState.ts index 1f4b069..aa65da6 100644 --- a/packages/rendering/vue/src/internal/editorState.ts +++ b/packages/rendering/vue/src/internal/editorState.ts @@ -54,6 +54,7 @@ const EMPTY_FIELD_EDITOR_STATE: FieldEditorStoreSnapshot = { inputMode: "none", mode: "inactive", activeCellCoord: null, + domSyncVersion: 0, }; export function useDocumentEmptyState(editor: Editor) { From 24015a3305833c012dd148f61efacafbc63e77d3 Mon Sep 17 00:00:00 2001 From: krijn Date: Mon, 25 May 2026 13:39:47 +0200 Subject: [PATCH 19/20] Remove obsolete test files and refactor editor application logic - Deleted `databaseOps.test.ts` and `editorCore.test.ts` files to streamline the test suite. - Refactored the `apply.ts` file to improve the application pipeline, consolidating operation execution and boundary emission. - Enhanced the `DatabaseViewExecutor` by integrating helper methods for better view management and code clarity. - Updated `documentSession.ts` to utilize new helper functions for scope management, improving maintainability. - Simplified the `editor.ts` file by extracting common logic into helper functions, enhancing readability and reducing complexity. --- ...eOps.test.ts => databaseOps.part1.test.ts} | 291 +- .../src/__tests__/databaseOps.part2.test.ts | 327 + .../src/__tests__/editorCore.part1.test.ts | 412 + .../src/__tests__/editorCore.part2.test.ts | 438 + .../src/__tests__/editorCore.part3.test.ts | 411 + .../src/__tests__/editorCore.part4.test.ts | 447 + .../src/__tests__/editorCore.part5.test.ts | 434 + .../src/__tests__/editorCore.part6.test.ts | 382 + .../src/__tests__/editorCore.part7.test.ts | 428 + .../src/__tests__/editorCore.part8.test.ts | 219 + .../core/src/__tests__/editorCore.test.ts | 2494 ----- packages/core/src/editor/apply.ts | 987 +- packages/core/src/editor/applyBlockOps.ts | 436 + .../core/src/editor/applyInlineAndMetaOps.ts | 310 + .../core/src/editor/applyPipelineRunner.ts | 384 + .../core/src/editor/applySharedHelpers.ts | 149 + .../core/src/editor/databaseViewExecutor.ts | 391 +- .../core/src/editor/databaseViewHelpers.ts | 381 + packages/core/src/editor/documentSession.ts | 231 +- .../core/src/editor/documentSessionHelpers.ts | 193 + packages/core/src/editor/editor.ts | 1056 +- packages/core/src/editor/editorApiHelpers.ts | 212 + packages/core/src/editor/editorLifecycle.ts | 354 + .../src/editor/editorSelectionMutations.ts | 397 + packages/core/src/editor/inlineCompletion.ts | 6 + .../core/src/editor/tableGridCellHelpers.ts | 128 + packages/core/src/editor/tableGridExecutor.ts | 164 +- packages/core/src/schema/appHandleImpl.ts | 66 + .../core/src/schema/handleValueHelpers.ts | 229 + packages/core/src/schema/handles.ts | 364 +- .../core/src/schema/tableCellHandleImpl.ts | 65 + .../yjs/src/__tests__/extensionRoots.test.ts | 2 + .../yjs/src/__tests__/fieldAdapters.test.ts | 29 + packages/crdt/yjs/src/extensionRoots.ts | 2 +- packages/crdt/yjs/src/fieldAdapters.ts | 60 +- packages/crdt/yjs/src/undo.ts | 5 +- .../extensions/ai-autocomplete/package.json | 2 +- .../src/__tests__/extension.part2.test.ts | 407 + .../src/__tests__/extension.part3.test.ts | 383 + .../src/__tests__/extension.part4.test.ts | 390 + .../src/__tests__/extension.part5.test.ts | 381 + .../src/__tests__/extension.part6.test.ts | 368 + .../src/__tests__/extension.part7.test.ts | 118 + .../src/__tests__/extension.test.ts | 1879 ---- .../src/autocompleteCompletionText.ts | 275 + .../src/autocompleteController.ts | 6 + .../src/autocompleteControllerContinuation.ts | 245 + .../src/autocompleteControllerCore.ts | 241 + .../src/autocompleteControllerLifecycle.ts | 427 + .../src/autocompleteControllerRequest.ts | 443 + .../src/autocompleteControllerSnapshots.ts | 122 + .../src/autocompleteControllerState.ts | 238 + .../ai-autocomplete/src/autocompleteDebug.ts | 26 + .../ai-autocomplete/src/continuationState.ts | 10 +- .../ai-autocomplete/src/extension.ts | 1688 +-- .../extensions/ai-suggestions/package.json | 2 +- .../ai-suggestions/src/controller.ts | 772 +- .../ai-suggestions/src/controllerCore.ts | 472 + .../ai-suggestions/src/controllerRuntime.ts | 315 + .../ai-suggestions/src/controllerUtils.ts | 72 + .../ai/src/__tests__/extension.part1.test.ts | 373 + .../ai/src/__tests__/extension.part10.test.ts | 325 + .../ai/src/__tests__/extension.part11.test.ts | 356 + .../ai/src/__tests__/extension.part12.test.ts | 358 + .../ai/src/__tests__/extension.part13.test.ts | 378 + .../ai/src/__tests__/extension.part14.test.ts | 337 + .../ai/src/__tests__/extension.part15.test.ts | 105 + .../ai/src/__tests__/extension.part2.test.ts | 377 + .../ai/src/__tests__/extension.part3.test.ts | 371 + .../ai/src/__tests__/extension.part4.test.ts | 374 + .../ai/src/__tests__/extension.part5.test.ts | 375 + .../ai/src/__tests__/extension.part6.test.ts | 346 + .../ai/src/__tests__/extension.part7.test.ts | 348 + .../ai/src/__tests__/extension.part8.test.ts | 368 + .../ai/src/__tests__/extension.part9.test.ts | 311 + .../ai/src/__tests__/extension.test.ts | 4846 +-------- .../ai/src/__tests__/extension.testUtils.ts | 53 + packages/extensions/ai/src/controllers.ts | 75 + packages/extensions/ai/src/extension.ts | 9040 +---------------- .../aiControllerMethodsPart1.ts | 467 + .../aiControllerMethodsPart10.ts | 477 + .../aiControllerMethodsPart11.ts | 454 + .../aiControllerMethodsPart12.ts | 467 + .../aiControllerMethodsPart13.ts | 484 + .../aiControllerMethodsPart14.ts | 455 + .../aiControllerMethodsPart15.ts | 464 + .../aiControllerMethodsPart16.ts | 451 + .../aiControllerMethodsPart2.ts | 416 + .../aiControllerMethodsPart3.ts | 477 + .../aiControllerMethodsPart4.ts | 409 + .../aiControllerMethodsPart5.ts | 19 + .../aiControllerMethodsPart6.ts | 23 + .../aiControllerMethodsPart7.ts | 446 + .../aiControllerMethodsPart8.ts | 370 + .../aiControllerMethodsPart9.ts | 480 + .../ai/src/extensionParts/controllerDeps.ts | 11 + .../ai/src/extensionParts/extensionHelpers.ts | 9 + .../extensionParts/extensionHelpersPart1.ts | 477 + .../extensionParts/extensionHelpersPart2.ts | 431 + .../extensionParts/extensionHelpersPart3.ts | 465 + .../extensionParts/extensionHelpersPart4.ts | 402 + .../extensionParts/extensionHelpersPart5.ts | 432 + .../extensionParts/extensionHelpersPart6.ts | 481 + .../extensionParts/extensionHelpersPart7.ts | 480 + .../extensionParts/extensionHelpersPart8.ts | 415 + .../extensionParts/extensionHelpersPart9.ts | 344 + .../src/extensionParts/generationExecution.ts | 364 + .../generationExecutionFinalize.ts | 367 + .../extensionParts/generationExecutionLoop.ts | 381 + .../extensionParts/localOperationExecution.ts | 457 + .../localOperationExecutionFinalize.ts | 75 + .../__tests__/planExecutor.part1.test.ts | 343 + .../__tests__/planExecutor.part2.test.ts | 365 + .../__tests__/planExecutor.part3.test.ts | 355 + .../runtime/__tests__/planExecutor.test.ts | 1066 +- .../__tests__/planExecutor.testUtils.ts | 13 + .../extensions/ai/src/runtime/planExecutor.ts | 1363 +-- .../planExecutorParts/planExecutorPart1.ts | 290 + .../planExecutorParts/planExecutorPart2.ts | 367 + .../planExecutorParts/planExecutorPart3.ts | 358 + .../planExecutorParts/planExecutorPart4.ts | 377 + .../planExecutorParts/planExecutorPart5.ts | 58 + .../ai/src/runtime/planValidation.ts | 963 +- .../planValidationPart1.ts | 370 + .../planValidationPart2.ts | 337 + .../planValidationPart3.ts | 282 + .../ai/src/runtime/playgroundPlanner.ts | 828 +- .../playgroundPlannerPart1.ts | 341 + .../playgroundPlannerPart2.ts | 358 + .../playgroundPlannerPart3.ts | 148 + .../ai/src/runtime/promptTargeting.ts | 44 + .../ai/src/runtime/reviewArtifacts.ts | 1204 +-- .../reviewArtifactsPart1.ts | 340 + .../reviewArtifactsPart2.ts | 368 + .../reviewArtifactsPart3.ts | 349 + .../reviewArtifactsPart4.ts | 176 + .../ai/src/runtime/structuredIntent.ts | 900 +- .../structuredIntentPart1.ts | 356 + .../structuredIntentPart2.ts | 344 + .../structuredIntentPart3.ts | 216 + .../ai/src/runtime/structuredPlanner.ts | 701 +- .../structuredPlannerPart1.ts | 374 + .../structuredPlannerPart2.ts | 342 + .../src/runtime/suggestedOperationRunner.ts | 44 + .../ai/src/suggestions/persistent.ts | 64 +- .../ai/src/suggestions/suggestMode.ts | 7 +- .../extensions/ai/src/typeParts/typesPart1.ts | 391 + .../extensions/ai/src/typeParts/typesPart2.ts | 359 + packages/extensions/ai/src/types.ts | 713 +- .../src/__tests__/engine.part2.test.ts | 447 + .../database/src/__tests__/engine.test.ts | 374 - .../src/__tests__/renderer.part10.test.tsx | 401 + .../src/__tests__/renderer.part11.test.tsx | 291 + .../src/__tests__/renderer.part2.test.tsx | 447 + .../src/__tests__/renderer.part3.test.tsx | 388 + .../src/__tests__/renderer.part4.test.tsx | 416 + .../src/__tests__/renderer.part5.test.tsx | 387 + .../src/__tests__/renderer.part6.test.tsx | 441 + .../src/__tests__/renderer.part7.test.tsx | 395 + .../src/__tests__/renderer.part8.test.tsx | 409 + .../src/__tests__/renderer.part9.test.tsx | 425 + .../database/src/__tests__/renderer.test.tsx | 1960 ---- .../src/cellEditorSpecializedCells.tsx | 130 + .../database/src/cellEditorUtils.ts | 82 + .../extensions/database/src/cellEditors.tsx | 214 +- .../src/databaseControllerMutationHandlers.ts | 382 + .../databaseControllerSelectionHandlers.ts | 282 + .../database/src/databaseControllerTypes.ts | 137 + packages/extensions/database/src/engine.ts | 869 +- .../extensions/database/src/engineCore.ts | 453 + .../extensions/database/src/engineFilters.ts | 376 + .../extensions/database/src/engineRows.ts | 151 + .../database/src/rendererColumnMenu.tsx | 273 + .../database/src/rendererFilterPanel.tsx | 374 + .../database/src/rendererPanels.tsx | 646 +- .../database/src/rendererViewTypes.ts | 64 + .../extensions/database/src/rendererViews.tsx | 60 +- .../database/src/useDatabaseController.ts | 765 +- .../database/src/utils/databaseRenderer.ts | 284 +- .../src/utils/databaseRendererFilters.ts | 275 + .../src/__tests__/tools.part2.test.ts | 430 + .../src/__tests__/tools.part3.test.ts | 434 + .../src/__tests__/tools.part4.test.ts | 427 + .../src/__tests__/tools.part5.test.ts | 346 + .../document-ops/src/__tests__/tools.test.ts | 473 - .../src/__tests__/exportHtml.part2.test.ts | 157 + .../src/__tests__/exportHtml.test.ts | 98 - .../__tests__/exportMarkdown.part2.test.ts | 157 + .../src/__tests__/exportMarkdown.test.ts | 96 - .../src/__tests__/importHtml.part2.test.ts | 447 + .../src/__tests__/importHtml.part3.test.ts | 354 + .../src/__tests__/importHtml.test.ts | 621 -- .../extensions/import-html/src/domToBlocks.ts | 217 +- .../import-html/src/domToDatabaseBlocks.ts | 219 + .../__tests__/importMarkdown.part2.test.ts | 226 + .../src/__tests__/importMarkdown.test.ts | 139 - packages/extensions/undo/src/undoExtension.ts | 3 +- packages/extensions/undo/src/undoManager.ts | 23 +- .../dom/src/field-editor/commands.ts | 880 +- .../dom/src/field-editor/commandsBlock.ts | 222 + .../dom/src/field-editor/commandsDelete.ts | 220 + .../dom/src/field-editor/commandsEnter.ts | 126 + .../src/field-editor/commandsNavigation.ts | 126 + .../dom/src/field-editor/commandsShared.ts | 227 + .../field-editor/contenteditableBackend.ts | 1398 +-- .../contenteditableBackendCore.ts | 316 + .../contenteditableBackendEvents.ts | 212 + .../contenteditableBackendSelection.ts | 338 + .../contenteditableDirectHandlers.ts | 312 + .../field-editor/contenteditableDomHelpers.ts | 261 + .../src/field-editor/editContextBackend.ts | 1279 +-- .../field-editor/editContextBackendCore.ts | 266 + .../field-editor/editContextBackendInput.ts | 310 + .../field-editor/editContextBackendRuntime.ts | 246 + .../editContextBackendSelection.ts | 360 + .../dom/src/field-editor/editContextDom.ts | 151 + .../dom/src/field-editor/editContextTypes.ts | 39 + .../dom/src/field-editor/fieldEditorImpl.ts | 1445 +-- .../src/field-editor/fieldEditorImplCore.ts | 313 + .../field-editor/fieldEditorImplHelpers.ts | 79 + .../field-editor/fieldEditorImplLifecycle.ts | 417 + .../field-editor/fieldEditorImplRuntime.ts | 434 + .../field-editor/fieldEditorImplSelection.ts | 469 + .../dom/src/field-editor/inlineAtomDom.ts | 419 +- .../src/field-editor/inlineAtomLogicalDom.ts | 423 + .../dom/src/field-editor/inlineInputRules.ts | 109 + .../src/field-editor/keyBindingShortcuts.ts | 213 + .../dom/src/field-editor/keyHandling.ts | 347 +- .../field-editor/keyHandlingInlineAtoms.ts | 128 + .../dom/src/field-editor/reconciler.ts | 67 +- .../src/field-editor/reconcilerSelection.ts | 67 + .../dom/src/field-editor/selectionBridge.ts | 265 +- .../field-editor/selectionBridgeOffsets.ts | 270 + .../dom/src/field-editor/textInputPipeline.ts | 124 + .../dom/src/utils/tableCellClipboard.ts | 194 + .../dom/src/utils/tableCellNavigation.ts | 195 +- .../src/__tests__/aiPrimitives.01.test.tsx | 400 + .../src/__tests__/aiPrimitives.02.test.tsx | 437 + .../src/__tests__/aiPrimitives.03.test.tsx | 374 + .../src/__tests__/aiPrimitives.04.test.tsx | 459 + .../src/__tests__/aiPrimitives.05.test.tsx | 356 + .../src/__tests__/aiPrimitives.06.test.tsx | 363 + .../src/__tests__/aiPrimitives.07.test.tsx | 344 + .../src/__tests__/aiPrimitives.08.test.tsx | 445 + .../src/__tests__/aiPrimitives.09.test.tsx | 409 + .../src/__tests__/aiPrimitives.10.test.tsx | 387 + .../src/__tests__/aiPrimitives.11.test.tsx | 394 + .../src/__tests__/aiPrimitives.12.test.tsx | 423 + .../src/__tests__/aiPrimitives.13.test.tsx | 414 + .../src/__tests__/aiPrimitives.14.test.tsx | 446 + .../src/__tests__/aiPrimitives.15.test.tsx | 430 + .../src/__tests__/aiPrimitives.16.test.tsx | 357 + .../src/__tests__/aiPrimitives.17.test.tsx | 401 + .../src/__tests__/aiPrimitives.18.test.tsx | 392 + .../src/__tests__/aiPrimitives.19.test.tsx | 409 + .../src/__tests__/aiPrimitives.20.test.tsx | 360 + .../src/__tests__/aiPrimitives.21.test.tsx | 378 + .../src/__tests__/aiPrimitives.22.test.tsx | 408 + .../src/__tests__/aiPrimitives.23.test.tsx | 363 + .../src/__tests__/aiPrimitives.24.test.tsx | 440 + .../src/__tests__/aiPrimitives.25.test.tsx | 423 + .../src/__tests__/aiPrimitives.26.test.tsx | 443 + .../react/src/__tests__/aiPrimitives.test.tsx | 4429 -------- .../__tests__/blockDragAndDrop.01.test.tsx | 433 + ....test.tsx => blockDragAndDrop.02.test.tsx} | 279 - .../__tests__/blockDragAndDrop.03.test.tsx | 277 + .../__tests__/blockTypeRendering.01.test.tsx | 396 + ...est.tsx => blockTypeRendering.02.test.tsx} | 345 +- .../react/src/__tests__/clipboard.01.test.ts | 432 + .../react/src/__tests__/clipboard.02.test.ts | 449 + .../react/src/__tests__/clipboard.03.test.ts | 300 + .../react/src/__tests__/clipboard.test.ts | 948 -- .../__tests__/escapeKeyHandling.01.test.tsx | 427 + .../__tests__/escapeKeyHandling.02.test.tsx | 370 + .../__tests__/escapeKeyHandling.03.test.tsx | 431 + .../__tests__/escapeKeyHandling.04.test.tsx | 391 + .../__tests__/escapeKeyHandling.05.test.tsx | 428 + .../__tests__/escapeKeyHandling.06.test.tsx | 344 + .../__tests__/escapeKeyHandling.07.test.tsx | 374 + .../__tests__/escapeKeyHandling.08.test.tsx | 363 + .../__tests__/escapeKeyHandling.09.test.tsx | 403 + .../__tests__/escapeKeyHandling.10.test.tsx | 379 + .../__tests__/escapeKeyHandling.11.test.tsx | 316 + .../src/__tests__/escapeKeyHandling.test.tsx | 3255 ------ .../__tests__/fieldEditorCommands.01.test.ts | 451 + .../__tests__/fieldEditorCommands.02.test.ts | 211 + .../__tests__/fieldEditorCommands.03.test.ts | 239 + .../__tests__/fieldEditorCommands.04.test.ts | 206 + .../__tests__/fieldEditorCommands.05.test.ts | 352 + .../__tests__/fieldEditorCommands.06.test.ts | 143 + .../__tests__/fieldEditorCommands.07.test.ts | 196 + .../__tests__/fieldEditorCommands.08.test.ts | 233 + .../src/__tests__/fieldEditorCommands.test.ts | 1579 --- ...rop.test.tsx => imageDragDrop.01.test.tsx} | 236 - .../src/__tests__/imageDragDrop.02.test.tsx | 300 + ...sx => inlineAtomDomOperations.01.test.tsx} | 95 - .../inlineAtomDomOperations.02.test.tsx | 167 + .../__tests__/inlineAtomEditing.01.test.tsx | 427 + .../__tests__/inlineAtomEditing.02.test.tsx | 397 + .../__tests__/inlineAtomEditing.03.test.tsx | 380 + .../src/__tests__/inlineAtomEditing.test.tsx | 943 -- .../src/__tests__/keyHandling.01.test.ts | 429 + .../src/__tests__/keyHandling.02.test.ts | 398 + .../src/__tests__/keyHandling.03.test.ts | 233 + .../src/__tests__/keyHandling.04.test.ts | 220 + .../src/__tests__/keyHandling.05.test.ts | 439 + .../src/__tests__/keyHandling.06.test.ts | 268 + .../react/src/__tests__/keyHandling.test.ts | 1270 --- ...st.tsx => placeholderBehavior.01.test.tsx} | 200 +- .../__tests__/placeholderBehavior.02.test.tsx | 241 + ...n.test.tsx => regionSelection.01.test.tsx} | 108 - .../src/__tests__/regionSelection.02.test.tsx | 211 + .../selectedTextDeletion.01.test.tsx | 438 + .../selectedTextDeletion.02.test.tsx | 349 + .../selectedTextDeletion.03.test.tsx | 376 + .../selectedTextDeletion.04.test.tsx | 362 + .../selectedTextDeletion.05.test.tsx | 435 + .../selectedTextDeletion.06.test.tsx | 383 + .../selectedTextDeletion.07.test.tsx | 440 + .../selectedTextDeletion.08.test.tsx | 457 + .../selectedTextDeletion.09.test.tsx | 450 + .../selectedTextDeletion.10.test.tsx | 392 + .../selectedTextDeletion.11.test.tsx | 444 + .../selectedTextDeletion.12.test.tsx | 442 + .../selectedTextDeletion.13.test.tsx | 387 + .../selectedTextDeletion.14.test.tsx | 406 + .../selectedTextDeletion.15.test.tsx | 404 + .../selectedTextDeletion.16.test.tsx | 424 + .../selectedTextDeletion.17.test.tsx | 446 + .../selectedTextDeletion.18.test.tsx | 407 + .../selectedTextDeletion.19.test.tsx | 363 + .../selectedTextDeletion.20.test.tsx | 426 + .../__tests__/selectedTextDeletion.test.tsx | 5532 ---------- ...ashMenu.test.tsx => slashMenu.01.test.tsx} | 155 - .../react/src/__tests__/slashMenu.02.test.tsx | 205 + ...nu.test.tsx => suggestionMenu.01.test.tsx} | 152 - .../src/__tests__/suggestionMenu.02.test.tsx | 236 + .../src/__tests__/tableRendering.01.test.tsx | 444 + .../src/__tests__/tableRendering.02.test.tsx | 394 + .../src/__tests__/tableRendering.03.test.tsx | 394 + .../src/__tests__/tableRendering.04.test.tsx | 400 + .../src/__tests__/tableRendering.05.test.tsx | 258 + .../src/__tests__/tableRendering.test.tsx | 1445 --- .../src/hooks/inlineSuggestionControlUtils.ts | 400 + .../src/hooks/useInlineSuggestionControls.ts | 412 +- .../react/src/primitives/ai/changeList.tsx | 752 +- .../src/primitives/ai/changeListItems.tsx | 454 + .../src/primitives/ai/changeListUtils.ts | 342 + .../src/primitives/ai/contextualPrompt.tsx | 1420 +-- .../ai/contextualPromptComposer.tsx | 467 + .../primitives/ai/contextualPromptGeometry.ts | 258 + .../ai/contextualPromptPlacement.ts | 246 + .../primitives/ai/contextualPromptSurface.tsx | 326 + .../primitives/ai/contextualPromptTrigger.tsx | 94 + .../primitives/ai/contextualPromptTypes.ts | 25 + .../react/src/primitives/editor/content.tsx | 1431 +-- .../editor/editorContentDropUtils.ts | 188 + .../editor/inlineAtomInteraction.ts | 325 +- .../editor/inlineAtomSelectionInteraction.ts | 213 + .../editor/useEditorContentGestures.ts | 459 + .../editor/useEditorContentPointerState.ts | 41 + .../primitives/editor/useTransferSession.ts | 5 +- .../vue/src/__tests__/mount.part2.test.ts | 267 + .../rendering/vue/src/__tests__/mount.test.ts | 142 - .../schema/default/src/blocks/paragraph.ts | 2 +- packages/tooling/bench/src/suites/ai.bench.ts | 397 +- .../bench/src/suites/aiBenchHelpers.ts | 384 + .../src/__tests__/normalize.part2.test.ts | 390 + .../test/src/__tests__/normalize.test.ts | 345 - packages/tooling/test/src/fixtures.ts | 1 + packages/types/src/types/crdt.ts | 6 +- packages/types/src/types/editor.ts | 1 + playground/server/aiRequestHandler.ts | 477 + playground/server/aiSuggestionsRequest.ts | 56 + playground/server/collaborationServer.ts | 74 + playground/server/config.ts | 104 + playground/server/http.ts | 53 + playground/server/index.ts | 2063 +--- playground/server/localOperationStream.ts | 128 + playground/server/operationValidation.ts | 244 + playground/server/requestPlan.ts | 136 + playground/server/routes.ts | 83 + playground/server/sessionHandlers.ts | 267 + playground/server/sessionHydration.ts | 164 + playground/server/sessionStore.ts | 72 + playground/server/toolHandlers.ts | 167 + playground/server/types.ts | 122 + playground/src/App.tsx | 292 +- .../AISuggestionsInspectorSection.tsx | 326 + .../AutocompleteInspectorSection.tsx | 337 + playground/src/components/InspectorPanel.tsx | 625 +- .../src/components/InspectorPanelUtils.ts | 21 + .../PlaygroundChatDock.history.test.tsx | 233 + .../components/PlaygroundChatDock.test.tsx | 268 +- playground/src/components/Toolbar.tsx | 602 +- .../components/ToolbarCollaborationStatus.tsx | 124 + .../src/components/ToolbarExportMenu.tsx | 97 + .../src/components/ToolbarLinkButton.tsx | 176 + .../src/components/ToolbarSearchMenu.tsx | 170 + playground/src/components/ToolbarUtils.ts | 50 + playground/src/hooks/usePlaygroundEditor.ts | 247 + playground/src/utils/playgroundAIChunks.ts | 161 + playground/src/utils/playgroundAIRequest.ts | 286 + .../playgroundAISession.isolation.test.ts | 422 + .../playgroundAISession.operations.test.ts | 383 + .../src/utils/playgroundAISession.test.ts | 796 +- .../utils/playgroundAISession.testUtils.ts | 34 + playground/src/utils/playgroundAISession.ts | 1087 +- .../src/utils/playgroundAISessionRuntime.ts | 226 + .../src/utils/playgroundAISessionTypes.ts | 78 + playground/src/utils/playgroundAIStream.ts | 66 + playground/src/utils/playgroundAISync.ts | 368 + 412 files changed, 100203 insertions(+), 75755 deletions(-) rename packages/core/src/__tests__/{databaseOps.test.ts => databaseOps.part1.test.ts} (58%) create mode 100644 packages/core/src/__tests__/databaseOps.part2.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part1.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part2.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part3.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part4.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part5.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part6.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part7.test.ts create mode 100644 packages/core/src/__tests__/editorCore.part8.test.ts delete mode 100644 packages/core/src/__tests__/editorCore.test.ts create mode 100644 packages/core/src/editor/applyBlockOps.ts create mode 100644 packages/core/src/editor/applyInlineAndMetaOps.ts create mode 100644 packages/core/src/editor/applyPipelineRunner.ts create mode 100644 packages/core/src/editor/applySharedHelpers.ts create mode 100644 packages/core/src/editor/databaseViewHelpers.ts create mode 100644 packages/core/src/editor/documentSessionHelpers.ts create mode 100644 packages/core/src/editor/editorApiHelpers.ts create mode 100644 packages/core/src/editor/editorLifecycle.ts create mode 100644 packages/core/src/editor/editorSelectionMutations.ts create mode 100644 packages/core/src/editor/tableGridCellHelpers.ts create mode 100644 packages/core/src/schema/appHandleImpl.ts create mode 100644 packages/core/src/schema/handleValueHelpers.ts create mode 100644 packages/core/src/schema/tableCellHandleImpl.ts create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteController.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts create mode 100644 packages/extensions/ai-autocomplete/src/autocompleteDebug.ts create mode 100644 packages/extensions/ai-suggestions/src/controllerCore.ts create mode 100644 packages/extensions/ai-suggestions/src/controllerRuntime.ts create mode 100644 packages/extensions/ai-suggestions/src/controllerUtils.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part1.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part10.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part11.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part12.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part13.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part14.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part15.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part2.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part3.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part4.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part5.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part6.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part7.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part8.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.part9.test.ts create mode 100644 packages/extensions/ai/src/__tests__/extension.testUtils.ts create mode 100644 packages/extensions/ai/src/controllers.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts create mode 100644 packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts create mode 100644 packages/extensions/ai/src/extensionParts/controllerDeps.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpers.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts create mode 100644 packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts create mode 100644 packages/extensions/ai/src/extensionParts/generationExecution.ts create mode 100644 packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts create mode 100644 packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts create mode 100644 packages/extensions/ai/src/extensionParts/localOperationExecution.ts create mode 100644 packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts create mode 100644 packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts create mode 100644 packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts create mode 100644 packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts create mode 100644 packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts create mode 100644 packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts create mode 100644 packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts create mode 100644 packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts create mode 100644 packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts create mode 100644 packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts create mode 100644 packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts create mode 100644 packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts create mode 100644 packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts create mode 100644 packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts create mode 100644 packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts create mode 100644 packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts create mode 100644 packages/extensions/ai/src/runtime/promptTargeting.ts create mode 100644 packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts create mode 100644 packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts create mode 100644 packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts create mode 100644 packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts create mode 100644 packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts create mode 100644 packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts create mode 100644 packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts create mode 100644 packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts create mode 100644 packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts create mode 100644 packages/extensions/ai/src/runtime/suggestedOperationRunner.ts create mode 100644 packages/extensions/ai/src/typeParts/typesPart1.ts create mode 100644 packages/extensions/ai/src/typeParts/typesPart2.ts create mode 100644 packages/extensions/database/src/__tests__/engine.part2.test.ts create mode 100644 packages/extensions/database/src/__tests__/renderer.part10.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part11.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part2.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part3.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part4.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part5.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part6.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part7.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part8.test.tsx create mode 100644 packages/extensions/database/src/__tests__/renderer.part9.test.tsx create mode 100644 packages/extensions/database/src/cellEditorSpecializedCells.tsx create mode 100644 packages/extensions/database/src/cellEditorUtils.ts create mode 100644 packages/extensions/database/src/databaseControllerMutationHandlers.ts create mode 100644 packages/extensions/database/src/databaseControllerSelectionHandlers.ts create mode 100644 packages/extensions/database/src/databaseControllerTypes.ts create mode 100644 packages/extensions/database/src/engineCore.ts create mode 100644 packages/extensions/database/src/engineFilters.ts create mode 100644 packages/extensions/database/src/engineRows.ts create mode 100644 packages/extensions/database/src/rendererColumnMenu.tsx create mode 100644 packages/extensions/database/src/rendererFilterPanel.tsx create mode 100644 packages/extensions/database/src/rendererViewTypes.ts create mode 100644 packages/extensions/database/src/utils/databaseRendererFilters.ts create mode 100644 packages/extensions/document-ops/src/__tests__/tools.part2.test.ts create mode 100644 packages/extensions/document-ops/src/__tests__/tools.part3.test.ts create mode 100644 packages/extensions/document-ops/src/__tests__/tools.part4.test.ts create mode 100644 packages/extensions/document-ops/src/__tests__/tools.part5.test.ts create mode 100644 packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts create mode 100644 packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts create mode 100644 packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts create mode 100644 packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts create mode 100644 packages/extensions/import-html/src/domToDatabaseBlocks.ts create mode 100644 packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts create mode 100644 packages/rendering/dom/src/field-editor/commandsBlock.ts create mode 100644 packages/rendering/dom/src/field-editor/commandsDelete.ts create mode 100644 packages/rendering/dom/src/field-editor/commandsEnter.ts create mode 100644 packages/rendering/dom/src/field-editor/commandsNavigation.ts create mode 100644 packages/rendering/dom/src/field-editor/commandsShared.ts create mode 100644 packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts create mode 100644 packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts create mode 100644 packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts create mode 100644 packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts create mode 100644 packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextBackendCore.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextBackendInput.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextBackendSelection.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextDom.ts create mode 100644 packages/rendering/dom/src/field-editor/editContextTypes.ts create mode 100644 packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts create mode 100644 packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts create mode 100644 packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts create mode 100644 packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts create mode 100644 packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts create mode 100644 packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts create mode 100644 packages/rendering/dom/src/field-editor/inlineInputRules.ts create mode 100644 packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts create mode 100644 packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts create mode 100644 packages/rendering/dom/src/field-editor/reconcilerSelection.ts create mode 100644 packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts create mode 100644 packages/rendering/dom/src/field-editor/textInputPipeline.ts create mode 100644 packages/rendering/dom/src/utils/tableCellClipboard.ts create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.10.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.11.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.12.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.13.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.14.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.15.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.16.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.17.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.18.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.19.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.20.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.21.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.22.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.23.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.24.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.25.test.tsx create mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.26.test.tsx delete mode 100644 packages/rendering/react/src/__tests__/aiPrimitives.test.tsx create mode 100644 packages/rendering/react/src/__tests__/blockDragAndDrop.01.test.tsx rename packages/rendering/react/src/__tests__/{blockDragAndDrop.test.tsx => blockDragAndDrop.02.test.tsx} (58%) create mode 100644 packages/rendering/react/src/__tests__/blockDragAndDrop.03.test.tsx create mode 100644 packages/rendering/react/src/__tests__/blockTypeRendering.01.test.tsx rename packages/rendering/react/src/__tests__/{blockTypeRendering.test.tsx => blockTypeRendering.02.test.tsx} (54%) create mode 100644 packages/rendering/react/src/__tests__/clipboard.01.test.ts create mode 100644 packages/rendering/react/src/__tests__/clipboard.02.test.ts create mode 100644 packages/rendering/react/src/__tests__/clipboard.03.test.ts delete mode 100644 packages/rendering/react/src/__tests__/clipboard.test.ts create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.01.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.03.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.04.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.05.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.06.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.07.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.08.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.09.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.10.test.tsx create mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.11.test.tsx delete mode 100644 packages/rendering/react/src/__tests__/escapeKeyHandling.test.tsx create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.01.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.02.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.03.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.04.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.05.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.06.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.07.test.ts create mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.08.test.ts delete mode 100644 packages/rendering/react/src/__tests__/fieldEditorCommands.test.ts rename packages/rendering/react/src/__tests__/{imageDragDrop.test.tsx => imageDragDrop.01.test.tsx} (62%) create mode 100644 packages/rendering/react/src/__tests__/imageDragDrop.02.test.tsx rename packages/rendering/react/src/__tests__/{inlineAtomDomOperations.test.tsx => inlineAtomDomOperations.01.test.tsx} (81%) create mode 100644 packages/rendering/react/src/__tests__/inlineAtomDomOperations.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/inlineAtomEditing.01.test.tsx create mode 100644 packages/rendering/react/src/__tests__/inlineAtomEditing.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/inlineAtomEditing.03.test.tsx delete mode 100644 packages/rendering/react/src/__tests__/inlineAtomEditing.test.tsx create mode 100644 packages/rendering/react/src/__tests__/keyHandling.01.test.ts create mode 100644 packages/rendering/react/src/__tests__/keyHandling.02.test.ts create mode 100644 packages/rendering/react/src/__tests__/keyHandling.03.test.ts create mode 100644 packages/rendering/react/src/__tests__/keyHandling.04.test.ts create mode 100644 packages/rendering/react/src/__tests__/keyHandling.05.test.ts create mode 100644 packages/rendering/react/src/__tests__/keyHandling.06.test.ts delete mode 100644 packages/rendering/react/src/__tests__/keyHandling.test.ts rename packages/rendering/react/src/__tests__/{placeholderBehavior.test.tsx => placeholderBehavior.01.test.tsx} (68%) create mode 100644 packages/rendering/react/src/__tests__/placeholderBehavior.02.test.tsx rename packages/rendering/react/src/__tests__/{regionSelection.test.tsx => regionSelection.01.test.tsx} (76%) create mode 100644 packages/rendering/react/src/__tests__/regionSelection.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.01.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.03.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.04.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.05.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.06.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.07.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.08.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.09.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.10.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.11.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.12.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.13.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.14.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.15.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.16.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.17.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.18.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.19.test.tsx create mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.20.test.tsx delete mode 100644 packages/rendering/react/src/__tests__/selectedTextDeletion.test.tsx rename packages/rendering/react/src/__tests__/{slashMenu.test.tsx => slashMenu.01.test.tsx} (74%) create mode 100644 packages/rendering/react/src/__tests__/slashMenu.02.test.tsx rename packages/rendering/react/src/__tests__/{suggestionMenu.test.tsx => suggestionMenu.01.test.tsx} (71%) create mode 100644 packages/rendering/react/src/__tests__/suggestionMenu.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/tableRendering.01.test.tsx create mode 100644 packages/rendering/react/src/__tests__/tableRendering.02.test.tsx create mode 100644 packages/rendering/react/src/__tests__/tableRendering.03.test.tsx create mode 100644 packages/rendering/react/src/__tests__/tableRendering.04.test.tsx create mode 100644 packages/rendering/react/src/__tests__/tableRendering.05.test.tsx delete mode 100644 packages/rendering/react/src/__tests__/tableRendering.test.tsx create mode 100644 packages/rendering/react/src/hooks/inlineSuggestionControlUtils.ts create mode 100644 packages/rendering/react/src/primitives/ai/changeListItems.tsx create mode 100644 packages/rendering/react/src/primitives/ai/changeListUtils.ts create mode 100644 packages/rendering/react/src/primitives/ai/contextualPromptComposer.tsx create mode 100644 packages/rendering/react/src/primitives/ai/contextualPromptGeometry.ts create mode 100644 packages/rendering/react/src/primitives/ai/contextualPromptPlacement.ts create mode 100644 packages/rendering/react/src/primitives/ai/contextualPromptSurface.tsx create mode 100644 packages/rendering/react/src/primitives/ai/contextualPromptTrigger.tsx create mode 100644 packages/rendering/react/src/primitives/ai/contextualPromptTypes.ts create mode 100644 packages/rendering/react/src/primitives/editor/editorContentDropUtils.ts create mode 100644 packages/rendering/react/src/primitives/editor/inlineAtomSelectionInteraction.ts create mode 100644 packages/rendering/react/src/primitives/editor/useEditorContentGestures.ts create mode 100644 packages/rendering/react/src/primitives/editor/useEditorContentPointerState.ts create mode 100644 packages/rendering/vue/src/__tests__/mount.part2.test.ts create mode 100644 packages/tooling/bench/src/suites/aiBenchHelpers.ts create mode 100644 packages/tooling/test/src/__tests__/normalize.part2.test.ts create mode 100644 playground/server/aiRequestHandler.ts create mode 100644 playground/server/aiSuggestionsRequest.ts create mode 100644 playground/server/collaborationServer.ts create mode 100644 playground/server/config.ts create mode 100644 playground/server/http.ts create mode 100644 playground/server/localOperationStream.ts create mode 100644 playground/server/operationValidation.ts create mode 100644 playground/server/requestPlan.ts create mode 100644 playground/server/routes.ts create mode 100644 playground/server/sessionHandlers.ts create mode 100644 playground/server/sessionHydration.ts create mode 100644 playground/server/sessionStore.ts create mode 100644 playground/server/toolHandlers.ts create mode 100644 playground/server/types.ts create mode 100644 playground/src/components/AISuggestionsInspectorSection.tsx create mode 100644 playground/src/components/AutocompleteInspectorSection.tsx create mode 100644 playground/src/components/InspectorPanelUtils.ts create mode 100644 playground/src/components/PlaygroundChatDock.history.test.tsx create mode 100644 playground/src/components/ToolbarCollaborationStatus.tsx create mode 100644 playground/src/components/ToolbarExportMenu.tsx create mode 100644 playground/src/components/ToolbarLinkButton.tsx create mode 100644 playground/src/components/ToolbarSearchMenu.tsx create mode 100644 playground/src/components/ToolbarUtils.ts create mode 100644 playground/src/hooks/usePlaygroundEditor.ts create mode 100644 playground/src/utils/playgroundAIChunks.ts create mode 100644 playground/src/utils/playgroundAIRequest.ts create mode 100644 playground/src/utils/playgroundAISession.isolation.test.ts create mode 100644 playground/src/utils/playgroundAISession.operations.test.ts create mode 100644 playground/src/utils/playgroundAISession.testUtils.ts create mode 100644 playground/src/utils/playgroundAISessionRuntime.ts create mode 100644 playground/src/utils/playgroundAISessionTypes.ts create mode 100644 playground/src/utils/playgroundAIStream.ts create mode 100644 playground/src/utils/playgroundAISync.ts diff --git a/packages/core/src/__tests__/databaseOps.test.ts b/packages/core/src/__tests__/databaseOps.part1.test.ts similarity index 58% rename from packages/core/src/__tests__/databaseOps.test.ts rename to packages/core/src/__tests__/databaseOps.part1.test.ts index 59148f8..d4bcbe1 100644 --- a/packages/core/src/__tests__/databaseOps.test.ts +++ b/packages/core/src/__tests__/databaseOps.part1.test.ts @@ -32,6 +32,7 @@ function databaseEditor() { return editor; } + describe("database core operations", () => { it("insert-block with database type seeds shared grid structures", () => { const editor = databaseEditor(); @@ -402,294 +403,4 @@ describe("database core operations", () => { editor.destroy(); }); - it("normalizes invalid database view references on write", () => { - const editor = databaseEditor(); - - editor.apply([ - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-a", - values: { - name: "Alpha", - tags: "todo", - status: "true", - }, - }, - { - type: "database-update-view", - blockId: "d1", - patch: { - visibleColumnIds: ["name", "missing", "name"], - columnOrder: ["missing", "tags", "name", "tags"], - sort: [ - { columnId: "missing", direction: "asc" }, - { columnId: "tags", direction: "asc" }, - { columnId: "tags", direction: "desc" }, - ], - filter: { - operator: "and", - conditions: [ - { columnId: "missing", operator: "is", value: "x" }, - { columnId: "tags", operator: "is", value: "todo" }, - ], - }, - groupBy: "missing", - rowPinning: { - top: ["missing-row", "row-a", "row-a"], - bottom: ["row-a", "missing-row"], - }, - }, - }, - ]); - - const view = editor.getBlock("d1")?.databaseActiveView(); - expect(view?.visibleColumnIds).toEqual(["name"]); - expect(view?.columnOrder).toEqual(["tags", "name"]); - expect(view?.sort).toEqual([{ columnId: "tags", direction: "asc" }]); - expect(view?.filter).toEqual({ - operator: "and", - conditions: [{ columnId: "tags", operator: "is", value: "todo" }], - }); - expect(view?.groupBy).toBeUndefined(); - expect(view?.rowPinning).toEqual({ - top: ["row-a"], - bottom: undefined, - }); - - editor.destroy(); - }); - - it("database row and select option ops clean up dependent data", () => { - const editor = databaseEditor(); - editor.apply([ - { - type: "database-update-view", - blockId: "d1", - patch: { - rowPinning: { - top: ["row-a"], - bottom: ["row-b"], - }, - }, - }, - { - type: "database-update-column", - blockId: "d1", - columnId: "tags", - patch: { - options: [ - { id: "bug", value: "Bug", color: "red" }, - { id: "chore", value: "Chore", color: "gray" }, - ], - }, - }, - { - type: "database-convert-column", - blockId: "d1", - columnId: "tags", - toType: "multiSelect", - }, - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-a", - values: { - name: "A", - tags: JSON.stringify(["bug", "chore"]), - }, - }, - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-b", - values: { - name: "B", - tags: JSON.stringify(["bug"]), - }, - }, - { - type: "database-update-select-options", - blockId: "d1", - columnId: "tags", - action: "remove", - optionId: "bug", - }, - { - type: "database-duplicate-row", - blockId: "d1", - rowId: "row-a", - newRowId: "row-c", - }, - { - type: "database-delete-rows", - blockId: "d1", - rowIds: ["row-b"], - }, - { - type: "database-move-row", - blockId: "d1", - rowId: "row-c", - index: 0, - }, - ]); - - const block = editor.getBlock("d1")!; - expect(block.tableRowCount()).toBe(2); - expect(block.tableRow(0)?.id).toBe("row-a"); - expect(block.tableRow(1)?.id).toBe("row-c"); - expect(block.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["chore"])); - expect(block.tableCell(1, 1)?.textContent()).toBe(JSON.stringify(["chore"])); - expect(block.tableColumns()[1]?.options).toEqual([ - { id: "chore", value: "Chore", color: "gray" }, - ]); - - editor.apply([ - { - type: "database-remove-column", - blockId: "d1", - columnId: "tags", - }, - ]); - - const nextBlock = editor.getBlock("d1")!; - expect(nextBlock.tableColumns().map((column) => column.id)).toEqual([ - "name", - "status", - ]); - expect(nextBlock.databaseActiveView()?.columnOrder).toEqual([ - "name", - "status", - ]); - expect(nextBlock.databaseActiveView()?.visibleColumnIds).toEqual([ - "name", - "status", - ]); - expect(nextBlock.databaseActiveView()?.rowPinning).toBeUndefined(); - editor.destroy(); - }); - - it("renaming a select option preserves stored option ids", () => { - const editor = databaseEditor(); - editor.apply([ - { - type: "database-update-column", - blockId: "d1", - columnId: "tags", - patch: { - options: [{ id: "todo", value: "Todo", color: "gray" }], - }, - }, - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-1", - values: { - name: "Write docs", - tags: "todo", - }, - }, - { - type: "database-update-select-options", - blockId: "d1", - columnId: "tags", - action: "rename", - optionId: "todo", - value: "Ready", - }, - ]); - - const block = editor.getBlock("d1")!; - expect(block.tableCell(0, 1)?.textContent()).toBe("todo"); - expect(block.tableColumns()[1]?.options).toEqual([ - { id: "todo", value: "Ready", color: "gray" }, - ]); - editor.destroy(); - }); - - it("rejects column type changes through database-update-column", () => { - const editor = databaseEditor(); - editor.apply([{ - type: "database-update-column", - blockId: "d1", - columnId: "name", - patch: { - type: "number", - title: "Name field", - }, - } as DocumentOp]); - - const block = editor.getBlock("d1")!; - expect(block.tableColumns()[0]).toEqual( - expect.objectContaining({ - id: "name", - title: "Name field", - type: "text", - }), - ); - editor.destroy(); - }); - - it("normalizes typed database row writes and rejects invalid updates", () => { - const editor = databaseEditor(); - editor.apply([{ - type: "update-table-columns", - blockId: "d1", - columns: [ - { id: "score", title: "Score", type: "number" }, - { id: "done", title: "Done", type: "checkbox" }, - { - id: "status", - title: "Status", - type: "select", - options: [{ id: "todo", value: "Todo" }], - }, - { - id: "labels", - title: "Labels", - type: "multiSelect", - options: [{ id: "todo", value: "Todo" }], - }, - ], - }]); - - editor.apply([{ - type: "database-insert-row", - blockId: "d1", - rowId: "row-typed", - values: { - score: "not-a-number", - done: "yes", - status: "Todo", - labels: JSON.stringify(["Todo"]), - }, - }]); - - const block = editor.getBlock("d1")!; - expect(block.tableCell(0, 0)?.textContent()).toBe(""); - expect(block.tableCell(0, 1)?.textContent()).toBe("true"); - expect(block.tableCell(0, 2)?.textContent()).toBe("todo"); - expect(block.tableCell(0, 3)?.textContent()).toBe(JSON.stringify(["todo"])); - - editor.apply([{ - type: "database-update-cell", - blockId: "d1", - rowId: "row-typed", - columnId: "score", - value: "42", - }]); - expect(block.tableCell(0, 0)?.textContent()).toBe("42"); - - editor.apply([{ - type: "database-update-cell", - blockId: "d1", - rowId: "row-typed", - columnId: "score", - value: "still-not-a-number", - }]); - expect(block.tableCell(0, 0)?.textContent()).toBe("42"); - - editor.destroy(); - }); - }); diff --git a/packages/core/src/__tests__/databaseOps.part2.test.ts b/packages/core/src/__tests__/databaseOps.part2.test.ts new file mode 100644 index 0000000..71a63fb --- /dev/null +++ b/packages/core/src/__tests__/databaseOps.part2.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "../index"; +import type { DocumentOp } from "@pen/types"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +type RawDatabaseBlockMap = { + get(key: string): unknown; +}; + +type LengthLike = { + length: number; +}; + +function databaseEditor() { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply([ + { + type: "insert-block", + blockId: "d1", + blockType: "database", + props: {}, + position: "last", + }, + ]); + return editor; +} + + +describe("database core operations", () => { + it("normalizes invalid database view references on write", () => { + const editor = databaseEditor(); + + editor.apply([ + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-a", + values: { + name: "Alpha", + tags: "todo", + status: "true", + }, + }, + { + type: "database-update-view", + blockId: "d1", + patch: { + visibleColumnIds: ["name", "missing", "name"], + columnOrder: ["missing", "tags", "name", "tags"], + sort: [ + { columnId: "missing", direction: "asc" }, + { columnId: "tags", direction: "asc" }, + { columnId: "tags", direction: "desc" }, + ], + filter: { + operator: "and", + conditions: [ + { columnId: "missing", operator: "is", value: "x" }, + { columnId: "tags", operator: "is", value: "todo" }, + ], + }, + groupBy: "missing", + rowPinning: { + top: ["missing-row", "row-a", "row-a"], + bottom: ["row-a", "missing-row"], + }, + }, + }, + ]); + + const view = editor.getBlock("d1")?.databaseActiveView(); + expect(view?.visibleColumnIds).toEqual(["name"]); + expect(view?.columnOrder).toEqual(["tags", "name"]); + expect(view?.sort).toEqual([{ columnId: "tags", direction: "asc" }]); + expect(view?.filter).toEqual({ + operator: "and", + conditions: [{ columnId: "tags", operator: "is", value: "todo" }], + }); + expect(view?.groupBy).toBeUndefined(); + expect(view?.rowPinning).toEqual({ + top: ["row-a"], + bottom: undefined, + }); + + editor.destroy(); + }); + + it("database row and select option ops clean up dependent data", () => { + const editor = databaseEditor(); + editor.apply([ + { + type: "database-update-view", + blockId: "d1", + patch: { + rowPinning: { + top: ["row-a"], + bottom: ["row-b"], + }, + }, + }, + { + type: "database-update-column", + blockId: "d1", + columnId: "tags", + patch: { + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "chore", value: "Chore", color: "gray" }, + ], + }, + }, + { + type: "database-convert-column", + blockId: "d1", + columnId: "tags", + toType: "multiSelect", + }, + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-a", + values: { + name: "A", + tags: JSON.stringify(["bug", "chore"]), + }, + }, + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-b", + values: { + name: "B", + tags: JSON.stringify(["bug"]), + }, + }, + { + type: "database-update-select-options", + blockId: "d1", + columnId: "tags", + action: "remove", + optionId: "bug", + }, + { + type: "database-duplicate-row", + blockId: "d1", + rowId: "row-a", + newRowId: "row-c", + }, + { + type: "database-delete-rows", + blockId: "d1", + rowIds: ["row-b"], + }, + { + type: "database-move-row", + blockId: "d1", + rowId: "row-c", + index: 0, + }, + ]); + + const block = editor.getBlock("d1")!; + expect(block.tableRowCount()).toBe(2); + expect(block.tableRow(0)?.id).toBe("row-a"); + expect(block.tableRow(1)?.id).toBe("row-c"); + expect(block.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["chore"])); + expect(block.tableCell(1, 1)?.textContent()).toBe(JSON.stringify(["chore"])); + expect(block.tableColumns()[1]?.options).toEqual([ + { id: "chore", value: "Chore", color: "gray" }, + ]); + + editor.apply([ + { + type: "database-remove-column", + blockId: "d1", + columnId: "tags", + }, + ]); + + const nextBlock = editor.getBlock("d1")!; + expect(nextBlock.tableColumns().map((column) => column.id)).toEqual([ + "name", + "status", + ]); + expect(nextBlock.databaseActiveView()?.columnOrder).toEqual([ + "name", + "status", + ]); + expect(nextBlock.databaseActiveView()?.visibleColumnIds).toEqual([ + "name", + "status", + ]); + expect(nextBlock.databaseActiveView()?.rowPinning).toBeUndefined(); + editor.destroy(); + }); + + it("renaming a select option preserves stored option ids", () => { + const editor = databaseEditor(); + editor.apply([ + { + type: "database-update-column", + blockId: "d1", + columnId: "tags", + patch: { + options: [{ id: "todo", value: "Todo", color: "gray" }], + }, + }, + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-1", + values: { + name: "Write docs", + tags: "todo", + }, + }, + { + type: "database-update-select-options", + blockId: "d1", + columnId: "tags", + action: "rename", + optionId: "todo", + value: "Ready", + }, + ]); + + const block = editor.getBlock("d1")!; + expect(block.tableCell(0, 1)?.textContent()).toBe("todo"); + expect(block.tableColumns()[1]?.options).toEqual([ + { id: "todo", value: "Ready", color: "gray" }, + ]); + editor.destroy(); + }); + + it("rejects column type changes through database-update-column", () => { + const editor = databaseEditor(); + editor.apply([{ + type: "database-update-column", + blockId: "d1", + columnId: "name", + patch: { + type: "number", + title: "Name field", + }, + } as DocumentOp]); + + const block = editor.getBlock("d1")!; + expect(block.tableColumns()[0]).toEqual( + expect.objectContaining({ + id: "name", + title: "Name field", + type: "text", + }), + ); + editor.destroy(); + }); + + it("normalizes typed database row writes and rejects invalid updates", () => { + const editor = databaseEditor(); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "score", title: "Score", type: "number" }, + { id: "done", title: "Done", type: "checkbox" }, + { + id: "status", + title: "Status", + type: "select", + options: [{ id: "todo", value: "Todo" }], + }, + { + id: "labels", + title: "Labels", + type: "multiSelect", + options: [{ id: "todo", value: "Todo" }], + }, + ], + }]); + + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "row-typed", + values: { + score: "not-a-number", + done: "yes", + status: "Todo", + labels: JSON.stringify(["Todo"]), + }, + }]); + + const block = editor.getBlock("d1")!; + expect(block.tableCell(0, 0)?.textContent()).toBe(""); + expect(block.tableCell(0, 1)?.textContent()).toBe("true"); + expect(block.tableCell(0, 2)?.textContent()).toBe("todo"); + expect(block.tableCell(0, 3)?.textContent()).toBe(JSON.stringify(["todo"])); + + editor.apply([{ + type: "database-update-cell", + blockId: "d1", + rowId: "row-typed", + columnId: "score", + value: "42", + }]); + expect(block.tableCell(0, 0)?.textContent()).toBe("42"); + + editor.apply([{ + type: "database-update-cell", + blockId: "d1", + rowId: "row-typed", + columnId: "score", + value: "still-not-a-number", + }]); + expect(block.tableCell(0, 0)?.textContent()).toBe("42"); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part1.test.ts b/packages/core/src/__tests__/editorCore.part1.test.ts new file mode 100644 index 0000000..93c3f24 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part1.test.ts @@ -0,0 +1,412 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("warns once when using the deprecated without option", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const editor = createCoreEditor({ + without: ["document-ops"], + }); + editor.destroy(); + + expect(warnSpy).toHaveBeenCalledWith( + "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", + ); + + warnSpy.mockRestore(); + }); + + it("installs extensions from presets before user extensions", () => { + const editor = createEditor({ + preset: { + resolve() { + return { + extensions: [ + defineExtension({ + name: "preset-test-extension", + activateClient: async (ctx) => { + ctx.editor.internals.setSlot( + "test:preset-installed", + true, + ); + }, + }), + ], + }; + }, + }, + }); + + expect(editor.internals.getSlot("test:preset-installed")).toBe(true); + + editor.destroy(); + }); + + it("supports multiple editors sharing one document session", () => { + const session = createDocumentSession({ + adapter: yjsAdapter(), + }); + const editorA = createEditor({ + documentSession: session, + }); + const editorB = createEditor({ + documentSession: session, + }); + const blockId = editorA.firstBlock()!.id; + + editorA.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Shared", + }, + ]); + + expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared"); + expect(editorA.documentScope.id).toBe(editorB.documentScope.id); + expect(editorA.internals.documentSession).toBe(session); + expect(editorB.internals.documentSession).toBe(session); + + editorA.destroy(); + editorB.apply([ + { + type: "insert-text", + blockId, + offset: 6, + text: " doc", + }, + ]); + + expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared doc"); + + editorB.destroy(); + session.destroy(); + }); + + it("creates headless editors around caller-owned documents without default undo behavior", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editor = createHeadlessEditor({ crdt: adapter, document }); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Server edit", + }, + ]); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Server edit"); + expect(editor.undoManager.undo()).toBe(false); + + editor.destroy(); + }); + + it("does not destroy caller-owned documents on editor teardown", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editorA = createEditor({ + document, + }); + const blockId = editorA.firstBlock()!.id; + + editorA.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Persisted", + }, + ]); + editorA.destroy(); + + const editorB = createEditor({ + document, + }); + + expect(editorB.getBlock(blockId)?.textContent()).toBe("Persisted"); + + editorB.destroy(); + }); + + it("persists document profile metadata for new editors", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + + expect(editor.documentProfile).toBe("flow"); + expect(editor.documentState.documentProfile).toBe("flow"); + expect(editor.editorViewMode).toBe("flow"); + expect( + editor.internals.adapter.getDocumentProfile?.( + editor.internals.crdtDoc, + ), + ).toBe("flow"); + + editor.destroy(); + }); + + it("loads persisted document profile independently from local editor view mode", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + adapter.setDocumentProfile?.(document, "flow"); + + const editor = createEditor({ + document, + editorViewMode: "structured", + }); + + expect(editor.documentProfile).toBe("flow"); + expect(editor.documentState.documentProfile).toBe("flow"); + expect(editor.editorViewMode).toBe("structured"); + + editor.destroy(); + }); + + it("keeps document profile in sync with persisted metadata changes", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editor = createEditor({ + document, + }); + + expect(editor.documentProfile).toBe("structured"); + expect(editor.documentState.documentProfile).toBe("structured"); + + adapter.setDocumentProfile?.(document, "flow"); + + expect(editor.documentProfile).toBe("flow"); + expect(editor.documentState.documentProfile).toBe("flow"); + expect(editor.editorViewMode).toBe("flow"); + + editor.destroy(); + }); + + it("drops flow-disallowed block insertions at the mutation boundary", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "db1", + blockType: "database", + props: {}, + position: "last", + }, + ]); + + expect(editor.getBlock("db1")).toBeNull(); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_PROFILE_001", + level: "warn", + source: "profile-policy", + blockType: "database", + documentProfile: "flow", + }), + ); + + editor.destroy(); + }); + + it("re-applies the flow mutation boundary after extension hooks run", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.onBeforeApply( + (ops) => [ + ...ops, + { + type: "insert-block", + blockId: "db-after-hook", + blockType: "database", + props: {}, + position: "last", + }, + ], + { priority: 20000 }, + ); + + editor.apply([ + { + type: "insert-block", + blockId: "p-after-hook", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + expect(editor.getBlock("p-after-hook")?.type).toBe("paragraph"); + expect(editor.getBlock("db-after-hook")).toBeNull(); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_PROFILE_001", + blockType: "database", + documentProfile: "flow", + }), + ); + + editor.destroy(); + }); + + it("drops flow-disallowed block conversions at the mutation boundary", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + const firstBlockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, + ]); + + editor.apply([ + { + type: "convert-block", + blockId: firstBlockId, + newType: "database", + newProps: {}, + }, + ]); + + expect(editor.getBlock(firstBlockId)?.type).toBe("paragraph"); + expect(editor.getBlock(firstBlockId)?.textContent()).toBe("Hello"); + + editor.destroy(); + }); + + it("still allows optional structural blocks in flow documents", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + + editor.apply([ + { + type: "insert-block", + blockId: "table1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + expect(editor.getBlock("table1")?.type).toBe("table"); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part2.test.ts b/packages/core/src/__tests__/editorCore.part2.test.ts new file mode 100644 index 0000000..89654c6 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part2.test.ts @@ -0,0 +1,438 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 8): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("discovers subdocument scopes and lets nested editors edit them", () => { + const session = createDocumentSession({ + adapter: yjsAdapter(), + }); + const rootEditor = createEditor({ + documentSession: session, + }); + + rootEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested" }, + position: "last", + }, + ]); + + const childScope = session.getScopeForBlock("subdoc-block", { + scopeId: rootEditor.documentScope.id, + }); + expect(childScope).not.toBeNull(); + expect(rootEditor.getBlock("subdoc-block")?.props.subdocumentGuid).toBe( + childScope?.guid, + ); + + const childEditor = createEditor({ + documentSession: session, + documentScopeId: childScope!.id, + }); + const childBlockId = childEditor.firstBlock()!.id; + + childEditor.apply([ + { + type: "insert-text", + blockId: childBlockId, + offset: 0, + text: "Nested content", + }, + ]); + + expect(childEditor.getBlock(childBlockId)?.textContent()).toBe( + "Nested content", + ); + expect(childEditor.documentScope.parentId).toBe( + rootEditor.documentScope.id, + ); + expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); + + childEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested Nested" }, + position: "last", + }, + ]); + + const nestedScope = session.getScopeForBlock("subdoc-block", { + scopeId: childEditor.documentScope.id, + }); + expect(nestedScope).not.toBeNull(); + expect(nestedScope?.id).not.toBe(childScope?.id); + expect(session.getScopeForBlock("subdoc-block")).toBeNull(); + + childEditor.destroy(); + rootEditor.destroy(); + session.destroy(); + }); + + it("supports delegated document session implementations for scope replacement", async () => { + const baseSession = createDocumentSession({ + adapter: yjsAdapter(), + }); + const delegatedSession: DocumentSession = { + adapter: baseSession.adapter, + get rootScope() { + return baseSession.rootScope; + }, + getScope: (scopeId) => baseSession.getScope(scopeId), + getScopeByGuid: (guid) => baseSession.getScopeByGuid(guid), + getScopeForBlock: (blockId, options) => + baseSession.getScopeForBlock(blockId, options), + listScopes: () => baseSession.listScopes(), + getAwareness: (scopeId) => baseSession.getAwareness(scopeId), + observe: (scopeId, callback) => + baseSession.observe(scopeId, callback), + observeAll: (callback) => baseSession.observeAll(callback), + createSubdocument: (blockId, options) => + baseSession.createSubdocument(blockId, options), + loadSubdocument: (scopeId) => baseSession.loadSubdocument(scopeId), + replaceScopeDocument: (scopeId, doc, options) => + baseSession.replaceScopeDocument(scopeId, doc, options), + attachEditor: (options) => baseSession.attachEditor(options), + destroy: () => baseSession.destroy(), + }; + const editor = createEditor({ + documentSession: delegatedSession, + }); + const originalDoc = editor.internals.crdtDoc; + const replacementSource = createEditor(); + const replacementDoc = delegatedSession.adapter.loadDocument( + delegatedSession.adapter.encodeState( + replacementSource.internals.crdtDoc, + ), + ); + + delegatedSession.replaceScopeDocument( + editor.documentScope.id, + replacementDoc, + ); + await flushMicrotasks(); + + expect(editor.internals.crdtDoc).toBe(replacementDoc); + expect(editor.internals.crdtDoc).not.toBe(originalDoc); + expect(editor.firstBlock()).not.toBeNull(); + + replacementSource.destroy(); + editor.destroy(); + delegatedSession.destroy(); + }); + + it("rebinds child-scope editors when the root session document is replaced", async () => { + const session = createDocumentSession({ + adapter: yjsAdapter(), + }); + const rootEditor = createEditor({ + documentSession: session, + }); + rootEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested" }, + position: "last", + }, + ]); + const childScope = session.getScopeForBlock("subdoc-block", { + scopeId: rootEditor.documentScope.id, + }); + const childEditor = createEditor({ + documentSession: session, + documentScopeId: childScope!.id, + }); + const childBlockId = childEditor.firstBlock()!.id; + childEditor.apply([ + { + type: "insert-text", + blockId: childBlockId, + offset: 0, + text: "Original nested content", + }, + ]); + + const replacementSession = createDocumentSession({ + adapter: yjsAdapter(), + ownsDocuments: false, + }); + const replacementRootEditor = createEditor({ + documentSession: replacementSession, + }); + replacementRootEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested" }, + position: "last", + }, + ]); + const replacementChildScope = replacementSession.getScopeForBlock( + "subdoc-block", + { + scopeId: replacementRootEditor.documentScope.id, + }, + ); + const replacementChildEditor = createEditor({ + documentSession: replacementSession, + documentScopeId: replacementChildScope!.id, + }); + const replacementChildBlockId = replacementChildEditor.firstBlock()!.id; + replacementChildEditor.apply([ + { + type: "insert-text", + blockId: replacementChildBlockId, + offset: 0, + text: "Replacement nested content", + }, + ]); + + session.replaceScopeDocument( + rootEditor.documentScope.id, + replacementSession.rootScope.doc, + ); + await flushMicrotasks(); + + expect(childEditor.firstBlock()?.textContent()).toBe( + "Replacement nested content", + ); + expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); + expect(childEditor.documentScope.parentId).toBe( + rootEditor.documentScope.id, + ); + + replacementChildEditor.destroy(); + replacementRootEditor.destroy(); + replacementSession.destroy(); + childEditor.destroy(); + rootEditor.destroy(); + session.destroy(); + }); + + it("creates a working editor with default schema and extensions", () => { + const editor = createDefaultEditor(); + + expect(editor.schema.resolve("paragraph")).toBeTruthy(); + expect(typeof editor.clientId).toBe("number"); + expect(editor.internals.getSlot("core:engine")).toBe( + editor.internals.engine, + ); + expect( + editor.internals.getSlot("document-ops:toolRuntime"), + ).toBeTruthy(); + expect(editor.internals.getSlot("undo:manager")).toBeTruthy(); + + editor.destroy(); + }); + + it("starts with a single empty paragraph block in zero-config mode", () => { + const editor = createDefaultEditor(); + + expect(editor.blockCount()).toBe(1); + expect(editor.firstBlock()?.type).toBe("paragraph"); + expect(editor.firstBlock()?.textContent()).toBe(""); + + editor.destroy(); + }); + + it("applies insert-block and insert-text operations", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello"); + + editor.destroy(); + }); + + it("moves the text selection after accepting an inline completion", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const { controller } = ensureInlineCompletionController(editor); + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + editor.selectText(blockId, 5, 5); + controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 5, + text: " world", + type: "inline", + }); + + expect(controller.acceptSuggestion()).toBe(true); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 11 }, + focus: { blockId, offset: 11 }, + }); + + editor.destroy(); + }); + + it("splits and merges inline blocks", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello world", + }, + ]); + + editor.apply([ + { + type: "split-block", + blockId: "b1", + offset: 5, + newBlockId: "b2", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello"); + expect(editor.getBlock("b2")?.textContent()).toBe(" world"); + + editor.apply([ + { + type: "merge-blocks", + targetBlockId: "b1", + sourceBlockId: "b2", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello world"); + expect(editor.getBlock("b2")).toBeNull(); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part3.test.ts b/packages/core/src/__tests__/editorCore.part3.test.ts new file mode 100644 index 0000000..f57152f --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part3.test.ts @@ -0,0 +1,411 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("splits at offset zero by inserting an empty block above", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "hello world", + }, + ]); + + editor.apply([ + { + type: "split-block", + blockId, + offset: 0, + newBlockId: "b2", + }, + ]); + + expect(editor.documentState.blockOrder).toEqual([blockId, "b2"]); + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.getBlock("b2")?.textContent()).toBe("hello world"); + + editor.destroy(); + }); + + it("preserves full text offsets for code blocks", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "convert-block", blockId, newType: "codeBlock" }, + { type: "insert-text", blockId, offset: 0, text: "abcd" }, + ]); + + editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 3 }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 3 }, + }); + expect(editor.getSelectedText()).toBe("bc"); + + editor.destroy(); + }); + + it("clears stale grid state when converting table or database blocks", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "table-block", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "database-block", + blockType: "database", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "database-insert-row", + blockId: "database-block", + rowId: "row-1", + values: { + name: "Alpha", + tags: "todo", + status: "true", + }, + }, + { + type: "convert-block", + blockId: "table-block", + newType: "paragraph", + }, + { + type: "convert-block", + blockId: "database-block", + newType: "paragraph", + }, + ]); + + const tableBlock = editor.getBlock("table-block")!; + const databaseBlock = editor.getBlock("database-block")!; + expect(tableBlock.type).toBe("paragraph"); + expect(tableBlock.tableRowCount()).toBe(0); + expect(tableBlock.tableColumns()).toEqual([]); + expect(tableBlock.databaseViews()).toEqual([]); + + expect(databaseBlock.type).toBe("paragraph"); + expect(databaseBlock.tableRowCount()).toBe(0); + expect(databaseBlock.tableColumns()).toEqual([]); + expect(databaseBlock.databaseViews()).toEqual([]); + expect(databaseBlock.databasePrimaryViewId()).toBeNull(); + + const tableBlockMap = editor.internals.doc.blocks.get( + "table-block", + ) as TestBlockMapLike; + const databaseBlockMap = editor.internals.doc.blocks.get( + "database-block", + ) as TestBlockMapLike; + expect(tableBlockMap.get("tableContent")).toBeUndefined(); + expect(tableBlockMap.get("tableColumns")).toBeUndefined(); + expect(tableBlockMap.get("databaseViews")).toBeUndefined(); + expect(tableBlockMap.get("databasePrimaryViewId")).toBeUndefined(); + expect(databaseBlockMap.get("tableContent")).toBeUndefined(); + expect(databaseBlockMap.get("tableColumns")).toBeUndefined(); + expect(databaseBlockMap.get("databaseViews")).toBeUndefined(); + expect(databaseBlockMap.get("databasePrimaryViewId")).toBeUndefined(); + + editor.destroy(); + }); + + it("queues reentrant apply calls from observe hooks", () => { + let appended = false; + const ext = defineExtension({ + name: "append-exclamation", + observe(events, editor) { + if (appended) return; + const hasInsertText = events.some((event) => + event.ops.some((op) => op.type === "insert-text"), + ); + if (!hasInsertText) return; + + appended = true; + editor.apply( + [ + { + type: "insert-text", + blockId: "b1", + offset: 5, + text: "!", + }, + ], + { origin: "extension" }, + ); + }, + }); + + const editor = createEditor({ + extensions: [ext], + }); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello!"); + + editor.destroy(); + }); + + it("activates input-rules extensions and applies block conversions", async () => { + const editor = createEditor({ + extensions: [inputRulesExtension()], + }); + const blockId = editor.firstBlock()!.id; + + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 0 }); + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "#", + }, + ], + { origin: "user" }, + ); + editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 1 }); + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 1, + text: " ", + }, + ], + { origin: "user" }, + ); + await flushMicrotasks(); + + expect(editor.getBlock(blockId)?.type).toBe("heading"); + expect(editor.getBlock(blockId)?.props.level).toBe(1); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); + + editor.destroy(); + }); + + it("activates input-rules extensions and applies inline markdown conversions", async () => { + const editor = createEditor({ + extensions: [inputRulesExtension()], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "**hello*", + }, + ], + { origin: "user" }, + ); + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 8, + text: "*", + }, + ], + { origin: "user" }, + ); + await flushMicrotasks(); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "hello", + ); + expect(editor.getBlock(blockId)?.textDeltas()).toEqual([ + { + insert: "hello", + attributes: { bold: true }, + }, + ]); + + editor.destroy(); + }); + + it("emits unified change and documentCommit once for a local apply batch", () => { + const observed: unknown[][] = []; + const ext = defineExtension({ + name: "capture-local-dispatch", + observe(events) { + observed.push(events); + }, + }); + const editor = createEditor({ + extensions: [ext], + }); + const changes: unknown[][] = []; + const documentCommits: unknown[] = []; + const blockId = editor.firstBlock()!.id; + + editor.on("change", (events) => { + changes.push(events); + }); + editor.on("documentCommit", (event) => { + documentCommits.push(event); + }); + observed.length = 0; + changes.length = 0; + documentCommits.length = 0; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "hello", + }, + ]); + + expect(changes).toHaveLength(1); + expect(changes[0]).toHaveLength(1); + expect(changes[0][0]).toMatchObject({ + origin: "user", + affectedBlocks: [blockId], + }); + expect(documentCommits).toHaveLength(1); + expect(documentCommits[0]).toMatchObject({ + commitId: 2, + origin: "user", + affectedBlocks: [blockId], + }); + expect( + (documentCommits[0] as { blockRevisions: Record }) + .blockRevisions[blockId], + ).toBe(editor.getBlockRevision(blockId)); + expect(observed).toHaveLength(1); + expect(observed[0]).toHaveLength(1); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part4.test.ts b/packages/core/src/__tests__/editorCore.part4.test.ts new file mode 100644 index 0000000..22aaf62 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part4.test.ts @@ -0,0 +1,447 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("emits unified change and documentCommit once for observed CRDT updates", () => { + const observed: unknown[][] = []; + const ext = defineExtension({ + name: "capture-observed-dispatch", + observe(events) { + observed.push(events); + }, + }); + const editor = createEditor({ + extensions: [ext], + }); + const changes: unknown[][] = []; + const documentCommits: unknown[] = []; + const adapter = editor.internals.adapter; + const editorDoc = editor.internals.crdtDoc; + const blockId = editor.firstBlock()!.id; + const remoteDoc = adapter.loadDocument(adapter.encodeState(editorDoc)); + const remoteYDoc = adapter.raw(remoteDoc); + const remoteYText = remoteYDoc + .getMap("blocks") + .get(blockId) + ?.get("content") as TestYTextLike | undefined; + if (!remoteYText) { + throw new Error(`Missing collaborator text for block ${blockId}`); + } + + editor.on("change", (events) => { + changes.push(events); + }); + editor.on("documentCommit", (event) => { + documentCommits.push(event); + }); + observed.length = 0; + changes.length = 0; + documentCommits.length = 0; + + adapter.transact( + remoteDoc, + () => { + remoteYText.insert(0, "remote "); + }, + "collaborator", + ); + adapter.applyUpdate(editorDoc, adapter.encodeState(remoteDoc)); + + expect(changes).toHaveLength(1); + expect(changes[0]).toHaveLength(1); + expect(changes[0][0]).toMatchObject({ + affectedBlocks: [blockId], + }); + expect(documentCommits).toHaveLength(1); + expect(documentCommits[0]).toMatchObject({ + commitId: 2, + affectedBlocks: [blockId], + }); + expect( + (documentCommits[0] as { blockRevisions: Record }) + .blockRevisions[blockId], + ).toBe(editor.getBlockRevision(blockId)); + expect(observed).toHaveLength(1); + expect(observed[0]).toHaveLength(1); + + editor.destroy(); + }); + + it("clamps text selections and returns backwards selected text", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello", + }, + ]); + + editor.selectText("b1", 10, 99); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 5 }, + focus: { blockId: "b1", offset: 5 }, + }); + + editor.setSelection({ + type: "text", + anchor: { blockId: "b1", offset: 5 }, + focus: { blockId: "b1", offset: 2 }, + isCollapsed: false, + isMultiBlock: false, + blockRange: ["b1"], + toRange: () => { + throw new Error("test helper"); + }, + }); + + expect(editor.getSelectedText()).toBe("llo"); + + editor.destroy(); + }); + + it("selects text ranges across blocks in document order", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 3 }, + ); + + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 2 }, + focus: { blockId: "b3", offset: 3 }, + isMultiBlock: true, + blockRange: ["b1", "b2", "b3"], + }); + expect(editor.getSelectedText()).toBe("llo\nWorld\nAga"); + expect(editor.getSelectedBlocks().map((block) => block.id)).toEqual([ + "b1", + "b2", + "b3", + ]); + + editor.destroy(); + }); + + it("deletes multi-block text selections and collapses at the start", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("b1")?.textContent()).toBe("Heain"); + expect(editor.getBlock("b2")).toBeNull(); + expect(editor.getBlock("b3")).toBeNull(); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 2 }, + focus: { blockId: "b1", offset: 2 }, + isMultiBlock: false, + blockRange: ["b1"], + }); + + editor.destroy(); + }); + + it("deletes a fully selected structural block", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "d1", + blockType: "divider", + props: {}, + position: "last", + }, + ]); + + editor.selectTextRange( + { blockId: "d1", offset: 0 }, + { blockId: "d1", offset: 1 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("d1")).toBeNull(); + expect(editor.getSelection()).toBeNull(); + + editor.destroy(); + }); + + it("deletes a fully selected delegated block", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.selectTextRange( + { blockId: "t1", offset: 0 }, + { blockId: "t1", offset: 1 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("t1")).toBeNull(); + expect(editor.getSelection()).toBeNull(); + + editor.destroy(); + }); + + it("deletes structural blocks at multi-block selection boundaries", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "p1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "d1", + blockType: "divider", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "p1", offset: 0, text: "Hello" }, + ]); + + editor.selectTextRange( + { blockId: "p1", offset: 2 }, + { blockId: "d1", offset: 1 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("p1")?.textContent()).toBe("He"); + expect(editor.getBlock("d1")).toBeNull(); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "p1", offset: 2 }, + focus: { blockId: "p1", offset: 2 }, + isMultiBlock: false, + blockRange: ["p1"], + }); + + editor.destroy(); + }); + + it("replaces multi-block text selections at a single insertion point", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + editor.replaceSelection("X"); + + expect(editor.getBlock("b1")?.textContent()).toBe("HeXain"); + expect(editor.getBlock("b2")).toBeNull(); + expect(editor.getBlock("b3")).toBeNull(); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 3 }, + focus: { blockId: "b1", offset: 3 }, + isMultiBlock: false, + blockRange: ["b1"], + }); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part5.test.ts b/packages/core/src/__tests__/editorCore.part5.test.ts new file mode 100644 index 0000000..855a5fb --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part5.test.ts @@ -0,0 +1,434 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("preserves formatted suffix text when deleting across blocks", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "Again" }, + { + type: "format-text", + blockId: "b2", + offset: 2, + length: 3, + marks: { bold: true }, + }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b2", offset: 2 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("b1")?.textDeltas()).toEqual([ + { insert: "He" }, + { + insert: "ain", + attributes: { bold: true }, + }, + ]); + expect(editor.getBlock("b2")).toBeNull(); + + editor.destroy(); + }); + + it("replaces multi-block text selections in a single document commit batch", () => { + const editor = createEditor(); + const events: Array<{ ops: readonly { type: string }[] }> = []; + + editor.on("documentCommit", (event) => { + events.push(event as { ops: readonly { type: string }[] }); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + events.length = 0; + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + editor.replaceSelection("X"); + + expect(events).toHaveLength(1); + expect(events[0]?.ops.map((op) => op.type)).toEqual([ + "delete-text", + "delete-text", + "delete-block", + "insert-text", + "insert-text", + "delete-block", + ]); + + editor.destroy(); + }); + + it("rebinds undo manager after loadDocument", async () => { + const editor = createDefaultEditor(); + const newDoc = editor.internals.adapter.createDocument(); + + editor.loadDocument(newDoc); + await flushMicrotasks(); + + expect(editor.undoManager).toBe( + editor.internals.getSlot("undo:manager"), + ); + + editor.destroy(); + }); + + it("waits for async extension teardown before reactivating after loadDocument", async () => { + const steps: string[] = []; + let activationCount = 0; + let resolveDeactivate!: () => void; + const deactivatePromise = new Promise((resolve) => { + resolveDeactivate = resolve; + }); + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "async-lifecycle", + activateClient: async () => { + activationCount += 1; + steps.push(`activate:${activationCount}`); + }, + deactivateClient: async () => { + steps.push("deactivate:start"); + await deactivatePromise; + steps.push("deactivate:end"); + }, + }), + ], + }); + + await flushMicrotasks(); + + editor.loadDocument(editor.internals.adapter.createDocument()); + await flushMicrotasks(); + + expect(steps).toEqual(["activate:1", "deactivate:start"]); + + resolveDeactivate(); + await flushMicrotasks(4); + + expect(steps).toEqual([ + "activate:1", + "deactivate:start", + "deactivate:end", + "activate:2", + ]); + + editor.destroy(); + }); + + it("refreshes editor.undoManager immediately when the undo slot is set", async () => { + const registeredUndoManager = { + undo: () => false, + redo: () => false, + canUndo: () => false, + canRedo: () => false, + stopCapturing: () => {}, + syncExplicitUndoGroup: () => {}, + setGroupTimeout: () => {}, + registerTrackedOrigins: () => () => {}, + onStackChange: () => () => {}, + }; + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "test-undo-slot", + activateClient: async ({ editor }) => { + expect(editor.undoManager).not.toBe( + registeredUndoManager, + ); + editor.internals.setSlot( + "undo:manager", + registeredUndoManager, + ); + expect(editor.undoManager).toBe(registeredUndoManager); + }, + }), + ], + }); + + await Promise.resolve(); + + expect(editor.undoManager).toBe(registeredUndoManager); + + editor.destroy(); + }); + + it("updates documentState parent relationships after parentId changes", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "parent", + blockType: "toggle", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "child", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "update-block", + blockId: "child", + props: { parentId: "parent" }, + }, + ]); + + expect(editor.documentState.parentOf("child")).toBe("parent"); + + editor.apply([ + { + type: "update-block", + blockId: "child", + props: { parentId: null }, + }, + ]); + + expect(editor.documentState.parentOf("child")).toBeNull(); + + editor.destroy(); + }); + + it("emits structured diagnostics for unknown block types", () => { + const editor = createEditor(); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "unknown", + blockType: "not-real", + props: {}, + position: "last", + }, + ]); + + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + }), + ); + + editor.destroy(); + }); + + it("emits remediation text for extension observe failures", () => { + const diagnostics: unknown[] = []; + const ext = defineExtension({ + name: "broken-observe", + observe() { + throw new Error("boom"); + }, + }); + const editor = createEditor({ + extensions: [ext], + }); + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_EXT_001", + level: "error", + source: "extension", + remediation: expect.any(String), + }), + ); + + editor.destroy(); + }); + + it("emits diagnostics for rejected async extension activation", async () => { + const diagnostics: unknown[] = []; + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "broken-async-activate", + activateClient: async () => { + await Promise.resolve(); + throw new Error("boom"); + }, + }), + ], + }); + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + await flushMicrotasks(4); + + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_EXT_004", + level: "error", + source: "extension", + extension: "broken-async-activate", + remediation: expect.any(String), + }), + ); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part6.test.ts b/packages/core/src/__tests__/editorCore.part6.test.ts new file mode 100644 index 0000000..160643c --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part6.test.ts @@ -0,0 +1,382 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("processes streamed AI deltas through the default delta-stream pipeline", async () => { + const editor = createDefaultEditor(); + const blockId = editor.firstBlock()!.id; + + await processStream( + createStream([ + { type: "gen-start", zoneId: "zone-1", blockId }, + { type: "gen-delta", zoneId: "zone-1", delta: "Hello " }, + { type: "gen-delta", zoneId: "zone-1", delta: "world" }, + { type: "gen-end", zoneId: "zone-1", status: "complete" }, + ]), + editor, + ); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "Hello world", + ); + expect( + editor.internals.getSlot<{ generationZone: unknown }>( + "delta-stream:target", + )?.generationZone ?? null, + ).toBeNull(); + + editor.destroy(); + }); + + it("keeps streamed AI generations in their own undo group", async () => { + const editor = createDefaultEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + + editor.apply( + [ + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "system" }, + ); + + editor.apply( + [ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "hello", + }, + ], + { origin: "user" }, + ); + + await processStream( + createStream([ + { type: "gen-start", zoneId: "zone-2", blockId: secondBlockId }, + { type: "gen-delta", zoneId: "zone-2", delta: "AI output" }, + { type: "gen-end", zoneId: "zone-2", status: "complete" }, + ]), + editor, + ); + + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "hello", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "hello", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + expect(editor.undoManager.redo()).toBe(true); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + editor.destroy(); + }); + + it("keeps concurrent user edits outside the generation zone in a separate undo group", async () => { + const editor = createDefaultEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + + editor.apply( + [ + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "system" }, + ); + + await processStream( + (async function* (): AsyncIterable { + yield { + type: "gen-start", + zoneId: "zone-concurrent", + blockId: secondBlockId, + }; + + editor.apply( + [ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "user edit", + }, + ], + { origin: "user" }, + ); + + yield { + type: "gen-delta", + zoneId: "zone-concurrent", + delta: "AI output", + }; + yield { + type: "gen-end", + zoneId: "zone-concurrent", + status: "complete", + }; + })(), + editor, + ); + + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "user edit", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "user edit", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + expect(editor.undoManager.redo()).toBe(true); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + editor.destroy(); + }); + + it("keeps user edits inside the generation zone in the same undo group", async () => { + const editor = createDefaultEditor(); + const blockId = editor.firstBlock()!.id; + + await processStream( + (async function* (): AsyncIterable { + yield { type: "gen-start", zoneId: "zone-shared", blockId }; + yield { + type: "gen-delta", + zoneId: "zone-shared", + delta: "AI ", + }; + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 3, + text: "user ", + }, + ], + { origin: "user" }, + ); + + yield { + type: "gen-delta", + zoneId: "zone-shared", + delta: "output", + }; + yield { + type: "gen-end", + zoneId: "zone-shared", + status: "complete", + }; + })(), + editor, + ); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "user AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); + + expect(editor.undoManager.redo()).toBe(true); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "user AI output", + ); + + editor.destroy(); + }); + + it("tracks imported edits in the undo stack", () => { + const editor = createEditorWithUndo(); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "Imported text", + }, + ], + { origin: "import", undoGroup: true }, + ); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "Imported text", + ); + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); + + editor.destroy(); + }); + + it("emits history origin for undo transactions on documentCommit", () => { + const editor = createEditorWithUndo(); + const blockId = editor.firstBlock()!.id; + const commitOrigins: string[] = []; + + editor.on("documentCommit", (event) => { + commitOrigins.push(getOpOriginType(event.origin)); + }); + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Hello", + }, + ]); + + editor.undoManager.undo(); + + expect(commitOrigins).toContain("user"); + expect(commitOrigins).toContain("history"); + + editor.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/editorCore.part7.test.ts b/packages/core/src/__tests__/editorCore.part7.test.ts new file mode 100644 index 0000000..98d9204 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part7.test.ts @@ -0,0 +1,428 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core table operations", () => { + it("insert-block with table type produces seeded 2x2 grid", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.type).toBe("table"); + expect(block.tableRowCount()).toBe(2); + expect(block.tableColumnCount()).toBe(2); + + const cell = block.tableCell(0, 0)!; + expect(cell).not.toBeNull(); + expect(cell.id).toEqual(expect.any(String)); + expect(cell.textContent()).toBe(""); + + editor.destroy(); + }); + + it("insert-table-row adds a row matching existing column count", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-row", + blockId: "t1", + index: 2, + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.tableRowCount()).toBe(3); + expect(block.tableColumnCount()).toBe(2); + expect(block.tableCell(2, 0)).not.toBeNull(); + expect(block.tableCell(2, 1)).not.toBeNull(); + + editor.destroy(); + }); + + it("repairs table width from the widest row when legacy rows are short", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-column", + blockId: "t1", + index: 2, + }, + ]); + + const blockMap = editor.internals.doc.blocks.get( + "t1", + ) as TestBlockMapLike; + const tableContent = blockMap.get( + "tableContent", + ) as TestTableContentLike; + const firstRow = tableContent.get(0); + firstRow.get("cells").delete(2, 1); + + let block = editor.getBlock("t1")!; + expect(block.tableColumnCount()).toBe(3); + + editor.apply([ + { + type: "insert-table-row", + blockId: "t1", + index: block.tableRowCount(), + }, + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 2, + offset: 0, + text: "Recovered", + }, + ]); + + block = editor.getBlock("t1")!; + expect(block.tableRowCount()).toBe(3); + expect(block.tableCell(0, 2)?.textContent()).toBe("Recovered"); + expect(block.tableCell(2, 0)).not.toBeNull(); + expect(block.tableCell(2, 1)).not.toBeNull(); + expect(block.tableCell(2, 2)).not.toBeNull(); + + editor.destroy(); + }); + + it("insert-table-column adds a column to all rows", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-column", + blockId: "t1", + index: 2, + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.tableRowCount()).toBe(2); + expect(block.tableColumnCount()).toBe(3); + expect(block.tableCell(0, 2)).not.toBeNull(); + expect(block.tableCell(1, 2)).not.toBeNull(); + + editor.destroy(); + }); + + it("delete-table-row removes a row", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "delete-table-row", + blockId: "t1", + index: 0, + }, + ]); + + expect(editor.getBlock("t1")!.tableRowCount()).toBe(1); + + editor.destroy(); + }); + + it("delete-table-column removes a column from all rows", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "delete-table-column", + blockId: "t1", + index: 0, + }, + ]); + + expect(editor.getBlock("t1")!.tableColumnCount()).toBe(1); + + editor.destroy(); + }); + + it("insert-table-cell-text writes text into a specific cell", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 1, + offset: 0, + text: "Hello", + }, + ]); + + const cell = editor.getBlock("t1")!.tableCell(0, 1)!; + expect(cell.textContent()).toBe("Hello"); + + editor.destroy(); + }); + + it("delete-table-cell-text removes text from a specific cell", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 0, + text: "Hello", + }, + { + type: "delete-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 1, + length: 3, + }, + ]); + + const cell = editor.getBlock("t1")!.tableCell(0, 0)!; + expect(cell.textContent()).toBe("Ho"); + + editor.destroy(); + }); + + it("format-table-cell-text applies formatting to cell text", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 0, + text: "bold text", + }, + { + type: "format-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 0, + length: 4, + marks: { bold: true }, + }, + ]); + + const cell = editor.getBlock("t1")!.tableCell(0, 0)!; + const deltas = cell.textDeltas(); + expect(deltas[0].insert).toBe("bold"); + expect(deltas[0].attributes).toEqual({ bold: true }); + expect(deltas[1].insert).toBe(" text"); + + editor.destroy(); + }); + + it("convert-block to table seeds tableContent", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "convert-block", + blockId: "b1", + newType: "table", + newProps: {}, + }, + ]); + + const block = editor.getBlock("b1")!; + expect(block.type).toBe("table"); + expect(block.tableRowCount()).toBe(2); + expect(block.tableColumnCount()).toBe(2); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part8.test.ts b/packages/core/src/__tests__/editorCore.part8.test.ts new file mode 100644 index 0000000..5db979d --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part8.test.ts @@ -0,0 +1,219 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core table operations", () => { + it("convert-block to table preserves inline text in the first cell", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "Hello table", + }, + ]); + + editor.apply([ + { + type: "convert-block", + blockId: "b1", + newType: "table", + newProps: {}, + }, + ]); + + const block = editor.getBlock("b1")!; + expect(block.type).toBe("table"); + expect(block.tableCell(0, 0)?.textContent()).toBe("Hello table"); + expect(block.tableCell(0, 1)?.textContent()).toBe(""); + expect(block.tableCell(1, 0)?.textContent()).toBe(""); + expect(block.tableCell(1, 1)?.textContent()).toBe(""); + + editor.destroy(); + }); + + it("tableCell returns null for out-of-bounds coordinates", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.tableCell(-1, 0)).toBeNull(); + expect(block.tableCell(0, -1)).toBeNull(); + expect(block.tableCell(99, 0)).toBeNull(); + expect(block.tableCell(0, 99)).toBeNull(); + + editor.destroy(); + }); + + it("tableRowCount/tableColumnCount return 0 for non-table blocks", () => { + const editor = createEditor(); + + const block = editor.firstBlock()!; + expect(block.tableRowCount()).toBe(0); + expect(block.tableColumnCount()).toBe(0); + expect(block.tableCell(0, 0)).toBeNull(); + + editor.destroy(); + }); + + it("caches decoration snapshots between decoration updates", () => { + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "test-decorations", + decorations(_state, currentEditor) { + const blockId = currentEditor.firstBlock()?.id; + if (!blockId) { + return createDecorationSet([]); + } + + return createDecorationSet([ + { + type: "block", + blockId, + attributes: { active: true }, + }, + ]); + }, + }), + ], + }); + + const initialDecorations = editor.getDecorations(); + const repeatedDecorations = editor.getDecorations(); + expect(repeatedDecorations).toBe(initialDecorations); + + editor.apply( + [ + { + type: "insert-text", + blockId: editor.firstBlock()!.id, + offset: 0, + text: "trigger", + }, + ], + { origin: "user" }, + ); + + const autoRefreshedDecorations = editor.getDecorations(); + expect(autoRefreshedDecorations).not.toBe(initialDecorations); + expect(editor.getDecorations()).toBe(autoRefreshedDecorations); + + editor.requestDecorationUpdate(); + + const refreshedDecorations = editor.getDecorations(); + expect(refreshedDecorations).not.toBe(autoRefreshedDecorations); + expect(editor.getDecorations()).toBe(refreshedDecorations); + + editor.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/editorCore.test.ts b/packages/core/src/__tests__/editorCore.test.ts deleted file mode 100644 index 0583084..0000000 --- a/packages/core/src/__tests__/editorCore.test.ts +++ /dev/null @@ -1,2494 +0,0 @@ -import { yjsAdapter } from "@pen/crdt-yjs"; -import { processStream } from "@pen/delta-stream"; -import { inputRulesExtension } from "@pen/input-rules"; -import { undoExtension } from "@pen/undo"; -import { - defineExtension, - type DocumentSession, - type PenStreamPart, - getOpOriginType, -} from "@pen/types"; -import { describe, expect, it, vi } from "vitest"; - -import { - createDecorationSet, - createDocumentSession, - createEditor as createCoreEditor, - createHeadlessEditor, - ensureInlineCompletionController, -} from "../index"; - -const noDefaultExtensionsPreset = { - resolve() { - return { extensions: [] }; - }, -}; - -const undoOnlyPreset = { - resolve() { - return { extensions: [undoExtension()] }; - }, -}; - -function createEditor(options: Parameters[0] = {}) { - return createCoreEditor({ - ...options, - preset: options.preset ?? noDefaultExtensionsPreset, - }); -} - -function createDefaultEditor( - options: Parameters[0] = {}, -) { - return createCoreEditor(options); -} - -function createEditorWithUndo( - options: Parameters[0] = {}, -) { - return createCoreEditor({ - ...options, - preset: options.preset ?? undoOnlyPreset, - }); -} - -async function* createStream(parts: PenStreamPart[]) { - for (const part of parts) { - yield part; - } -} - -async function flushMicrotasks(count = 2): Promise { - for (let index = 0; index < count; index++) { - await Promise.resolve(); - } -} - -function visibleText(text: string): string { - return text.replace(/\u200B/g, ""); -} - -type TestYTextLike = { - insert(offset: number, text: string): void; -}; - -type TestBlockMapLike = { - get(key: string): unknown; -}; - -type TestBlocksMapLike = { - get(key: string): TestBlockMapLike | undefined; -}; - -type TestRawDocLike = { - getMap(name: "blocks"): TestBlocksMapLike; -}; - -type TestTableRowLike = { - get(field: "cells"): { delete(index: number, length: number): void }; -}; - -type TestTableContentLike = { - get(index: number): TestTableRowLike; -}; - -describe("@pen/core createEditor", () => { - it("warns once when using the deprecated without option", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const editor = createCoreEditor({ - without: ["document-ops"], - }); - editor.destroy(); - - expect(warnSpy).toHaveBeenCalledWith( - "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", - ); - - warnSpy.mockRestore(); - }); - - it("installs extensions from presets before user extensions", () => { - const editor = createEditor({ - preset: { - resolve() { - return { - extensions: [ - defineExtension({ - name: "preset-test-extension", - activateClient: async (ctx) => { - ctx.editor.internals.setSlot( - "test:preset-installed", - true, - ); - }, - }), - ], - }; - }, - }, - }); - - expect(editor.internals.getSlot("test:preset-installed")).toBe(true); - - editor.destroy(); - }); - - it("supports multiple editors sharing one document session", () => { - const session = createDocumentSession({ - adapter: yjsAdapter(), - }); - const editorA = createEditor({ - documentSession: session, - }); - const editorB = createEditor({ - documentSession: session, - }); - const blockId = editorA.firstBlock()!.id; - - editorA.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Shared", - }, - ]); - - expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared"); - expect(editorA.documentScope.id).toBe(editorB.documentScope.id); - expect(editorA.internals.documentSession).toBe(session); - expect(editorB.internals.documentSession).toBe(session); - - editorA.destroy(); - editorB.apply([ - { - type: "insert-text", - blockId, - offset: 6, - text: " doc", - }, - ]); - - expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared doc"); - - editorB.destroy(); - session.destroy(); - }); - - it("creates headless editors around caller-owned documents without default undo behavior", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - const editor = createHeadlessEditor({ crdt: adapter, document }); - const blockId = editor.firstBlock()!.id; - - editor.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Server edit", - }, - ]); - - expect(editor.getBlock(blockId)?.textContent()).toBe("Server edit"); - expect(editor.undoManager.undo()).toBe(false); - - editor.destroy(); - }); - - it("does not destroy caller-owned documents on editor teardown", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - const editorA = createEditor({ - document, - }); - const blockId = editorA.firstBlock()!.id; - - editorA.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Persisted", - }, - ]); - editorA.destroy(); - - const editorB = createEditor({ - document, - }); - - expect(editorB.getBlock(blockId)?.textContent()).toBe("Persisted"); - - editorB.destroy(); - }); - - it("persists document profile metadata for new editors", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - - expect(editor.documentProfile).toBe("flow"); - expect(editor.documentState.documentProfile).toBe("flow"); - expect(editor.editorViewMode).toBe("flow"); - expect( - editor.internals.adapter.getDocumentProfile?.( - editor.internals.crdtDoc, - ), - ).toBe("flow"); - - editor.destroy(); - }); - - it("loads persisted document profile independently from local editor view mode", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - adapter.setDocumentProfile?.(document, "flow"); - - const editor = createEditor({ - document, - editorViewMode: "structured", - }); - - expect(editor.documentProfile).toBe("flow"); - expect(editor.documentState.documentProfile).toBe("flow"); - expect(editor.editorViewMode).toBe("structured"); - - editor.destroy(); - }); - - it("keeps document profile in sync with persisted metadata changes", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - const editor = createEditor({ - document, - }); - - expect(editor.documentProfile).toBe("structured"); - expect(editor.documentState.documentProfile).toBe("structured"); - - adapter.setDocumentProfile?.(document, "flow"); - - expect(editor.documentProfile).toBe("flow"); - expect(editor.documentState.documentProfile).toBe("flow"); - expect(editor.editorViewMode).toBe("flow"); - - editor.destroy(); - }); - - it("drops flow-disallowed block insertions at the mutation boundary", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "db1", - blockType: "database", - props: {}, - position: "last", - }, - ]); - - expect(editor.getBlock("db1")).toBeNull(); - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_PROFILE_001", - level: "warn", - source: "profile-policy", - blockType: "database", - documentProfile: "flow", - }), - ); - - editor.destroy(); - }); - - it("re-applies the flow mutation boundary after extension hooks run", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.onBeforeApply( - (ops) => [ - ...ops, - { - type: "insert-block", - blockId: "db-after-hook", - blockType: "database", - props: {}, - position: "last", - }, - ], - { priority: 20000 }, - ); - - editor.apply([ - { - type: "insert-block", - blockId: "p-after-hook", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - expect(editor.getBlock("p-after-hook")?.type).toBe("paragraph"); - expect(editor.getBlock("db-after-hook")).toBeNull(); - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_PROFILE_001", - blockType: "database", - documentProfile: "flow", - }), - ); - - editor.destroy(); - }); - - it("drops flow-disallowed block conversions at the mutation boundary", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - const firstBlockId = editor.firstBlock()!.id; - - editor.apply([ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "Hello", - }, - ]); - - editor.apply([ - { - type: "convert-block", - blockId: firstBlockId, - newType: "database", - newProps: {}, - }, - ]); - - expect(editor.getBlock(firstBlockId)?.type).toBe("paragraph"); - expect(editor.getBlock(firstBlockId)?.textContent()).toBe("Hello"); - - editor.destroy(); - }); - - it("still allows optional structural blocks in flow documents", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - - editor.apply([ - { - type: "insert-block", - blockId: "table1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - expect(editor.getBlock("table1")?.type).toBe("table"); - - editor.destroy(); - }); - - it("discovers subdocument scopes and lets nested editors edit them", () => { - const session = createDocumentSession({ - adapter: yjsAdapter(), - }); - const rootEditor = createEditor({ - documentSession: session, - }); - - rootEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested" }, - position: "last", - }, - ]); - - const childScope = session.getScopeForBlock("subdoc-block", { - scopeId: rootEditor.documentScope.id, - }); - expect(childScope).not.toBeNull(); - expect(rootEditor.getBlock("subdoc-block")?.props.subdocumentGuid).toBe( - childScope?.guid, - ); - - const childEditor = createEditor({ - documentSession: session, - documentScopeId: childScope!.id, - }); - const childBlockId = childEditor.firstBlock()!.id; - - childEditor.apply([ - { - type: "insert-text", - blockId: childBlockId, - offset: 0, - text: "Nested content", - }, - ]); - - expect(childEditor.getBlock(childBlockId)?.textContent()).toBe( - "Nested content", - ); - expect(childEditor.documentScope.parentId).toBe( - rootEditor.documentScope.id, - ); - expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); - - childEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested Nested" }, - position: "last", - }, - ]); - - const nestedScope = session.getScopeForBlock("subdoc-block", { - scopeId: childEditor.documentScope.id, - }); - expect(nestedScope).not.toBeNull(); - expect(nestedScope?.id).not.toBe(childScope?.id); - expect(session.getScopeForBlock("subdoc-block")).toBeNull(); - - childEditor.destroy(); - rootEditor.destroy(); - session.destroy(); - }); - - it("supports delegated document session implementations for scope replacement", async () => { - const baseSession = createDocumentSession({ - adapter: yjsAdapter(), - }); - const delegatedSession: DocumentSession = { - adapter: baseSession.adapter, - get rootScope() { - return baseSession.rootScope; - }, - getScope: (scopeId) => baseSession.getScope(scopeId), - getScopeByGuid: (guid) => baseSession.getScopeByGuid(guid), - getScopeForBlock: (blockId, options) => - baseSession.getScopeForBlock(blockId, options), - listScopes: () => baseSession.listScopes(), - getAwareness: (scopeId) => baseSession.getAwareness(scopeId), - observe: (scopeId, callback) => - baseSession.observe(scopeId, callback), - observeAll: (callback) => baseSession.observeAll(callback), - createSubdocument: (blockId, options) => - baseSession.createSubdocument(blockId, options), - loadSubdocument: (scopeId) => baseSession.loadSubdocument(scopeId), - replaceScopeDocument: (scopeId, doc, options) => - baseSession.replaceScopeDocument(scopeId, doc, options), - attachEditor: (options) => baseSession.attachEditor(options), - destroy: () => baseSession.destroy(), - }; - const editor = createEditor({ - documentSession: delegatedSession, - }); - const originalDoc = editor.internals.crdtDoc; - const replacementSource = createEditor(); - const replacementDoc = delegatedSession.adapter.loadDocument( - delegatedSession.adapter.encodeState( - replacementSource.internals.crdtDoc, - ), - ); - - delegatedSession.replaceScopeDocument( - editor.documentScope.id, - replacementDoc, - ); - await flushMicrotasks(); - - expect(editor.internals.crdtDoc).toBe(replacementDoc); - expect(editor.internals.crdtDoc).not.toBe(originalDoc); - expect(editor.firstBlock()).not.toBeNull(); - - replacementSource.destroy(); - editor.destroy(); - delegatedSession.destroy(); - }); - - it("rebinds child-scope editors when the root session document is replaced", async () => { - const session = createDocumentSession({ - adapter: yjsAdapter(), - }); - const rootEditor = createEditor({ - documentSession: session, - }); - rootEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested" }, - position: "last", - }, - ]); - const childScope = session.getScopeForBlock("subdoc-block", { - scopeId: rootEditor.documentScope.id, - }); - const childEditor = createEditor({ - documentSession: session, - documentScopeId: childScope!.id, - }); - const childBlockId = childEditor.firstBlock()!.id; - childEditor.apply([ - { - type: "insert-text", - blockId: childBlockId, - offset: 0, - text: "Original nested content", - }, - ]); - - const replacementSession = createDocumentSession({ - adapter: yjsAdapter(), - ownsDocuments: false, - }); - const replacementRootEditor = createEditor({ - documentSession: replacementSession, - }); - replacementRootEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested" }, - position: "last", - }, - ]); - const replacementChildScope = replacementSession.getScopeForBlock( - "subdoc-block", - { - scopeId: replacementRootEditor.documentScope.id, - }, - ); - const replacementChildEditor = createEditor({ - documentSession: replacementSession, - documentScopeId: replacementChildScope!.id, - }); - const replacementChildBlockId = replacementChildEditor.firstBlock()!.id; - replacementChildEditor.apply([ - { - type: "insert-text", - blockId: replacementChildBlockId, - offset: 0, - text: "Replacement nested content", - }, - ]); - - session.replaceScopeDocument( - rootEditor.documentScope.id, - replacementSession.rootScope.doc, - ); - await flushMicrotasks(); - - expect(childEditor.firstBlock()?.textContent()).toBe( - "Replacement nested content", - ); - expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); - expect(childEditor.documentScope.parentId).toBe( - rootEditor.documentScope.id, - ); - - replacementChildEditor.destroy(); - replacementRootEditor.destroy(); - replacementSession.destroy(); - childEditor.destroy(); - rootEditor.destroy(); - session.destroy(); - }); - - it("creates a working editor with default schema and extensions", () => { - const editor = createDefaultEditor(); - - expect(editor.schema.resolve("paragraph")).toBeTruthy(); - expect(typeof editor.clientId).toBe("number"); - expect(editor.internals.getSlot("core:engine")).toBe( - editor.internals.engine, - ); - expect( - editor.internals.getSlot("document-ops:toolRuntime"), - ).toBeTruthy(); - expect(editor.internals.getSlot("undo:manager")).toBeTruthy(); - - editor.destroy(); - }); - - it("starts with a single empty paragraph block in zero-config mode", () => { - const editor = createDefaultEditor(); - - expect(editor.blockCount()).toBe(1); - expect(editor.firstBlock()?.type).toBe("paragraph"); - expect(editor.firstBlock()?.textContent()).toBe(""); - - editor.destroy(); - }); - - it("applies insert-block and insert-text operations", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello"); - - editor.destroy(); - }); - - it("moves the text selection after accepting an inline completion", () => { - const editor = createEditor(); - const blockId = editor.firstBlock()!.id; - const { controller } = ensureInlineCompletionController(editor); - - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello" }, - ]); - editor.selectText(blockId, 5, 5); - controller.showSuggestion({ - id: "suggestion-1", - blockId, - offset: 5, - text: " world", - type: "inline", - }); - - expect(controller.acceptSuggestion()).toBe(true); - - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 11 }, - focus: { blockId, offset: 11 }, - }); - - editor.destroy(); - }); - - it("splits and merges inline blocks", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello world", - }, - ]); - - editor.apply([ - { - type: "split-block", - blockId: "b1", - offset: 5, - newBlockId: "b2", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello"); - expect(editor.getBlock("b2")?.textContent()).toBe(" world"); - - editor.apply([ - { - type: "merge-blocks", - targetBlockId: "b1", - sourceBlockId: "b2", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello world"); - expect(editor.getBlock("b2")).toBeNull(); - - editor.destroy(); - }); - - it("splits at offset zero by inserting an empty block above", () => { - const editor = createEditor(); - const blockId = editor.firstBlock()!.id; - - editor.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "hello world", - }, - ]); - - editor.apply([ - { - type: "split-block", - blockId, - offset: 0, - newBlockId: "b2", - }, - ]); - - expect(editor.documentState.blockOrder).toEqual([blockId, "b2"]); - expect(editor.getBlock(blockId)?.textContent()).toBe(""); - expect(editor.getBlock("b2")?.textContent()).toBe("hello world"); - - editor.destroy(); - }); - - it("preserves full text offsets for code blocks", () => { - const editor = createEditor(); - const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "convert-block", blockId, newType: "codeBlock" }, - { type: "insert-text", blockId, offset: 0, text: "abcd" }, - ]); - - editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 3 }); - - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 1 }, - focus: { blockId, offset: 3 }, - }); - expect(editor.getSelectedText()).toBe("bc"); - - editor.destroy(); - }); - - it("clears stale grid state when converting table or database blocks", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "table-block", - blockType: "table", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "database-block", - blockType: "database", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "database-insert-row", - blockId: "database-block", - rowId: "row-1", - values: { - name: "Alpha", - tags: "todo", - status: "true", - }, - }, - { - type: "convert-block", - blockId: "table-block", - newType: "paragraph", - }, - { - type: "convert-block", - blockId: "database-block", - newType: "paragraph", - }, - ]); - - const tableBlock = editor.getBlock("table-block")!; - const databaseBlock = editor.getBlock("database-block")!; - expect(tableBlock.type).toBe("paragraph"); - expect(tableBlock.tableRowCount()).toBe(0); - expect(tableBlock.tableColumns()).toEqual([]); - expect(tableBlock.databaseViews()).toEqual([]); - - expect(databaseBlock.type).toBe("paragraph"); - expect(databaseBlock.tableRowCount()).toBe(0); - expect(databaseBlock.tableColumns()).toEqual([]); - expect(databaseBlock.databaseViews()).toEqual([]); - expect(databaseBlock.databasePrimaryViewId()).toBeNull(); - - const tableBlockMap = editor.internals.doc.blocks.get( - "table-block", - ) as TestBlockMapLike; - const databaseBlockMap = editor.internals.doc.blocks.get( - "database-block", - ) as TestBlockMapLike; - expect(tableBlockMap.get("tableContent")).toBeUndefined(); - expect(tableBlockMap.get("tableColumns")).toBeUndefined(); - expect(tableBlockMap.get("databaseViews")).toBeUndefined(); - expect(tableBlockMap.get("databasePrimaryViewId")).toBeUndefined(); - expect(databaseBlockMap.get("tableContent")).toBeUndefined(); - expect(databaseBlockMap.get("tableColumns")).toBeUndefined(); - expect(databaseBlockMap.get("databaseViews")).toBeUndefined(); - expect(databaseBlockMap.get("databasePrimaryViewId")).toBeUndefined(); - - editor.destroy(); - }); - - it("queues reentrant apply calls from observe hooks", () => { - let appended = false; - const ext = defineExtension({ - name: "append-exclamation", - observe(events, editor) { - if (appended) return; - const hasInsertText = events.some((event) => - event.ops.some((op) => op.type === "insert-text"), - ); - if (!hasInsertText) return; - - appended = true; - editor.apply( - [ - { - type: "insert-text", - blockId: "b1", - offset: 5, - text: "!", - }, - ], - { origin: "extension" }, - ); - }, - }); - - const editor = createEditor({ - extensions: [ext], - }); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello!"); - - editor.destroy(); - }); - - it("activates input-rules extensions and applies block conversions", async () => { - const editor = createEditor({ - extensions: [inputRulesExtension()], - }); - const blockId = editor.firstBlock()!.id; - - editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 0 }); - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 0, - text: "#", - }, - ], - { origin: "user" }, - ); - editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 1 }); - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 1, - text: " ", - }, - ], - { origin: "user" }, - ); - await flushMicrotasks(); - - expect(editor.getBlock(blockId)?.type).toBe("heading"); - expect(editor.getBlock(blockId)?.props.level).toBe(1); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); - - editor.destroy(); - }); - - it("activates input-rules extensions and applies inline markdown conversions", async () => { - const editor = createEditor({ - extensions: [inputRulesExtension()], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 0, - text: "**hello*", - }, - ], - { origin: "user" }, - ); - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 8, - text: "*", - }, - ], - { origin: "user" }, - ); - await flushMicrotasks(); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "hello", - ); - expect(editor.getBlock(blockId)?.textDeltas()).toEqual([ - { - insert: "hello", - attributes: { bold: true }, - }, - ]); - - editor.destroy(); - }); - - it("emits unified change and documentCommit once for a local apply batch", () => { - const observed: unknown[][] = []; - const ext = defineExtension({ - name: "capture-local-dispatch", - observe(events) { - observed.push(events); - }, - }); - const editor = createEditor({ - extensions: [ext], - }); - const changes: unknown[][] = []; - const documentCommits: unknown[] = []; - const blockId = editor.firstBlock()!.id; - - editor.on("change", (events) => { - changes.push(events); - }); - editor.on("documentCommit", (event) => { - documentCommits.push(event); - }); - observed.length = 0; - changes.length = 0; - documentCommits.length = 0; - - editor.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "hello", - }, - ]); - - expect(changes).toHaveLength(1); - expect(changes[0]).toHaveLength(1); - expect(changes[0][0]).toMatchObject({ - origin: "user", - affectedBlocks: [blockId], - }); - expect(documentCommits).toHaveLength(1); - expect(documentCommits[0]).toMatchObject({ - commitId: 2, - origin: "user", - affectedBlocks: [blockId], - }); - expect( - (documentCommits[0] as { blockRevisions: Record }) - .blockRevisions[blockId], - ).toBe(editor.getBlockRevision(blockId)); - expect(observed).toHaveLength(1); - expect(observed[0]).toHaveLength(1); - - editor.destroy(); - }); - - it("emits unified change and documentCommit once for observed CRDT updates", () => { - const observed: unknown[][] = []; - const ext = defineExtension({ - name: "capture-observed-dispatch", - observe(events) { - observed.push(events); - }, - }); - const editor = createEditor({ - extensions: [ext], - }); - const changes: unknown[][] = []; - const documentCommits: unknown[] = []; - const adapter = editor.internals.adapter; - const editorDoc = editor.internals.crdtDoc; - const blockId = editor.firstBlock()!.id; - const remoteDoc = adapter.loadDocument(adapter.encodeState(editorDoc)); - const remoteYDoc = adapter.raw(remoteDoc); - const remoteYText = remoteYDoc - .getMap("blocks") - .get(blockId) - ?.get("content") as TestYTextLike | undefined; - if (!remoteYText) { - throw new Error(`Missing collaborator text for block ${blockId}`); - } - - editor.on("change", (events) => { - changes.push(events); - }); - editor.on("documentCommit", (event) => { - documentCommits.push(event); - }); - observed.length = 0; - changes.length = 0; - documentCommits.length = 0; - - adapter.transact( - remoteDoc, - () => { - remoteYText.insert(0, "remote "); - }, - "collaborator", - ); - adapter.applyUpdate(editorDoc, adapter.encodeState(remoteDoc)); - - expect(changes).toHaveLength(1); - expect(changes[0]).toHaveLength(1); - expect(changes[0][0]).toMatchObject({ - affectedBlocks: [blockId], - }); - expect(documentCommits).toHaveLength(1); - expect(documentCommits[0]).toMatchObject({ - commitId: 2, - affectedBlocks: [blockId], - }); - expect( - (documentCommits[0] as { blockRevisions: Record }) - .blockRevisions[blockId], - ).toBe(editor.getBlockRevision(blockId)); - expect(observed).toHaveLength(1); - expect(observed[0]).toHaveLength(1); - - editor.destroy(); - }); - - it("clamps text selections and returns backwards selected text", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello", - }, - ]); - - editor.selectText("b1", 10, 99); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 5 }, - focus: { blockId: "b1", offset: 5 }, - }); - - editor.setSelection({ - type: "text", - anchor: { blockId: "b1", offset: 5 }, - focus: { blockId: "b1", offset: 2 }, - isCollapsed: false, - isMultiBlock: false, - blockRange: ["b1"], - toRange: () => { - throw new Error("test helper"); - }, - }); - - expect(editor.getSelectedText()).toBe("llo"); - - editor.destroy(); - }); - - it("selects text ranges across blocks in document order", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 3 }, - ); - - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 2 }, - focus: { blockId: "b3", offset: 3 }, - isMultiBlock: true, - blockRange: ["b1", "b2", "b3"], - }); - expect(editor.getSelectedText()).toBe("llo\nWorld\nAga"); - expect(editor.getSelectedBlocks().map((block) => block.id)).toEqual([ - "b1", - "b2", - "b3", - ]); - - editor.destroy(); - }); - - it("deletes multi-block text selections and collapses at the start", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("b1")?.textContent()).toBe("Heain"); - expect(editor.getBlock("b2")).toBeNull(); - expect(editor.getBlock("b3")).toBeNull(); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 2 }, - focus: { blockId: "b1", offset: 2 }, - isMultiBlock: false, - blockRange: ["b1"], - }); - - editor.destroy(); - }); - - it("deletes a fully selected structural block", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "d1", - blockType: "divider", - props: {}, - position: "last", - }, - ]); - - editor.selectTextRange( - { blockId: "d1", offset: 0 }, - { blockId: "d1", offset: 1 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("d1")).toBeNull(); - expect(editor.getSelection()).toBeNull(); - - editor.destroy(); - }); - - it("deletes a fully selected delegated block", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.selectTextRange( - { blockId: "t1", offset: 0 }, - { blockId: "t1", offset: 1 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("t1")).toBeNull(); - expect(editor.getSelection()).toBeNull(); - - editor.destroy(); - }); - - it("deletes structural blocks at multi-block selection boundaries", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "p1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "d1", - blockType: "divider", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "p1", offset: 0, text: "Hello" }, - ]); - - editor.selectTextRange( - { blockId: "p1", offset: 2 }, - { blockId: "d1", offset: 1 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("p1")?.textContent()).toBe("He"); - expect(editor.getBlock("d1")).toBeNull(); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "p1", offset: 2 }, - focus: { blockId: "p1", offset: 2 }, - isMultiBlock: false, - blockRange: ["p1"], - }); - - editor.destroy(); - }); - - it("replaces multi-block text selections at a single insertion point", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - editor.replaceSelection("X"); - - expect(editor.getBlock("b1")?.textContent()).toBe("HeXain"); - expect(editor.getBlock("b2")).toBeNull(); - expect(editor.getBlock("b3")).toBeNull(); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 3 }, - focus: { blockId: "b1", offset: 3 }, - isMultiBlock: false, - blockRange: ["b1"], - }); - - editor.destroy(); - }); - - it("preserves formatted suffix text when deleting across blocks", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "Again" }, - { - type: "format-text", - blockId: "b2", - offset: 2, - length: 3, - marks: { bold: true }, - }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b2", offset: 2 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("b1")?.textDeltas()).toEqual([ - { insert: "He" }, - { - insert: "ain", - attributes: { bold: true }, - }, - ]); - expect(editor.getBlock("b2")).toBeNull(); - - editor.destroy(); - }); - - it("replaces multi-block text selections in a single document commit batch", () => { - const editor = createEditor(); - const events: Array<{ ops: readonly { type: string }[] }> = []; - - editor.on("documentCommit", (event) => { - events.push(event as { ops: readonly { type: string }[] }); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - events.length = 0; - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - editor.replaceSelection("X"); - - expect(events).toHaveLength(1); - expect(events[0]?.ops.map((op) => op.type)).toEqual([ - "delete-text", - "delete-text", - "delete-block", - "insert-text", - "insert-text", - "delete-block", - ]); - - editor.destroy(); - }); - - it("rebinds undo manager after loadDocument", async () => { - const editor = createDefaultEditor(); - const newDoc = editor.internals.adapter.createDocument(); - - editor.loadDocument(newDoc); - await flushMicrotasks(); - - expect(editor.undoManager).toBe( - editor.internals.getSlot("undo:manager"), - ); - - editor.destroy(); - }); - - it("waits for async extension teardown before reactivating after loadDocument", async () => { - const steps: string[] = []; - let activationCount = 0; - let resolveDeactivate!: () => void; - const deactivatePromise = new Promise((resolve) => { - resolveDeactivate = resolve; - }); - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "async-lifecycle", - activateClient: async () => { - activationCount += 1; - steps.push(`activate:${activationCount}`); - }, - deactivateClient: async () => { - steps.push("deactivate:start"); - await deactivatePromise; - steps.push("deactivate:end"); - }, - }), - ], - }); - - await flushMicrotasks(); - - editor.loadDocument(editor.internals.adapter.createDocument()); - await flushMicrotasks(); - - expect(steps).toEqual(["activate:1", "deactivate:start"]); - - resolveDeactivate(); - await flushMicrotasks(4); - - expect(steps).toEqual([ - "activate:1", - "deactivate:start", - "deactivate:end", - "activate:2", - ]); - - editor.destroy(); - }); - - it("refreshes editor.undoManager immediately when the undo slot is set", async () => { - const registeredUndoManager = { - undo: () => false, - redo: () => false, - canUndo: () => false, - canRedo: () => false, - stopCapturing: () => {}, - syncExplicitUndoGroup: () => {}, - setGroupTimeout: () => {}, - registerTrackedOrigins: () => () => {}, - onStackChange: () => () => {}, - }; - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "test-undo-slot", - activateClient: async ({ editor }) => { - expect(editor.undoManager).not.toBe( - registeredUndoManager, - ); - editor.internals.setSlot( - "undo:manager", - registeredUndoManager, - ); - expect(editor.undoManager).toBe(registeredUndoManager); - }, - }), - ], - }); - - await Promise.resolve(); - - expect(editor.undoManager).toBe(registeredUndoManager); - - editor.destroy(); - }); - - it("updates documentState parent relationships after parentId changes", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "parent", - blockType: "toggle", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "child", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "update-block", - blockId: "child", - props: { parentId: "parent" }, - }, - ]); - - expect(editor.documentState.parentOf("child")).toBe("parent"); - - editor.apply([ - { - type: "update-block", - blockId: "child", - props: { parentId: null }, - }, - ]); - - expect(editor.documentState.parentOf("child")).toBeNull(); - - editor.destroy(); - }); - - it("emits structured diagnostics for unknown block types", () => { - const editor = createEditor(); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "unknown", - blockType: "not-real", - props: {}, - position: "last", - }, - ]); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - }), - ); - - editor.destroy(); - }); - - it("emits remediation text for extension observe failures", () => { - const diagnostics: unknown[] = []; - const ext = defineExtension({ - name: "broken-observe", - observe() { - throw new Error("boom"); - }, - }); - const editor = createEditor({ - extensions: [ext], - }); - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_EXT_001", - level: "error", - source: "extension", - remediation: expect.any(String), - }), - ); - - editor.destroy(); - }); - - it("emits diagnostics for rejected async extension activation", async () => { - const diagnostics: unknown[] = []; - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "broken-async-activate", - activateClient: async () => { - await Promise.resolve(); - throw new Error("boom"); - }, - }), - ], - }); - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - await flushMicrotasks(4); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_EXT_004", - level: "error", - source: "extension", - extension: "broken-async-activate", - remediation: expect.any(String), - }), - ); - - editor.destroy(); - }); - - it("processes streamed AI deltas through the default delta-stream pipeline", async () => { - const editor = createDefaultEditor(); - const blockId = editor.firstBlock()!.id; - - await processStream( - createStream([ - { type: "gen-start", zoneId: "zone-1", blockId }, - { type: "gen-delta", zoneId: "zone-1", delta: "Hello " }, - { type: "gen-delta", zoneId: "zone-1", delta: "world" }, - { type: "gen-end", zoneId: "zone-1", status: "complete" }, - ]), - editor, - ); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "Hello world", - ); - expect( - editor.internals.getSlot<{ generationZone: unknown }>( - "delta-stream:target", - )?.generationZone ?? null, - ).toBeNull(); - - editor.destroy(); - }); - - it("keeps streamed AI generations in their own undo group", async () => { - const editor = createDefaultEditor(); - const firstBlockId = editor.firstBlock()!.id; - const secondBlockId = crypto.randomUUID(); - - editor.apply( - [ - { - type: "insert-block", - blockId: secondBlockId, - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - - editor.apply( - [ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "hello", - }, - ], - { origin: "user" }, - ); - - await processStream( - createStream([ - { type: "gen-start", zoneId: "zone-2", blockId: secondBlockId }, - { type: "gen-delta", zoneId: "zone-2", delta: "AI output" }, - { type: "gen-end", zoneId: "zone-2", status: "complete" }, - ]), - editor, - ); - - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "hello", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "hello", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - expect(editor.undoManager.redo()).toBe(true); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - editor.destroy(); - }); - - it("keeps concurrent user edits outside the generation zone in a separate undo group", async () => { - const editor = createDefaultEditor(); - const firstBlockId = editor.firstBlock()!.id; - const secondBlockId = crypto.randomUUID(); - - editor.apply( - [ - { - type: "insert-block", - blockId: secondBlockId, - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - - await processStream( - (async function* (): AsyncIterable { - yield { - type: "gen-start", - zoneId: "zone-concurrent", - blockId: secondBlockId, - }; - - editor.apply( - [ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "user edit", - }, - ], - { origin: "user" }, - ); - - yield { - type: "gen-delta", - zoneId: "zone-concurrent", - delta: "AI output", - }; - yield { - type: "gen-end", - zoneId: "zone-concurrent", - status: "complete", - }; - })(), - editor, - ); - - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "user edit", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "user edit", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - expect(editor.undoManager.redo()).toBe(true); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - editor.destroy(); - }); - - it("keeps user edits inside the generation zone in the same undo group", async () => { - const editor = createDefaultEditor(); - const blockId = editor.firstBlock()!.id; - - await processStream( - (async function* (): AsyncIterable { - yield { type: "gen-start", zoneId: "zone-shared", blockId }; - yield { - type: "gen-delta", - zoneId: "zone-shared", - delta: "AI ", - }; - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 3, - text: "user ", - }, - ], - { origin: "user" }, - ); - - yield { - type: "gen-delta", - zoneId: "zone-shared", - delta: "output", - }; - yield { - type: "gen-end", - zoneId: "zone-shared", - status: "complete", - }; - })(), - editor, - ); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "user AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); - - expect(editor.undoManager.redo()).toBe(true); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "user AI output", - ); - - editor.destroy(); - }); - - it("tracks imported edits in the undo stack", () => { - const editor = createEditorWithUndo(); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 0, - text: "Imported text", - }, - ], - { origin: "import", undoGroup: true }, - ); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "Imported text", - ); - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); - - editor.destroy(); - }); - - it("emits history origin for undo transactions on documentCommit", () => { - const editor = createEditorWithUndo(); - const blockId = editor.firstBlock()!.id; - const commitOrigins: string[] = []; - - editor.on("documentCommit", (event) => { - commitOrigins.push(getOpOriginType(event.origin)); - }); - - editor.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Hello", - }, - ]); - - editor.undoManager.undo(); - - expect(commitOrigins).toContain("user"); - expect(commitOrigins).toContain("history"); - - editor.destroy(); - }); -}); - -describe("@pen/core table operations", () => { - it("insert-block with table type produces seeded 2x2 grid", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.type).toBe("table"); - expect(block.tableRowCount()).toBe(2); - expect(block.tableColumnCount()).toBe(2); - - const cell = block.tableCell(0, 0)!; - expect(cell).not.toBeNull(); - expect(cell.id).toEqual(expect.any(String)); - expect(cell.textContent()).toBe(""); - - editor.destroy(); - }); - - it("insert-table-row adds a row matching existing column count", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-row", - blockId: "t1", - index: 2, - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.tableRowCount()).toBe(3); - expect(block.tableColumnCount()).toBe(2); - expect(block.tableCell(2, 0)).not.toBeNull(); - expect(block.tableCell(2, 1)).not.toBeNull(); - - editor.destroy(); - }); - - it("repairs table width from the widest row when legacy rows are short", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-column", - blockId: "t1", - index: 2, - }, - ]); - - const blockMap = editor.internals.doc.blocks.get( - "t1", - ) as TestBlockMapLike; - const tableContent = blockMap.get( - "tableContent", - ) as TestTableContentLike; - const firstRow = tableContent.get(0); - firstRow.get("cells").delete(2, 1); - - let block = editor.getBlock("t1")!; - expect(block.tableColumnCount()).toBe(3); - - editor.apply([ - { - type: "insert-table-row", - blockId: "t1", - index: block.tableRowCount(), - }, - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 2, - offset: 0, - text: "Recovered", - }, - ]); - - block = editor.getBlock("t1")!; - expect(block.tableRowCount()).toBe(3); - expect(block.tableCell(0, 2)?.textContent()).toBe("Recovered"); - expect(block.tableCell(2, 0)).not.toBeNull(); - expect(block.tableCell(2, 1)).not.toBeNull(); - expect(block.tableCell(2, 2)).not.toBeNull(); - - editor.destroy(); - }); - - it("insert-table-column adds a column to all rows", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-column", - blockId: "t1", - index: 2, - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.tableRowCount()).toBe(2); - expect(block.tableColumnCount()).toBe(3); - expect(block.tableCell(0, 2)).not.toBeNull(); - expect(block.tableCell(1, 2)).not.toBeNull(); - - editor.destroy(); - }); - - it("delete-table-row removes a row", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "delete-table-row", - blockId: "t1", - index: 0, - }, - ]); - - expect(editor.getBlock("t1")!.tableRowCount()).toBe(1); - - editor.destroy(); - }); - - it("delete-table-column removes a column from all rows", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "delete-table-column", - blockId: "t1", - index: 0, - }, - ]); - - expect(editor.getBlock("t1")!.tableColumnCount()).toBe(1); - - editor.destroy(); - }); - - it("insert-table-cell-text writes text into a specific cell", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 1, - offset: 0, - text: "Hello", - }, - ]); - - const cell = editor.getBlock("t1")!.tableCell(0, 1)!; - expect(cell.textContent()).toBe("Hello"); - - editor.destroy(); - }); - - it("delete-table-cell-text removes text from a specific cell", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 0, - text: "Hello", - }, - { - type: "delete-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 1, - length: 3, - }, - ]); - - const cell = editor.getBlock("t1")!.tableCell(0, 0)!; - expect(cell.textContent()).toBe("Ho"); - - editor.destroy(); - }); - - it("format-table-cell-text applies formatting to cell text", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 0, - text: "bold text", - }, - { - type: "format-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 0, - length: 4, - marks: { bold: true }, - }, - ]); - - const cell = editor.getBlock("t1")!.tableCell(0, 0)!; - const deltas = cell.textDeltas(); - expect(deltas[0].insert).toBe("bold"); - expect(deltas[0].attributes).toEqual({ bold: true }); - expect(deltas[1].insert).toBe(" text"); - - editor.destroy(); - }); - - it("convert-block to table seeds tableContent", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "convert-block", - blockId: "b1", - newType: "table", - newProps: {}, - }, - ]); - - const block = editor.getBlock("b1")!; - expect(block.type).toBe("table"); - expect(block.tableRowCount()).toBe(2); - expect(block.tableColumnCount()).toBe(2); - - editor.destroy(); - }); - - it("convert-block to table preserves inline text in the first cell", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "Hello table", - }, - ]); - - editor.apply([ - { - type: "convert-block", - blockId: "b1", - newType: "table", - newProps: {}, - }, - ]); - - const block = editor.getBlock("b1")!; - expect(block.type).toBe("table"); - expect(block.tableCell(0, 0)?.textContent()).toBe("Hello table"); - expect(block.tableCell(0, 1)?.textContent()).toBe(""); - expect(block.tableCell(1, 0)?.textContent()).toBe(""); - expect(block.tableCell(1, 1)?.textContent()).toBe(""); - - editor.destroy(); - }); - - it("tableCell returns null for out-of-bounds coordinates", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.tableCell(-1, 0)).toBeNull(); - expect(block.tableCell(0, -1)).toBeNull(); - expect(block.tableCell(99, 0)).toBeNull(); - expect(block.tableCell(0, 99)).toBeNull(); - - editor.destroy(); - }); - - it("tableRowCount/tableColumnCount return 0 for non-table blocks", () => { - const editor = createEditor(); - - const block = editor.firstBlock()!; - expect(block.tableRowCount()).toBe(0); - expect(block.tableColumnCount()).toBe(0); - expect(block.tableCell(0, 0)).toBeNull(); - - editor.destroy(); - }); - - it("caches decoration snapshots between decoration updates", () => { - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "test-decorations", - decorations(_state, currentEditor) { - const blockId = currentEditor.firstBlock()?.id; - if (!blockId) { - return createDecorationSet([]); - } - - return createDecorationSet([ - { - type: "block", - blockId, - attributes: { active: true }, - }, - ]); - }, - }), - ], - }); - - const initialDecorations = editor.getDecorations(); - const repeatedDecorations = editor.getDecorations(); - expect(repeatedDecorations).toBe(initialDecorations); - - editor.apply( - [ - { - type: "insert-text", - blockId: editor.firstBlock()!.id, - offset: 0, - text: "trigger", - }, - ], - { origin: "user" }, - ); - - const autoRefreshedDecorations = editor.getDecorations(); - expect(autoRefreshedDecorations).not.toBe(initialDecorations); - expect(editor.getDecorations()).toBe(autoRefreshedDecorations); - - editor.requestDecorationUpdate(); - - const refreshedDecorations = editor.getDecorations(); - expect(refreshedDecorations).not.toBe(autoRefreshedDecorations); - expect(editor.getDecorations()).toBe(refreshedDecorations); - - editor.destroy(); - }); -}); diff --git a/packages/core/src/editor/apply.ts b/packages/core/src/editor/apply.ts index 2779809..000e538 100644 --- a/packages/core/src/editor/apply.ts +++ b/packages/core/src/editor/apply.ts @@ -1,52 +1,17 @@ -import type { - DocumentOp, - OpOrigin, - PenDocument, - CRDTDocument, - CRDTAdapter, - CRDTEvent, - SchemaRegistry, - CRDTMap, - CRDTArray, - InsertBlockOp, - UpdateBlockOp, - DeleteBlockOp, - MoveBlockOp, - ConvertBlockOp, - SplitBlockOp, - MergeBlocksOp, - InsertTextOp, - DeleteTextOp, - FormatTextOp, - ReplaceTextOp, - InsertInlineNodeOp, - RemoveInlineNodeOp, - UpdateLayoutOp, - SetMetaOp, - CreateAppOp, - UpdateAppOp, - DeleteAppOp, - SetSelectionOp, - UpdateTableColumnsOp, -} from "@pen/types"; +import type { DocumentOp, OpOrigin, PenDocument, CRDTDocument, CRDTAdapter, CRDTEvent, SchemaRegistry, CRDTMap, CRDTArray, InsertBlockOp, UpdateBlockOp, DeleteBlockOp, MoveBlockOp, ConvertBlockOp, SplitBlockOp, MergeBlocksOp, InsertTextOp, DeleteTextOp, FormatTextOp, ReplaceTextOp, InsertInlineNodeOp, RemoveInlineNodeOp, UpdateLayoutOp, SetMetaOp, CreateAppOp, UpdateAppOp, DeleteAppOp, SetSelectionOp, UpdateTableColumnsOp } from "@pen/types"; import { generateId, getOpOriginType } from "@pen/types"; import { resolveRuntimeContentType } from "../schema/contentType"; import type { SchemaEngineImpl } from "../schema/normalize"; -import { - type CRDTUnknownArray, - type CRDTUnknownMap, - getArrayProp, - getMapProp, - getStringProp, - getTableColumns, - getTableContent, - isCRDTMap, -} from "./crdtShapes"; +import { type CRDTUnknownArray, type CRDTUnknownMap, getArrayProp, getMapProp, getStringProp, getTableColumns, getTableContent, isCRDTMap } from "./crdtShapes"; import { DatabaseOpExecutor } from "./databaseOpExecutor"; import type { EventEmitter } from "./events"; import type { SelectionManagerImpl } from "./selection"; import { TableGridExecutor } from "./tableGridExecutor"; +import { blockExists, createMutableMap, getMutableBlockMap, getMutableAppMap, getOrCreateMapProp, getOrCreateStringArrayProp, removeBlockIdFromArray, removeBlockIdFromAllChildren, getTextContent, getInlineTextContent, opBlockId } from "./applySharedHelpers"; +import { applyInternal, executeOps, emitApplyBoundary, validateOp, resolvePosition, executeSingleOp } from "./applyPipelineRunner"; +import { insertBlock, updateBlock, deleteBlock, moveBlock, convertBlock, migrateTableToDatabase, splitBlock, mergeBlocks } from "./applyBlockOps"; +import { insertText, deleteText, formatText, replaceText, resolveMarks, insertInlineNode, removeInlineNode, setSelectionOp, updateLayout, createApp, updateApp, deleteApp, tableOp, databaseOp, clearTableState, clearDatabaseState, isDatabaseStructuralTableOp, getPreservedInlineDeltas, setMeta } from "./applyInlineAndMetaOps"; // Typed CRDT structure interfaces used by the op executor. type CRDTBlockMap = CRDTMap>; type MutableMap = CRDTUnknownMap & { delete(key: string): void }; @@ -221,149 +186,13 @@ export class ApplyPipeline { } private _applyInternal(ops: DocumentOp[], origin: OpOrigin): void { - if (this._applying) { - this._queue.push({ ops, origin }); - return; - } - - this._applying = true; - try { - this._executeOps(ops, origin); - while (this._queue.length > 0) { - const { ops: queued, origin: queuedOrigin } = - this._queue.shift()!; - this._executeOps(queued, queuedOrigin); - } - } finally { - this._applying = false; - } + applyInternal(this, ops, origin); } // ── Core Pipeline ──────────────────────────────────────── private _executeOps(ops: DocumentOp[], origin: OpOrigin): void { - // Let extensions transform ops before validation and execution. - let transformedOps = ops; - for (const { hook } of this._beforeApplyHooks) { - try { - transformedOps = hook(transformedOps, { origin }); - } catch (err) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_005", - level: "error", - source: "apply", - message: "onBeforeApply hook threw", - remediation: - "Update the onBeforeApply hook to handle incoming ops defensively and " + - "always return a valid DocumentOp array.", - error: err, - }); - } - } - if (this._finalBeforeApplyHook) { - try { - transformedOps = this._finalBeforeApplyHook(transformedOps, { - origin, - }); - } catch (err) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_007", - level: "error", - source: "apply", - message: "final apply boundary hook threw", - remediation: - "Update the final apply boundary hook to handle incoming ops defensively and " + - "always return a valid DocumentOp array.", - error: err, - }); - } - } - - this._emitApplyBoundary({ - phase: "before", - ops: transformedOps, - origin, - applied: false, - }); - - const affectedBlocks: string[] = []; - const validatedOps: DocumentOp[] = []; - const pendingBlockIds = new Set(); - - for (const op of transformedOps) { - const blockId = this._opBlockId(op); - - if (!this._validateOp(op)) continue; - - if (op.type === "insert-block") { - pendingBlockIds.add(op.blockId); - } - - if ( - blockId && - !this._blockExists(blockId) && - !pendingBlockIds.has(blockId) && - op.type !== "insert-block" - ) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_003", - level: "warn", - source: "apply", - message: `apply: skipping ${op.type} for non-existent block "${blockId}"`, - }); - continue; - } - - validatedOps.push(op); - } - - if (validatedOps.length === 0) { - this._emitApplyBoundary({ - phase: "after", - ops: transformedOps, - origin, - applied: false, - }); - return; - } - - this._suppressObserver = true; - - try { - this._adapter.transact( - this._crdtDoc, - () => { - for (const op of validatedOps) { - const affected = this._executeSingleOp(op); - affectedBlocks.push(...affected); - } - - for (const blockId of affectedBlocks) { - this._engine.markDirty(blockId); - } - - this._engine.normalizeDirty(); - }, - getOpOriginType(origin), - ); - } finally { - this._suppressObserver = false; - } - - const event: CRDTEvent = { - origin, - affectedBlocks: [...new Set(affectedBlocks)], - ops: validatedOps, - timestamp: Date.now(), - }; - - this._onDidApply?.(event); - this._emitApplyBoundary({ - phase: "after", - ops: validatedOps, - origin, - applied: true, - }); + executeOps(this, ops, origin); } private _emitApplyBoundary(event: { @@ -372,864 +201,190 @@ export class ApplyPipeline { origin: OpOrigin; applied: boolean; }): void { - for (const hook of this._applyBoundaryHooks) { - try { - hook(event); - } catch (err) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_008", - level: "error", - source: "apply", - message: "apply boundary hook threw", - remediation: - "Update the apply boundary hook to avoid throwing during transaction lifecycle notifications.", - error: err, - }); - } - } + emitApplyBoundary(this, event); } // ── Schema Validation ──────────────────────────────────── private _validateOp(op: DocumentOp): boolean { - switch (op.type) { - case "insert-block": { - const schema = this._registry.resolve(op.blockType); - if (!schema) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - message: `Unknown block type: "${op.blockType}"`, - op, - }); - return false; - } - return true; - } - case "convert-block": { - const schema = this._registry.resolve(op.newType); - if (!schema) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - message: `Unknown block type: "${op.newType}"`, - op, - }); - return false; - } - return true; - } - case "insert-inline-node": { - const schema = this._registry.resolveInline(op.nodeType); - if (!schema || schema.kind !== "node") { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - message: `Unknown inline node type: "${op.nodeType}"`, - op, - }); - return false; - } - return true; - } - default: - return true; - } + return validateOp(this, op); } // ── Position Resolution ────────────────────────────────── _resolvePosition(position: import("@pen/types").Position): number { - const blockOrder = this._doc.blockOrder; - - if (position === "first") return 0; - if (position === "last") return blockOrder.length; - - if (typeof position === "object" && "after" in position) { - for (let i = 0; i < blockOrder.length; i++) { - if ((blockOrder.get(i) as string) === position.after) - return i + 1; - } - return blockOrder.length; - } - - if (typeof position === "object" && "before" in position) { - for (let i = 0; i < blockOrder.length; i++) { - if ((blockOrder.get(i) as string) === position.before) return i; - } - return 0; - } - - if (typeof position === "object" && "parent" in position) { - const parentMap = this.blocks.get(position.parent); - if (!parentMap) return blockOrder.length; - const children = parentMap.get("children") as - | CRDTArray - | undefined; - if (!children) return 0; - return Math.min(position.index, children.length); - } - - return blockOrder.length; + return resolvePosition(this, position); } // ── Op Dispatch ────────────────────────────────────────── private _executeSingleOp(op: DocumentOp): string[] { - switch (op.type) { - case "insert-block": - return this._insertBlock(op); - case "update-block": - return this._updateBlock(op); - case "delete-block": - return this._deleteBlock(op); - case "move-block": - return this._moveBlock(op); - case "convert-block": - return this._convertBlock(op); - case "split-block": - return this._splitBlock(op); - case "merge-blocks": - return this._mergeBlocks(op); - case "insert-text": - return this._insertText(op); - case "delete-text": - return this._deleteText(op); - case "format-text": - return this._formatText(op); - case "replace-text": - return this._replaceText(op); - case "insert-inline-node": - return this._insertInlineNode(op); - case "remove-inline-node": - return this._removeInlineNode(op); - case "set-selection": - return this._setSelection(op); - case "update-layout": - return this._updateLayout(op); - case "create-app": - return this._createApp(op); - case "update-app": - return this._updateApp(op); - case "delete-app": - return this._deleteApp(op); - case "insert-table-row": - case "delete-table-row": - case "insert-table-column": - case "delete-table-column": - case "merge-table-cells": - case "split-table-cell": - case "insert-table-cell-text": - case "delete-table-cell-text": - case "format-table-cell-text": - case "update-table-columns": - return this._tableOp(op); - case "database-add-column": - case "database-update-column": - case "database-convert-column": - case "database-remove-column": - case "database-insert-row": - case "database-update-cell": - case "database-delete-row": - case "database-delete-rows": - case "database-duplicate-row": - case "database-move-row": - case "database-add-view": - case "database-update-view": - case "database-remove-view": - case "database-set-active-view": - case "database-update-select-options": - return this._databaseOp(op); - case "set-meta": - return this._setMeta(op); - default: - return []; - } + return executeSingleOp(this, op); } // ── Block Ops ──────────────────────────────────────────── private _insertBlock(op: InsertBlockOp): string[] { - const schema = this._registry.resolve(op.blockType); - if (!schema) return []; - - const contentType = resolveRuntimeContentType(schema); - const blockMap = this._adapter.initBlockMap( - this._crdtDoc, - op.blockId, - op.blockType, - contentType, - ) as MutableMap; - - if (op.props && Object.keys(op.props).length > 0) { - const propsMap = this._getOrCreateMapProp(blockMap, "props"); - for (const [key, value] of Object.entries(op.props)) { - propsMap.set(key, value); - } - } - - if ((schema as { content: unknown }).content === "subdocument") { - const propsMap = this._getOrCreateMapProp(blockMap, "props"); - const subdocument = blockMap.get("subdocument") as - | { guid?: unknown } - | undefined; - if ( - subdocument && - typeof subdocument === "object" && - typeof subdocument.guid === "string" - ) { - propsMap.set("subdocumentGuid", subdocument.guid); - } - } - - if (typeof op.position === "object" && "parent" in op.position) { - const parentMap = this._getMutableBlockMap(op.position.parent); - if (parentMap) { - const children = this._getOrCreateStringArrayProp( - parentMap, - "children", - ); - const idx = Math.min(op.position.index, children.length); - children.insert(idx, [op.blockId]); - } - } else { - const idx = this._resolvePosition(op.position); - this.mutableBlockOrder.insert(idx, [op.blockId]); - } - - if ((schema as { content: unknown }).content === "database") { - this._databaseOps.seedDatabaseBlock(blockMap); - } - - return [op.blockId]; + return insertBlock(this, op); } private _updateBlock(op: UpdateBlockOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const propsMap = this._getOrCreateMapProp(blockMap, "props"); - - for (const [key, value] of Object.entries(op.props)) { - if (value === undefined || value === null) { - propsMap.delete(key); - } else { - propsMap.set(key, value); - } - } - - return [op.blockId]; + return updateBlock(this, op); } private _deleteBlock(op: DeleteBlockOp): string[] { - this.mutableBlocks.delete(op.blockId); - this._removeBlockIdFromArray(this.mutableBlockOrder, op.blockId); - this._removeBlockIdFromAllChildren(op.blockId); - - return [op.blockId]; + return deleteBlock(this, op); } private _moveBlock(op: MoveBlockOp): string[] { - this._removeBlockIdFromArray(this.mutableBlockOrder, op.blockId, true); - this._removeBlockIdFromAllChildren(op.blockId); - - // Insert at new position - if (typeof op.position === "object" && "parent" in op.position) { - const parentMap = this._getMutableBlockMap(op.position.parent); - if (parentMap) { - const children = this._getOrCreateStringArrayProp( - parentMap, - "children", - ); - const idx = Math.min(op.position.index, children.length); - children.insert(idx, [op.blockId]); - } - } else { - const idx = this._resolvePosition(op.position); - this.mutableBlockOrder.insert(idx, [op.blockId]); - } - - return [op.blockId]; + return moveBlock(this, op); } private _convertBlock(op: ConvertBlockOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const oldType = blockMap.get("type") as string; - const oldSchema = this._registry.resolve(oldType); - const newSchema = this._registry.resolve(op.newType); - if (!newSchema) return []; - - blockMap.set("type", op.newType); - - const propsMap = getMapProp(blockMap, "props"); - if (propsMap) { - const mutablePropsMap = propsMap as MutableMap; - const newPropKeys = new Set( - Object.keys(newSchema.propSchema ?? {}), - ); - for (const key of [...(mutablePropsMap.keys?.() ?? [])]) { - if (!newPropKeys.has(key)) { - mutablePropsMap.delete(key); - } - } - } - - if (op.newProps) { - const props = this._getOrCreateMapProp(blockMap, "props"); - for (const [key, value] of Object.entries(op.newProps)) { - props.set(key, value); - } - } - - const oldContent = oldSchema?.content; - const newContent = newSchema.content; - const preservedInlineDeltas = - oldContent === "inline" - ? this._getPreservedInlineDeltas(this._getTextContent(blockMap)) - : []; - - if (oldContent === "inline" && newContent !== "inline") { - if ( - newContent === "none" || - newContent === "table" || - Array.isArray(newContent) - ) { - blockMap.delete("content"); - } - } else if (oldContent !== "inline" && newContent === "inline") { - const ytext = this._adapter.createText(); - blockMap.set("content", ytext); - } - - const targetContent = resolveRuntimeContentType(newSchema); - if (targetContent !== "database") { - this._clearDatabaseState(blockMap); - } - if (targetContent === "table") { - blockMap.delete("tableColumns"); - } else if (targetContent !== "database") { - this._clearTableState(blockMap); - } - - if (targetContent === "table" && !getTableContent(blockMap)) { - this._tableGrid.seedTableBlock(blockMap, { - rowCount: 2, - colCount: 2, - preservedInlineDeltas, - }); - } - - if (targetContent === "database") { - if (oldType === "table") { - this._migrateTableToDatabase(blockMap, propsMap); - } - this._databaseOps.seedDatabaseBlock(blockMap); - } - - return [op.blockId]; + return convertBlock(this, op); } private _migrateTableToDatabase( blockMap: MutableMap, propsMap: CRDTUnknownMap | null, ): void { - const tableContent = getTableContent(blockMap); - if (!tableContent) { - return; - } - - const hasHeaderRow = propsMap?.get("hasHeaderRow") !== false; - const existingColumns = getTableColumns(blockMap); - if (!existingColumns || existingColumns.length === 0) { - const columnCount = - this._tableGrid.resolveGridColumnCount(blockMap); - const columns = Array.from({ length: columnCount }, (_, index) => { - const title = - hasHeaderRow && tableContent.length > 0 - ? this._tableGrid - .readTableCellText( - tableContent.get(0) as CRDTUnknownMap, - index, - ) - .trim() || `Column ${index + 1}` - : `Column ${index + 1}`; - return { - id: `column-${index + 1}`, - title, - type: "text" as const, - }; - }); - if (columns.length > 0) { - this._tableGrid.setStructuredTableColumns(blockMap, columns); - } - } - - if (hasHeaderRow && tableContent.length > 0) { - tableContent.delete(0, 1); - } - - for (let rowIndex = 0; rowIndex < tableContent.length; rowIndex++) { - const row = tableContent.get(rowIndex); - if (!row || !isCRDTMap(row)) { - continue; - } - if (!getStringProp(row, "id")) { - row.set("id", generateId()); - } - } + migrateTableToDatabase(this, blockMap, propsMap); } private _splitBlock(op: SplitBlockOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const content = this._getTextContent(blockMap); - if (!content) return []; - - const oldType = blockMap.get("type") as string; - const newType = op.newBlockType ?? oldType; - const schema = this._registry.resolve(newType); - - const deltas = content.toDelta(); - const tailDeltas: Array<{ - insert: string | object; - attributes?: Record; - }> = []; - let pos = 0; - - for (const delta of deltas) { - const len = - typeof delta.insert === "string" ? delta.insert.length : 1; - if (pos + len <= op.offset) { - pos += len; - continue; - } - - if (pos < op.offset) { - const splitAt = op.offset - pos; - const tailText = (delta.insert as string).slice(splitAt); - if (tailText) { - tailDeltas.push({ - insert: tailText, - attributes: delta.attributes, - }); - } - } else { - tailDeltas.push(delta); - } - pos += len; - } - - const totalLength = content.length; - if (op.offset < totalLength) { - content.delete(op.offset, totalLength - op.offset); - } - - // Initialize the new block through the adapter so shared CRDT state stays consistent. - const contentType = resolveRuntimeContentType(schema); - const newBlockMap = this._adapter.initBlockMap( - this._crdtDoc, - op.newBlockId, - newType, - contentType, - ) as MutableMap; - - const newContent = this._getTextContent(newBlockMap); - if (newContent) { - for (const delta of tailDeltas) { - newContent.insert( - newContent.length, - delta.insert as string, - delta.attributes, - ); - } - } - - // Copy parentId if present - const propsMap = getMapProp(blockMap, "props"); - if (propsMap?.get?.("parentId")) { - const newProps = getMapProp(newBlockMap, "props"); - if (newProps) { - newProps.set("parentId", propsMap.get("parentId")); - } - } - - // Insert new block right after original in blockOrder - for (let i = 0; i < this.blockOrder.length; i++) { - if (this.blockOrder.get(i) === op.blockId) { - this.mutableBlockOrder.insert(i + 1, [op.newBlockId]); - break; - } - } - - return [op.blockId, op.newBlockId]; + return splitBlock(this, op); } private _mergeBlocks(op: MergeBlocksOp): string[] { - const targetMap = this._getMutableBlockMap(op.targetBlockId); - const sourceMap = this._getMutableBlockMap(op.sourceBlockId); - if (!targetMap || !sourceMap) return []; - - const targetContent = this._getTextContent(targetMap); - const sourceContent = this._getTextContent(sourceMap); - - if ( - targetContent && - sourceContent && - typeof sourceContent.toDelta === "function" - ) { - if ( - targetContent.length === 1 && - targetContent.toString() === ZERO_WIDTH_SPACE - ) { - targetContent.delete(0, 1); - } - - const deltas = sourceContent.toDelta(); - for (const delta of deltas) { - if ( - typeof delta.insert === "string" && - delta.insert === ZERO_WIDTH_SPACE - ) { - continue; - } - targetContent.insert( - targetContent.length, - delta.insert as string, - delta.attributes, - ); - } - - while (targetContent.length > 1) { - const placeholderOffset = targetContent - .toString() - .indexOf(ZERO_WIDTH_SPACE); - if (placeholderOffset < 0) break; - targetContent.delete(placeholderOffset, 1); - } - } - - this.mutableBlocks.delete(op.sourceBlockId); - for (let i = this.mutableBlockOrder.length - 1; i >= 0; i--) { - if (this.blockOrder.get(i) === op.sourceBlockId) { - this.mutableBlockOrder.delete(i, 1); - break; - } - } - - return [op.targetBlockId, op.sourceBlockId]; + return mergeBlocks(this, op); } // ── Text Ops ───────────────────────────────────────────── private _insertText(op: InsertTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { - content.delete(0, 1); - } - - const marks = op.marks ? this._resolveMarks(op.marks) : undefined; - content.insert(op.offset, op.text, marks); - return [op.blockId]; + return insertText(this, op); } private _deleteText(op: DeleteTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - content.delete(op.offset, op.length); - return [op.blockId]; + return deleteText(this, op); } private _formatText(op: FormatTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - content.format(op.offset, op.length, op.marks); - return [op.blockId]; + return formatText(this, op); } private _replaceText(op: ReplaceTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { - content.delete(0, 1); - } - - content.delete(op.offset, op.length); - const marks = op.marks ? this._resolveMarks(op.marks) : undefined; - content.insert(op.offset, op.text, marks); - return [op.blockId]; + return replaceText(this, op); } private _resolveMarks( marks: Record, ): Record { - const resolved: Record = {}; - for (const [type, value] of Object.entries(marks)) { - const schema = this._registry.resolveInline(type); - if (!schema) continue; - resolved[type] = value; - } - return resolved; + return resolveMarks(this, marks); } // ── Inline Node Ops ────────────────────────────────────── private _insertInlineNode(op: InsertInlineNodeOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getInlineTextContent(blockMap); - if (!content) return []; - - content.insertEmbed(op.offset, { - type: op.nodeType, - ...op.props, - }); - return [op.blockId]; + return insertInlineNode(this, op); } private _removeInlineNode(op: RemoveInlineNodeOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - content.delete(op.offset, 1); - return [op.blockId]; + return removeInlineNode(this, op); } // ── Selection Op ───────────────────────────────────────── private _setSelection(op: SetSelectionOp): string[] { - this._selection.setSelection(op.selection); - return []; + return setSelectionOp(this, op); } // ── Layout Op ──────────────────────────────────────────── private _updateLayout(op: UpdateLayoutOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const layoutMap = this._getOrCreateMapProp(blockMap, "layout"); - - for (const [key, value] of Object.entries(op.layout)) { - if (value === undefined || value === null) { - layoutMap.delete(key); - } else { - layoutMap.set(key, value); - } - } - - return [op.blockId]; + return updateLayout(this, op); } // ── App Ops ────────────────────────────────────────────── private _createApp(op: CreateAppOp): string[] { - const appMap = this._createMutableMap(); - appMap.set("type", op.appType); - appMap.set("placement", op.placement); - - if (op.config && Object.keys(op.config).length > 0) { - const configMap = this._createMutableMap(); - for (const [key, value] of Object.entries(op.config)) { - configMap.set(key, value); - } - appMap.set("config", configMap); - } - - this.mutableApps.set(op.appId, appMap); - return []; + return createApp(this, op); } private _updateApp(op: UpdateAppOp): string[] { - const appMap = this._getMutableAppMap(op.appId); - if (!appMap) return []; - - const configMap = this._getOrCreateMapProp(appMap, "config"); - - for (const [key, value] of Object.entries(op.patch)) { - if (value === undefined || value === null) { - configMap.delete(key); - } else { - configMap.set(key, value); - } - } - return []; + return updateApp(this, op); } private _deleteApp(op: DeleteAppOp): string[] { - this.mutableApps.delete(op.appId); - return []; + return deleteApp(this, op); } // ── Table Ops ──────────────────────────────────────────── private _tableOp(op: DocumentOp): string[] { - const tableOp = op as { blockId: string; type: string }; - const blockMap = this._getMutableBlockMap(tableOp.blockId); - if (!blockMap) return []; - - const blockType = blockMap.get("type"); - if (blockType === "database") { - if (op.type === "update-table-columns") { - return this._databaseOps.replaceColumns( - blockMap, - (op as UpdateTableColumnsOp).columns, - ) - ? [tableOp.blockId] - : []; - } - - if (this._isDatabaseStructuralTableOp(op.type)) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_006", - level: "warn", - source: "apply", - message: `apply: skipping ${op.type} for database block "${tableOp.blockId}"`, - remediation: - "Use database operations for structural database changes so row ids, column schema, and views stay in sync.", - op, - }); - return []; - } - } - - return this._tableGrid.execute(blockMap, op); + return tableOp(this, op); } private _databaseOp(op: DocumentOp): string[] { - const databaseOp = op as { type: string; blockId: string }; - const blockMap = this._getMutableBlockMap(databaseOp.blockId); - if (!blockMap) return []; - - return this._databaseOps.execute(blockMap, op); + return databaseOp(this, op); } private _clearTableState(blockMap: MutableMap): void { - blockMap.delete("tableContent"); - blockMap.delete("tableColumns"); + clearTableState(this, blockMap); } private _clearDatabaseState(blockMap: MutableMap): void { - blockMap.delete("databaseViews"); - blockMap.delete("databasePrimaryViewId"); + clearDatabaseState(this, blockMap); } private _isDatabaseStructuralTableOp(type: string): boolean { - return ( - type === "insert-table-row" || - type === "delete-table-row" || - type === "insert-table-column" || - type === "delete-table-column" || - type === "merge-table-cells" || - type === "split-table-cell" - ); + return isDatabaseStructuralTableOp(this, type); } private _getPreservedInlineDeltas(content: CRDTText | undefined): Array<{ insert: string; attributes?: Record; }> { - if (!content || typeof content.toDelta !== "function") { - return []; - } - - return content.toDelta().filter( - ( - delta, - ): delta is { - insert: string; - attributes?: Record; - } => - typeof delta.insert === "string" && - delta.insert !== ZERO_WIDTH_SPACE, - ); + return getPreservedInlineDeltas(this, content); } + // ── Meta Op ────────────────────────────────────────────── private _setMeta(op: SetMetaOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const metaMap = this._getOrCreateMapProp(blockMap, "meta"); - - // Persist metadata as plain JSON so adapters can round-trip it predictably. - if (op.data === null) { - metaMap.delete(op.namespace); - } else { - metaMap.set(op.namespace, op.data); - } - - return [op.blockId]; + return setMeta(this, op); } // ── Helpers ────────────────────────────────────────────── private _blockExists(blockId: string): boolean { - return this.blocks.has(blockId); + return blockExists(this, blockId); } private _createMutableMap(): MutableMap { - return this._adapter.createMap() as MutableMap; + return createMutableMap(this); } private _getMutableBlockMap(blockId: string): MutableMap | null { - return ( - (this.blocks.get(blockId) as unknown as MutableMap | undefined) ?? - null - ); + return getMutableBlockMap(this, blockId); } private _getMutableAppMap(appId: string): MutableMap | null { - return ( - (this.apps.get(appId) as unknown as MutableMap | undefined) ?? null - ); + return getMutableAppMap(this, appId); } private _getOrCreateMapProp( container: CRDTUnknownMap, key: string, ): MutableMap { - const existing = getMapProp(container, key); - if (existing) { - return existing as MutableMap; - } - const map = this._createMutableMap(); - container.set(key, map); - return map; + return getOrCreateMapProp(this, container, key); } private _getOrCreateStringArrayProp( container: CRDTUnknownMap, key: string, ): MutableStringArray { - const existing = getArrayProp(container, key); - if (existing) { - return existing as MutableStringArray; - } - const array = this._adapter.createArray() as MutableStringArray; - container.set(key, array); - return array; + return getOrCreateStringArrayProp(this, container, key); } private _removeBlockIdFromArray( @@ -1237,65 +392,25 @@ export class ApplyPipeline { blockId: string, stopAfterFirst = false, ): void { - for (let index = array.length - 1; index >= 0; index--) { - if (array.get(index) !== blockId) { - continue; - } - array.delete(index, 1); - if (stopAfterFirst) { - return; - } - } + removeBlockIdFromArray(this, array, blockId, stopAfterFirst); } private _removeBlockIdFromAllChildren(blockId: string): void { - for (const [, parentMap] of this.blocks.entries()) { - const children = getArrayProp( - parentMap as unknown as CRDTUnknownMap, - "children", - ); - if (!children) { - continue; - } - this._removeBlockIdFromArray( - children as MutableStringArray, - blockId, - ); - } + removeBlockIdFromAllChildren(this, blockId); } private _getTextContent(blockMap: CRDTUnknownMap): CRDTText | undefined { - const content = blockMap.get("content"); - return content && - typeof content === "object" && - typeof (content as { insert?: unknown }).insert === "function" && - typeof (content as { delete?: unknown }).delete === "function" && - typeof (content as { format?: unknown }).format === "function" && - typeof (content as { toDelta?: unknown }).toDelta === "function" && - typeof (content as { toString?: unknown }).toString === - "function" && - typeof (content as { length?: unknown }).length === "number" - ? (content as CRDTText) - : undefined; + return getTextContent(this, blockMap); } private _getInlineTextContent( blockMap: CRDTUnknownMap, ): CRDTInlineText | undefined { - const content = this._getTextContent(blockMap); - return content && - typeof (content as { insertEmbed?: unknown }).insertEmbed === - "function" - ? (content as CRDTInlineText) - : undefined; + return getInlineTextContent(this, blockMap); } private _opBlockId(op: DocumentOp): string | null { - if ("blockId" in op) return (op as { blockId: string }).blockId; - if ("targetBlockId" in op) - return (op as { targetBlockId: string }).targetBlockId; - if ("appId" in op) return null; - return null; + return opBlockId(this, op); } updateDocument( diff --git a/packages/core/src/editor/applyBlockOps.ts b/packages/core/src/editor/applyBlockOps.ts new file mode 100644 index 0000000..e5680b0 --- /dev/null +++ b/packages/core/src/editor/applyBlockOps.ts @@ -0,0 +1,436 @@ +import type { + DocumentOp, + OpOrigin, + InsertBlockOp, + UpdateBlockOp, + DeleteBlockOp, + MoveBlockOp, + ConvertBlockOp, + SplitBlockOp, + MergeBlocksOp, + InsertTextOp, + DeleteTextOp, + FormatTextOp, + ReplaceTextOp, + InsertInlineNodeOp, + RemoveInlineNodeOp, + UpdateLayoutOp, + SetMetaOp, + CreateAppOp, + UpdateAppOp, + DeleteAppOp, + SetSelectionOp, + UpdateTableColumnsOp, + CRDTArray, +} from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; +import { resolveRuntimeContentType } from "../schema/contentType"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, + getStringProp, + getTableColumns, + getTableContent, + isCRDTMap, +} from "./crdtShapes"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} +const ZERO_WIDTH_SPACE = "\u200B"; + + +export function insertBlock(pipeline: ApplyPipeline, op: InsertBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const schema = self._registry.resolve(op.blockType); +if (!schema) return []; + +const contentType = resolveRuntimeContentType(schema); +const blockMap = self._adapter.initBlockMap( + self._crdtDoc, + op.blockId, + op.blockType, + contentType, +) as MutableMap; + +if (op.props && Object.keys(op.props).length > 0) { + const propsMap = self._getOrCreateMapProp(blockMap, "props"); + for (const [key, value] of Object.entries(op.props)) { + propsMap.set(key, value); + } +} + +if ((schema as { content: unknown }).content === "subdocument") { + const propsMap = self._getOrCreateMapProp(blockMap, "props"); + const subdocument = blockMap.get("subdocument") as + | { guid?: unknown } + | undefined; + if ( + subdocument && + typeof subdocument === "object" && + typeof subdocument.guid === "string" + ) { + propsMap.set("subdocumentGuid", subdocument.guid); + } +} + +if (typeof op.position === "object" && "parent" in op.position) { + const parentMap = self._getMutableBlockMap(op.position.parent); + if (parentMap) { + const children = self._getOrCreateStringArrayProp( + parentMap, + "children", + ); + const idx = Math.min(op.position.index, children.length); + children.insert(idx, [op.blockId]); + } +} else { + const idx = self._resolvePosition(op.position); + self.mutableBlockOrder.insert(idx, [op.blockId]); +} + +if ((schema as { content: unknown }).content === "database") { + self._databaseOps.seedDatabaseBlock(blockMap); +} + +return [op.blockId]; +} + +export function updateBlock(pipeline: ApplyPipeline, op: UpdateBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const propsMap = self._getOrCreateMapProp(blockMap, "props"); + +for (const [key, value] of Object.entries(op.props)) { + if (value === undefined || value === null) { + propsMap.delete(key); + } else { + propsMap.set(key, value); + } +} + +return [op.blockId]; +} + +export function deleteBlock(pipeline: ApplyPipeline, op: DeleteBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self.mutableBlocks.delete(op.blockId); +self._removeBlockIdFromArray(self.mutableBlockOrder, op.blockId); +self._removeBlockIdFromAllChildren(op.blockId); + +return [op.blockId]; +} + +export function moveBlock(pipeline: ApplyPipeline, op: MoveBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self._removeBlockIdFromArray(self.mutableBlockOrder, op.blockId, true); +self._removeBlockIdFromAllChildren(op.blockId); + +// Insert at new position +if (typeof op.position === "object" && "parent" in op.position) { + const parentMap = self._getMutableBlockMap(op.position.parent); + if (parentMap) { + const children = self._getOrCreateStringArrayProp( + parentMap, + "children", + ); + const idx = Math.min(op.position.index, children.length); + children.insert(idx, [op.blockId]); + } +} else { + const idx = self._resolvePosition(op.position); + self.mutableBlockOrder.insert(idx, [op.blockId]); +} + +return [op.blockId]; +} + +export function convertBlock(pipeline: ApplyPipeline, op: ConvertBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const oldType = blockMap.get("type") as string; +const oldSchema = self._registry.resolve(oldType); +const newSchema = self._registry.resolve(op.newType); +if (!newSchema) return []; + +blockMap.set("type", op.newType); + +const propsMap = getMapProp(blockMap, "props"); +if (propsMap) { + const mutablePropsMap = propsMap as MutableMap; + const newPropKeys = new Set( + Object.keys(newSchema.propSchema ?? {}), + ); + for (const key of [...(mutablePropsMap.keys?.() ?? [])]) { + if (!newPropKeys.has(key)) { + mutablePropsMap.delete(key); + } + } +} + +if (op.newProps) { + const props = self._getOrCreateMapProp(blockMap, "props"); + for (const [key, value] of Object.entries(op.newProps)) { + props.set(key, value); + } +} + +const oldContent = oldSchema?.content; +const newContent = newSchema.content; +const preservedInlineDeltas = + oldContent === "inline" + ? self._getPreservedInlineDeltas(self._getTextContent(blockMap)) + : []; + +if (oldContent === "inline" && newContent !== "inline") { + if ( + newContent === "none" || + newContent === "table" || + Array.isArray(newContent) + ) { + blockMap.delete("content"); + } +} else if (oldContent !== "inline" && newContent === "inline") { + const ytext = self._adapter.createText(); + blockMap.set("content", ytext); +} + +const targetContent = resolveRuntimeContentType(newSchema); +if (targetContent !== "database") { + self._clearDatabaseState(blockMap); +} +if (targetContent === "table") { + blockMap.delete("tableColumns"); +} else if (targetContent !== "database") { + self._clearTableState(blockMap); +} + +if (targetContent === "table" && !getTableContent(blockMap)) { + self._tableGrid.seedTableBlock(blockMap, { + rowCount: 2, + colCount: 2, + preservedInlineDeltas, + }); +} + +if (targetContent === "database") { + if (oldType === "table") { + self._migrateTableToDatabase(blockMap, propsMap); + } + self._databaseOps.seedDatabaseBlock(blockMap); +} + +return [op.blockId]; +} + +export function migrateTableToDatabase(pipeline: ApplyPipeline, + blockMap: MutableMap, + propsMap: CRDTUnknownMap | null, +): void { + const self = pipeline as ApplyPipelineRuntime; +const tableContent = getTableContent(blockMap); +if (!tableContent) { + return; +} + +const hasHeaderRow = propsMap?.get("hasHeaderRow") !== false; +const existingColumns = getTableColumns(blockMap); +if (!existingColumns || existingColumns.length === 0) { + const columnCount = + self._tableGrid.resolveGridColumnCount(blockMap); + const columns = Array.from({ length: columnCount }, (_, index) => { + const title = + hasHeaderRow && tableContent.length > 0 + ? self._tableGrid + .readTableCellText( + tableContent.get(0) as CRDTUnknownMap, + index, + ) + .trim() || `Column ${index + 1}` + : `Column ${index + 1}`; + return { + id: `column-${index + 1}`, + title, + type: "text" as const, + }; + }); + if (columns.length > 0) { + self._tableGrid.setStructuredTableColumns(blockMap, columns); + } +} + +if (hasHeaderRow && tableContent.length > 0) { + tableContent.delete(0, 1); +} + +for (let rowIndex = 0; rowIndex < tableContent.length; rowIndex++) { + const row = tableContent.get(rowIndex); + if (!row || !isCRDTMap(row)) { + continue; + } + if (!getStringProp(row, "id")) { + row.set("id", generateId()); + } +} +} + +export function splitBlock(pipeline: ApplyPipeline, op: SplitBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const content = self._getTextContent(blockMap); +if (!content) return []; + +const oldType = blockMap.get("type") as string; +const newType = op.newBlockType ?? oldType; +const schema = self._registry.resolve(newType); + +const deltas = content.toDelta(); +const tailDeltas: Array<{ + insert: string | object; + attributes?: Record; +}> = []; +let pos = 0; + +for (const delta of deltas) { + const len = + typeof delta.insert === "string" ? delta.insert.length : 1; + if (pos + len <= op.offset) { + pos += len; + continue; + } + + if (pos < op.offset) { + const splitAt = op.offset - pos; + const tailText = (delta.insert as string).slice(splitAt); + if (tailText) { + tailDeltas.push({ + insert: tailText, + attributes: delta.attributes, + }); + } + } else { + tailDeltas.push(delta); + } + pos += len; +} + +const totalLength = content.length; +if (op.offset < totalLength) { + content.delete(op.offset, totalLength - op.offset); +} + +// Initialize the new block through the adapter so shared CRDT state stays consistent. +const contentType = resolveRuntimeContentType(schema); +const newBlockMap = self._adapter.initBlockMap( + self._crdtDoc, + op.newBlockId, + newType, + contentType, +) as MutableMap; + +const newContent = self._getTextContent(newBlockMap); +if (newContent) { + for (const delta of tailDeltas) { + newContent.insert( + newContent.length, + delta.insert as string, + delta.attributes, + ); + } +} + +// Copy parentId if present +const propsMap = getMapProp(blockMap, "props"); +if (propsMap?.get?.("parentId")) { + const newProps = getMapProp(newBlockMap, "props"); + if (newProps) { + newProps.set("parentId", propsMap.get("parentId")); + } +} + +// Insert new block right after original in blockOrder +for (let i = 0; i < self.blockOrder.length; i++) { + if (self.blockOrder.get(i) === op.blockId) { + self.mutableBlockOrder.insert(i + 1, [op.newBlockId]); + break; + } +} + +return [op.blockId, op.newBlockId]; +} + +export function mergeBlocks(pipeline: ApplyPipeline, op: MergeBlocksOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const targetMap = self._getMutableBlockMap(op.targetBlockId); +const sourceMap = self._getMutableBlockMap(op.sourceBlockId); +if (!targetMap || !sourceMap) return []; + +const targetContent = self._getTextContent(targetMap); +const sourceContent = self._getTextContent(sourceMap); + +if ( + targetContent && + sourceContent && + typeof sourceContent.toDelta === "function" +) { + if ( + targetContent.length === 1 && + targetContent.toString() === ZERO_WIDTH_SPACE + ) { + targetContent.delete(0, 1); + } + + const deltas = sourceContent.toDelta(); + for (const delta of deltas) { + if ( + typeof delta.insert === "string" && + delta.insert === ZERO_WIDTH_SPACE + ) { + continue; + } + targetContent.insert( + targetContent.length, + delta.insert as string, + delta.attributes, + ); + } + + while (targetContent.length > 1) { + const placeholderOffset = targetContent + .toString() + .indexOf(ZERO_WIDTH_SPACE); + if (placeholderOffset < 0) break; + targetContent.delete(placeholderOffset, 1); + } +} + +self.mutableBlocks.delete(op.sourceBlockId); +for (let i = self.mutableBlockOrder.length - 1; i >= 0; i--) { + if (self.blockOrder.get(i) === op.sourceBlockId) { + self.mutableBlockOrder.delete(i, 1); + break; + } +} + +return [op.targetBlockId, op.sourceBlockId]; +} diff --git a/packages/core/src/editor/applyInlineAndMetaOps.ts b/packages/core/src/editor/applyInlineAndMetaOps.ts new file mode 100644 index 0000000..71f4dac --- /dev/null +++ b/packages/core/src/editor/applyInlineAndMetaOps.ts @@ -0,0 +1,310 @@ +import type { + DocumentOp, + OpOrigin, + InsertBlockOp, + UpdateBlockOp, + DeleteBlockOp, + MoveBlockOp, + ConvertBlockOp, + SplitBlockOp, + MergeBlocksOp, + InsertTextOp, + DeleteTextOp, + FormatTextOp, + ReplaceTextOp, + InsertInlineNodeOp, + RemoveInlineNodeOp, + UpdateLayoutOp, + SetMetaOp, + CreateAppOp, + UpdateAppOp, + DeleteAppOp, + SetSelectionOp, + UpdateTableColumnsOp, + CRDTArray, +} from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; +import { resolveRuntimeContentType } from "../schema/contentType"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, + getStringProp, + getTableColumns, + getTableContent, + isCRDTMap, +} from "./crdtShapes"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} +const ZERO_WIDTH_SPACE = "\u200B"; + + +export function insertText(pipeline: ApplyPipeline, op: InsertTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { + content.delete(0, 1); +} + +const marks = op.marks ? self._resolveMarks(op.marks) : undefined; +content.insert(op.offset, op.text, marks); +return [op.blockId]; +} + +export function deleteText(pipeline: ApplyPipeline, op: DeleteTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +content.delete(op.offset, op.length); +return [op.blockId]; +} + +export function formatText(pipeline: ApplyPipeline, op: FormatTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +content.format(op.offset, op.length, op.marks); +return [op.blockId]; +} + +export function replaceText(pipeline: ApplyPipeline, op: ReplaceTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { + content.delete(0, 1); +} + +content.delete(op.offset, op.length); +const marks = op.marks ? self._resolveMarks(op.marks) : undefined; +content.insert(op.offset, op.text, marks); +return [op.blockId]; +} + +export function resolveMarks(pipeline: ApplyPipeline, + marks: Record, +): Record { + const self = pipeline as ApplyPipelineRuntime; +const resolved: Record = {}; +for (const [type, value] of Object.entries(marks)) { + const schema = self._registry.resolveInline(type); + if (!schema) continue; + resolved[type] = value; +} +return resolved; +} + +export function insertInlineNode(pipeline: ApplyPipeline, op: InsertInlineNodeOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getInlineTextContent(blockMap); +if (!content) return []; + +content.insertEmbed(op.offset, { + type: op.nodeType, + ...op.props, +}); +return [op.blockId]; +} + +export function removeInlineNode(pipeline: ApplyPipeline, op: RemoveInlineNodeOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +content.delete(op.offset, 1); +return [op.blockId]; +} + +export function setSelectionOp(pipeline: ApplyPipeline, op: SetSelectionOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self._selection.setSelection(op.selection); +return []; +} + +export function updateLayout(pipeline: ApplyPipeline, op: UpdateLayoutOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const layoutMap = self._getOrCreateMapProp(blockMap, "layout"); + +for (const [key, value] of Object.entries(op.layout)) { + if (value === undefined || value === null) { + layoutMap.delete(key); + } else { + layoutMap.set(key, value); + } +} + +return [op.blockId]; +} + +export function createApp(pipeline: ApplyPipeline, op: CreateAppOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const appMap = self._createMutableMap(); +appMap.set("type", op.appType); +appMap.set("placement", op.placement); + +if (op.config && Object.keys(op.config).length > 0) { + const configMap = self._createMutableMap(); + for (const [key, value] of Object.entries(op.config)) { + configMap.set(key, value); + } + appMap.set("config", configMap); +} + +self.mutableApps.set(op.appId, appMap); +return []; +} + +export function updateApp(pipeline: ApplyPipeline, op: UpdateAppOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const appMap = self._getMutableAppMap(op.appId); +if (!appMap) return []; + +const configMap = self._getOrCreateMapProp(appMap, "config"); + +for (const [key, value] of Object.entries(op.patch)) { + if (value === undefined || value === null) { + configMap.delete(key); + } else { + configMap.set(key, value); + } +} +return []; +} + +export function deleteApp(pipeline: ApplyPipeline, op: DeleteAppOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self.mutableApps.delete(op.appId); +return []; +} + +export function tableOp(pipeline: ApplyPipeline, op: DocumentOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const tableOp = op as { blockId: string; type: string }; +const blockMap = self._getMutableBlockMap(tableOp.blockId); +if (!blockMap) return []; + +const blockType = blockMap.get("type"); +if (blockType === "database") { + if (op.type === "update-table-columns") { + return self._databaseOps.replaceColumns( + blockMap, + (op as UpdateTableColumnsOp).columns, + ) + ? [tableOp.blockId] + : []; + } + + if (self._isDatabaseStructuralTableOp(op.type)) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_006", + level: "warn", + source: "apply", + message: `apply: skipping ${op.type} for database block "${tableOp.blockId}"`, + remediation: + "Use database operations for structural database changes so row ids, column schema, and views stay in sync.", + op, + }); + return []; + } +} + +return self._tableGrid.execute(blockMap, op); +} + +export function databaseOp(pipeline: ApplyPipeline, op: DocumentOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const databaseOp = op as { type: string; blockId: string }; +const blockMap = self._getMutableBlockMap(databaseOp.blockId); +if (!blockMap) return []; + +return self._databaseOps.execute(blockMap, op); +} + +export function clearTableState(pipeline: ApplyPipeline, blockMap: MutableMap): void { + const self = pipeline as ApplyPipelineRuntime; +blockMap.delete("tableContent"); +blockMap.delete("tableColumns"); +} + +export function clearDatabaseState(pipeline: ApplyPipeline, blockMap: MutableMap): void { + const self = pipeline as ApplyPipelineRuntime; +blockMap.delete("databaseViews"); +blockMap.delete("databasePrimaryViewId"); +} + +export function isDatabaseStructuralTableOp(pipeline: ApplyPipeline, type: string): boolean { + const self = pipeline as ApplyPipelineRuntime; +return ( + type === "insert-table-row" || + type === "delete-table-row" || + type === "insert-table-column" || + type === "delete-table-column" || + type === "merge-table-cells" || + type === "split-table-cell" +); +} + +export function getPreservedInlineDeltas( + _pipeline: ApplyPipeline, + content: CRDTText | undefined, +): Array<{ insert: string; attributes?: Record }> { + if (!content || typeof content.toDelta !== "function") { + return []; + } + return content.toDelta().filter( + (delta): delta is { insert: string; attributes?: Record } => + typeof delta.insert === "string" && delta.insert !== ZERO_WIDTH_SPACE, + ); +} + +export function setMeta(pipeline: ApplyPipeline, op: SetMetaOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const metaMap = self._getOrCreateMapProp(blockMap, "meta"); + +// Persist metadata as plain JSON so adapters can round-trip it predictably. +if (op.data === null) { + metaMap.delete(op.namespace); +} else { + metaMap.set(op.namespace, op.data); +} + +return [op.blockId]; +} diff --git a/packages/core/src/editor/applyPipelineRunner.ts b/packages/core/src/editor/applyPipelineRunner.ts new file mode 100644 index 0000000..3f8e9c1 --- /dev/null +++ b/packages/core/src/editor/applyPipelineRunner.ts @@ -0,0 +1,384 @@ +import type { + DocumentOp, + OpOrigin, + CRDTEvent, + InsertBlockOp, + UpdateBlockOp, + DeleteBlockOp, + MoveBlockOp, + ConvertBlockOp, + SplitBlockOp, + MergeBlocksOp, + InsertTextOp, + DeleteTextOp, + FormatTextOp, + ReplaceTextOp, + InsertInlineNodeOp, + RemoveInlineNodeOp, + UpdateLayoutOp, + SetMetaOp, + CreateAppOp, + UpdateAppOp, + DeleteAppOp, + SetSelectionOp, + UpdateTableColumnsOp, + CRDTArray, +} from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; +import { resolveRuntimeContentType } from "../schema/contentType"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, + getStringProp, + getTableColumns, + getTableContent, + isCRDTMap, +} from "./crdtShapes"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} +const ZERO_WIDTH_SPACE = "\u200B"; + + +export function applyInternal(pipeline: ApplyPipeline, ops: DocumentOp[], origin: OpOrigin): void { + const self = pipeline as ApplyPipelineRuntime; +if (self._applying) { + self._queue.push({ ops, origin }); + return; +} + +self._applying = true; +try { + self._executeOps(ops, origin); + while (self._queue.length > 0) { + const { ops: queued, origin: queuedOrigin } = + self._queue.shift()!; + self._executeOps(queued, queuedOrigin); + } +} finally { + self._applying = false; +} +} + +export function executeOps(pipeline: ApplyPipeline, ops: DocumentOp[], origin: OpOrigin): void { + const self = pipeline as ApplyPipelineRuntime; +// Let extensions transform ops before validation and execution. +let transformedOps = ops; +for (const { hook } of self._beforeApplyHooks) { + try { + transformedOps = hook(transformedOps, { origin }); + } catch (err) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_005", + level: "error", + source: "apply", + message: "onBeforeApply hook threw", + remediation: + "Update the onBeforeApply hook to handle incoming ops defensively and " + + "always return a valid DocumentOp array.", + error: err, + }); + } +} +if (self._finalBeforeApplyHook) { + try { + transformedOps = self._finalBeforeApplyHook(transformedOps, { + origin, + }); + } catch (err) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_007", + level: "error", + source: "apply", + message: "final apply boundary hook threw", + remediation: + "Update the final apply boundary hook to handle incoming ops defensively and " + + "always return a valid DocumentOp array.", + error: err, + }); + } +} + +self._emitApplyBoundary({ + phase: "before", + ops: transformedOps, + origin, + applied: false, +}); + +const affectedBlocks: string[] = []; +const validatedOps: DocumentOp[] = []; +const pendingBlockIds = new Set(); + +for (const op of transformedOps) { + const blockId = self._opBlockId(op); + + if (!self._validateOp(op)) continue; + + if (op.type === "insert-block") { + pendingBlockIds.add(op.blockId); + } + + if ( + blockId && + !self._blockExists(blockId) && + !pendingBlockIds.has(blockId) && + op.type !== "insert-block" + ) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_003", + level: "warn", + source: "apply", + message: `apply: skipping ${op.type} for non-existent block "${blockId}"`, + }); + continue; + } + + validatedOps.push(op); +} + +if (validatedOps.length === 0) { + self._emitApplyBoundary({ + phase: "after", + ops: transformedOps, + origin, + applied: false, + }); + return; +} + +self._suppressObserver = true; + +try { + self._adapter.transact( + self._crdtDoc, + () => { + for (const op of validatedOps) { + const affected = self._executeSingleOp(op); + affectedBlocks.push(...affected); + } + + for (const blockId of affectedBlocks) { + self._engine.markDirty(blockId); + } + + self._engine.normalizeDirty(); + }, + getOpOriginType(origin), + ); +} finally { + self._suppressObserver = false; +} + +const event: CRDTEvent = { + origin, + affectedBlocks: [...new Set(affectedBlocks)], + ops: validatedOps, + timestamp: Date.now(), +}; + +self._onDidApply?.(event); +self._emitApplyBoundary({ + phase: "after", + ops: validatedOps, + origin, + applied: true, +}); +} + +export function emitApplyBoundary(pipeline: ApplyPipeline, event: { + phase: "before" | "after"; + ops: readonly DocumentOp[]; + origin: OpOrigin; + applied: boolean; +}): void { + const self = pipeline as ApplyPipelineRuntime; + for (const hook of self._applyBoundaryHooks) { + try { + hook(event); + } catch (err) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_008", + level: "error", + source: "apply", + message: "apply boundary hook threw", + remediation: + "Update the apply boundary hook to avoid throwing during transaction lifecycle notifications.", + error: err, + }); + } + } +} + +export function validateOp(pipeline: ApplyPipeline, op: DocumentOp): boolean { + const self = pipeline as ApplyPipelineRuntime; +switch (op.type) { + case "insert-block": { + const schema = self._registry.resolve(op.blockType); + if (!schema) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + message: `Unknown block type: "${op.blockType}"`, + op, + }); + return false; + } + return true; + } + case "convert-block": { + const schema = self._registry.resolve(op.newType); + if (!schema) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + message: `Unknown block type: "${op.newType}"`, + op, + }); + return false; + } + return true; + } + case "insert-inline-node": { + const schema = self._registry.resolveInline(op.nodeType); + if (!schema || schema.kind !== "node") { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + message: `Unknown inline node type: "${op.nodeType}"`, + op, + }); + return false; + } + return true; + } + default: + return true; +} +} + +export function resolvePosition(pipeline: ApplyPipeline, position: import("@pen/types").Position): number { + const self = pipeline as ApplyPipelineRuntime; +const blockOrder = self._doc.blockOrder; + +if (position === "first") return 0; +if (position === "last") return blockOrder.length; + +if (typeof position === "object" && "after" in position) { + for (let i = 0; i < blockOrder.length; i++) { + if ((blockOrder.get(i) as string) === position.after) + return i + 1; + } + return blockOrder.length; +} + +if (typeof position === "object" && "before" in position) { + for (let i = 0; i < blockOrder.length; i++) { + if ((blockOrder.get(i) as string) === position.before) return i; + } + return 0; +} + +if (typeof position === "object" && "parent" in position) { + const parentMap = self.blocks.get(position.parent); + if (!parentMap) return blockOrder.length; + const children = parentMap.get("children") as + | CRDTArray + | undefined; + if (!children) return 0; + return Math.min(position.index, children.length); +} + +return blockOrder.length; +} + +export function executeSingleOp(pipeline: ApplyPipeline, op: DocumentOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +switch (op.type) { + case "insert-block": + return self._insertBlock(op); + case "update-block": + return self._updateBlock(op); + case "delete-block": + return self._deleteBlock(op); + case "move-block": + return self._moveBlock(op); + case "convert-block": + return self._convertBlock(op); + case "split-block": + return self._splitBlock(op); + case "merge-blocks": + return self._mergeBlocks(op); + case "insert-text": + return self._insertText(op); + case "delete-text": + return self._deleteText(op); + case "format-text": + return self._formatText(op); + case "replace-text": + return self._replaceText(op); + case "insert-inline-node": + return self._insertInlineNode(op); + case "remove-inline-node": + return self._removeInlineNode(op); + case "set-selection": + return self._setSelection(op); + case "update-layout": + return self._updateLayout(op); + case "create-app": + return self._createApp(op); + case "update-app": + return self._updateApp(op); + case "delete-app": + return self._deleteApp(op); + case "insert-table-row": + case "delete-table-row": + case "insert-table-column": + case "delete-table-column": + case "merge-table-cells": + case "split-table-cell": + case "insert-table-cell-text": + case "delete-table-cell-text": + case "format-table-cell-text": + case "update-table-columns": + return self._tableOp(op); + case "database-add-column": + case "database-update-column": + case "database-convert-column": + case "database-remove-column": + case "database-insert-row": + case "database-update-cell": + case "database-delete-row": + case "database-delete-rows": + case "database-duplicate-row": + case "database-move-row": + case "database-add-view": + case "database-update-view": + case "database-remove-view": + case "database-set-active-view": + case "database-update-select-options": + return self._databaseOp(op); + case "set-meta": + return self._setMeta(op); + default: + return []; +} +} diff --git a/packages/core/src/editor/applySharedHelpers.ts b/packages/core/src/editor/applySharedHelpers.ts new file mode 100644 index 0000000..78f46f6 --- /dev/null +++ b/packages/core/src/editor/applySharedHelpers.ts @@ -0,0 +1,149 @@ +import type { CRDTDocument, CRDTArray, CRDTMap, DocumentOp } from "@pen/types"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, +} from "./crdtShapes"; +import type { SchemaEngineImpl } from "../schema/normalize"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} + + +export function blockExists(pipeline: ApplyPipeline, blockId: string): boolean { + const self = pipeline as ApplyPipelineRuntime; +return self.blocks.has(blockId); +} + +export function createMutableMap(pipeline: ApplyPipeline, ): MutableMap { + const self = pipeline as ApplyPipelineRuntime; +return self._adapter.createMap() as MutableMap; +} + +export function getMutableBlockMap(pipeline: ApplyPipeline, blockId: string): MutableMap | null { + const self = pipeline as ApplyPipelineRuntime; +return ( + (self.blocks.get(blockId) as unknown as MutableMap | undefined) ?? + null +); +} + +export function getMutableAppMap(pipeline: ApplyPipeline, appId: string): MutableMap | null { + const self = pipeline as ApplyPipelineRuntime; +return ( + (self.apps.get(appId) as unknown as MutableMap | undefined) ?? null +); +} + +export function getOrCreateMapProp(pipeline: ApplyPipeline, + container: CRDTUnknownMap, + key: string, +): MutableMap { + const self = pipeline as ApplyPipelineRuntime; +const existing = getMapProp(container, key); +if (existing) { + return existing as MutableMap; +} +const map = self._createMutableMap(); +container.set(key, map); +return map; +} + +export function getOrCreateStringArrayProp(pipeline: ApplyPipeline, + container: CRDTUnknownMap, + key: string, +): MutableStringArray { + const self = pipeline as ApplyPipelineRuntime; +const existing = getArrayProp(container, key); +if (existing) { + return existing as MutableStringArray; +} +const array = self._adapter.createArray() as MutableStringArray; +container.set(key, array); +return array; +} + +export function removeBlockIdFromArray(pipeline: ApplyPipeline, + array: MutableStringArray, + blockId: string, + stopAfterFirst = false, +): void { + const self = pipeline as ApplyPipelineRuntime; +for (let index = array.length - 1; index >= 0; index--) { + if (array.get(index) !== blockId) { + continue; + } + array.delete(index, 1); + if (stopAfterFirst) { + return; + } +} +} + +export function removeBlockIdFromAllChildren(pipeline: ApplyPipeline, blockId: string): void { + const self = pipeline as ApplyPipelineRuntime; +for (const [, parentMap] of self.blocks.entries()) { + const children = getArrayProp( + parentMap as unknown as CRDTUnknownMap, + "children", + ); + if (!children) { + continue; + } + self._removeBlockIdFromArray( + children as MutableStringArray, + blockId, + ); +} +} + +export function getTextContent(pipeline: ApplyPipeline, blockMap: CRDTUnknownMap): CRDTText | undefined { + const self = pipeline as ApplyPipelineRuntime; +const content = blockMap.get("content"); +return content && + typeof content === "object" && + typeof (content as { insert?: unknown }).insert === "function" && + typeof (content as { delete?: unknown }).delete === "function" && + typeof (content as { format?: unknown }).format === "function" && + typeof (content as { toDelta?: unknown }).toDelta === "function" && + typeof (content as { toString?: unknown }).toString === + "function" && + typeof (content as { length?: unknown }).length === "number" + ? (content as CRDTText) + : undefined; +} + +export function getInlineTextContent(pipeline: ApplyPipeline, + blockMap: CRDTUnknownMap, +): CRDTInlineText | undefined { + const self = pipeline as ApplyPipelineRuntime; +const content = self._getTextContent(blockMap); +return content && + typeof (content as { insertEmbed?: unknown }).insertEmbed === + "function" + ? (content as CRDTInlineText) + : undefined; +} + +export function opBlockId(pipeline: ApplyPipeline, op: DocumentOp): string | null { + const self = pipeline as ApplyPipelineRuntime; +if ("blockId" in op) return (op as { blockId: string }).blockId; +if ("targetBlockId" in op) + return (op as { targetBlockId: string }).targetBlockId; +if ("appId" in op) return null; +return null; +} diff --git a/packages/core/src/editor/databaseViewExecutor.ts b/packages/core/src/editor/databaseViewExecutor.ts index 325195a..e22d446 100644 --- a/packages/core/src/editor/databaseViewExecutor.ts +++ b/packages/core/src/editor/databaseViewExecutor.ts @@ -4,10 +4,6 @@ import type { DatabaseRowPinning, DatabaseRemoveViewOp, DatabaseSetActiveViewOp, - DatabaseSort, - DatabaseViewState, - FilterCondition, - FilterGroup, DatabaseUpdateViewOp, } from "@pen/types"; import { generateId } from "@pen/types"; @@ -23,14 +19,17 @@ import { isCRDTArray, isCRDTMap, } from "./crdtShapes"; +import { DatabaseViewHelpers } from "./databaseViewHelpers"; import { TableGridExecutor } from "./tableGridExecutor"; export class DatabaseViewExecutor { private readonly _adapter: CRDTAdapter; + private readonly _helpers: DatabaseViewHelpers; private readonly _tableGrid: TableGridExecutor; constructor(adapter: CRDTAdapter, tableGrid: TableGridExecutor) { this._adapter = adapter; + this._helpers = new DatabaseViewHelpers(adapter, tableGrid); this._tableGrid = tableGrid; } @@ -87,8 +86,8 @@ export class DatabaseViewExecutor { continue; } - this._replaceViewStringArray(viewMap, "columnOrder", columnIds); - this._replaceViewStringArray(viewMap, "visibleColumnIds", columnIds); + this._helpers.replaceViewStringArray(viewMap, "columnOrder", columnIds); + this._helpers.replaceViewStringArray(viewMap, "visibleColumnIds", columnIds); const sort = viewMap.get("sort"); if (isCRDTArray(sort)) { @@ -124,8 +123,8 @@ export class DatabaseViewExecutor { ), ); databaseViews.insert(nextIndex, [ - this._createDatabaseViewMap( - this._normalizeViewState(blockMap, op.view), + this._helpers.createDatabaseViewMap( + this._helpers.normalizeViewState(blockMap, op.view), ), ]); if (!blockMap.get("databasePrimaryViewId")) { @@ -154,7 +153,7 @@ export class DatabaseViewExecutor { continue; } - const normalizedPatch = this._normalizeViewPatch(blockMap, op.patch); + const normalizedPatch = this._helpers.normalizeViewPatch(blockMap, op.patch); for (const [key, value] of Object.entries(normalizedPatch)) { if (value === undefined) { continue; @@ -182,7 +181,7 @@ export class DatabaseViewExecutor { if (value.length > 0) { const sortEntries = value.flatMap((entry) => entry != null && typeof entry === "object" - ? [this._createRecordMap(entry)] + ? [this._helpers.createRecordMap(entry)] : [], ); array.insert(0, sortEntries); @@ -200,7 +199,7 @@ export class DatabaseViewExecutor { if (!rowPinning.top?.length && !rowPinning.bottom?.length) { viewMap.delete?.(key); } else { - viewMap.set(key, this._createNestedRecord(value)); + viewMap.set(key, this._helpers.createNestedRecord(value)); } continue; } @@ -210,7 +209,7 @@ export class DatabaseViewExecutor { typeof value === "object" && !Array.isArray(value) ) { - viewMap.set(key, this._createNestedRecord(value)); + viewMap.set(key, this._helpers.createNestedRecord(value)); continue; } viewMap.set(key, value); @@ -257,7 +256,7 @@ export class DatabaseViewExecutor { setActiveView(blockMap: CRDTUnknownMap, op: DatabaseSetActiveViewOp): boolean { const databaseViews = getDatabaseViews(blockMap); - const targetViewMap = this._findDatabaseViewMap(databaseViews, op.viewId); + const targetViewMap = this._helpers.findDatabaseViewMap(databaseViews, op.viewId); if (!targetViewMap) { return false; } @@ -286,13 +285,13 @@ export class DatabaseViewExecutor { if (targetViewId && currentViewId !== targetViewId) { continue; } - this._insertStringIntoViewArray( + this._helpers.insertStringIntoViewArray( viewMap, "columnOrder", columnId, columnIndex, ); - this._insertStringIntoViewArray( + this._helpers.insertStringIntoViewArray( viewMap, "visibleColumnIds", columnId, @@ -314,8 +313,8 @@ export class DatabaseViewExecutor { if (!viewMap || !isCRDTMap(viewMap)) { continue; } - this._removeStringFromViewArray(viewMap, "columnOrder", columnId); - this._removeStringFromViewArray( + this._helpers.removeStringFromViewArray(viewMap, "columnOrder", columnId); + this._helpers.removeStringFromViewArray( viewMap, "visibleColumnIds", columnId, @@ -353,362 +352,8 @@ export class DatabaseViewExecutor { if (!rowPinning || !isCRDTMap(rowPinning)) { continue; } - this._removeStringsFromNestedArray(rowPinning, "top", rowIds); - this._removeStringsFromNestedArray(rowPinning, "bottom", rowIds); + this._helpers.removeStringsFromNestedArray(rowPinning, "top", rowIds); + this._helpers.removeStringsFromNestedArray(rowPinning, "bottom", rowIds); } } - - private _normalizeViewState( - blockMap: CRDTUnknownMap, - view: DatabaseViewState, - ): DatabaseViewState { - return { - ...view, - ...this._normalizeViewPatch(blockMap, view), - }; - } - - private _normalizeViewPatch( - blockMap: CRDTUnknownMap, - patch: Partial, - ): Partial { - const columnIds = this._readColumnIds(blockMap); - const columnIdSet = new Set(columnIds); - const rowIdSet = new Set(this._readRowIds(blockMap)); - const normalized: Partial = { ...patch }; - - if (patch.visibleColumnIds) { - normalized.visibleColumnIds = this._normalizeColumnIdList( - patch.visibleColumnIds, - columnIdSet, - ); - } - - if (patch.columnOrder) { - normalized.columnOrder = this._normalizeColumnIdList( - patch.columnOrder, - columnIdSet, - ); - } - - if (patch.sort) { - normalized.sort = this._normalizeSort(patch.sort, columnIdSet); - } - - if (patch.groupBy !== undefined && patch.groupBy !== null) { - normalized.groupBy = columnIdSet.has(patch.groupBy) - ? patch.groupBy - : null; - } - - if (patch.rowPinning) { - normalized.rowPinning = this._normalizeRowPinning( - patch.rowPinning, - rowIdSet, - ); - } - - if (patch.filter) { - normalized.filter = this._normalizeFilterGroup( - patch.filter, - columnIdSet, - ); - } - - return normalized; - } - - private _readColumnIds(blockMap: CRDTUnknownMap): string[] { - return this._tableGrid.readColumnIds(getTableColumns(blockMap)); - } - - private _readRowIds(blockMap: CRDTUnknownMap): string[] { - const tableContent = getTableContent(blockMap); - if (!tableContent) { - return []; - } - - const rowIds: string[] = []; - for (let index = 0; index < tableContent.length; index++) { - const row = tableContent.get(index); - if (!row || !isCRDTMap(row)) { - continue; - } - const rowId = getStringProp(row, "id"); - if (rowId) { - rowIds.push(rowId); - } - } - return rowIds; - } - - private _normalizeColumnIdList( - values: string[], - columnIdSet: Set, - ): string[] { - const seen = new Set(); - return values.filter((value) => { - if (!columnIdSet.has(value) || seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); - } - - private _normalizeSort( - sorts: DatabaseSort[], - columnIdSet: Set, - ): DatabaseSort[] { - const seen = new Set(); - return sorts.filter((sort) => { - if ( - !columnIdSet.has(sort.columnId) || - (sort.direction !== "asc" && sort.direction !== "desc") || - seen.has(sort.columnId) - ) { - return false; - } - seen.add(sort.columnId); - return true; - }); - } - - private _normalizeRowPinning( - rowPinning: DatabaseRowPinning, - rowIdSet: Set, - ): DatabaseRowPinning { - const top = this._normalizeUniqueIds(rowPinning.top ?? [], rowIdSet); - const bottom = this._normalizeUniqueIds( - (rowPinning.bottom ?? []).filter((rowId) => !top.includes(rowId)), - rowIdSet, - ); - return { - ...(top.length > 0 ? { top } : {}), - ...(bottom.length > 0 ? { bottom } : {}), - }; - } - - private _normalizeUniqueIds(values: string[], allowed: Set): string[] { - const seen = new Set(); - return values.filter((value) => { - if (!allowed.has(value) || seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); - } - - private _normalizeFilterGroup( - group: FilterGroup, - columnIdSet: Set, - ): FilterGroup | null { - const conditions: Array = - group.conditions.flatMap((condition) => { - if (this._isFilterGroup(condition)) { - const nestedGroup = this._normalizeFilterGroup(condition, columnIdSet); - return nestedGroup ? [nestedGroup] : []; - } - return this._isValidFilterCondition(condition, columnIdSet) - ? [condition] - : []; - }); - - if (conditions.length === 0) { - return null; - } - - return { - operator: group.operator === "or" ? "or" : "and", - conditions, - }; - } - - private _isFilterGroup( - value: FilterCondition | FilterGroup, - ): value is FilterGroup { - return Array.isArray((value as FilterGroup).conditions); - } - - private _isValidFilterCondition( - condition: FilterCondition, - columnIdSet: Set, - ): boolean { - return columnIdSet.has(condition.columnId); - } - - private _findDatabaseViewMap( - databaseViews: ReturnType, - viewId: string, - ): DatabaseViewMap | null { - if (!databaseViews) { - return null; - } - for (let index = 0; index < databaseViews.length; index++) { - const viewMap = databaseViews.get(index); - if (viewMap && isCRDTMap(viewMap) && getStringProp(viewMap, "id") === viewId) { - return viewMap; - } - } - return null; - } - - private _insertStringIntoViewArray( - viewMap: CRDTUnknownMap, - key: "columnOrder" | "visibleColumnIds", - value: string, - index: number, - ): void { - let arrayValue = viewMap.get(key); - if (!isCRDTArray(arrayValue)) { - arrayValue = this._adapter.createArray() as CRDTUnknownArray; - viewMap.set(key, arrayValue); - } - const array = arrayValue as CRDTUnknownArray; - for (let currentIndex = 0; currentIndex < array.length; currentIndex++) { - if (array.get(currentIndex) === value) { - array.delete(currentIndex, 1); - break; - } - } - array.insert(Math.max(0, Math.min(index, array.length)), [value]); - } - - private _removeStringFromViewArray( - viewMap: CRDTUnknownMap, - key: "columnOrder" | "visibleColumnIds", - value: string, - ): void { - const arrayValue = viewMap.get(key); - if (!isCRDTArray(arrayValue)) { - return; - } - const array = arrayValue as CRDTUnknownArray; - for (let index = array.length - 1; index >= 0; index--) { - if (array.get(index) === value) { - array.delete(index, 1); - } - } - } - - private _replaceViewStringArray( - viewMap: CRDTUnknownMap, - key: "columnOrder" | "visibleColumnIds", - values: string[], - ): void { - const array = this._adapter.createArray() as CRDTUnknownArray; - if (values.length > 0) { - array.insert(0, values); - } - viewMap.set(key, array); - } - - private _removeStringsFromNestedArray( - map: CRDTUnknownMap, - key: "top" | "bottom", - values: string[], - ): void { - const arrayValue = map.get(key); - if (!isCRDTArray(arrayValue)) { - return; - } - const array = arrayValue as CRDTUnknownArray; - const valueSet = new Set(values); - for (let index = array.length - 1; index >= 0; index--) { - if (valueSet.has(array.get(index))) { - array.delete(index, 1); - } - } - } - - private _createDatabaseViewMap(view: DatabaseViewState): DatabaseViewMap { - const viewMap = this._adapter.createMap() as DatabaseViewMap; - viewMap.set("id", view.id); - viewMap.set("type", view.type); - if (view.title) { - viewMap.set("title", view.title); - } - if (view.visibleColumnIds) { - const visibleColumnIds = this._adapter.createArray() as CRDTUnknownArray; - if (view.visibleColumnIds.length > 0) { - visibleColumnIds.insert(0, view.visibleColumnIds); - } - viewMap.set("visibleColumnIds", visibleColumnIds); - } - if (view.columnOrder) { - const columnOrder = this._adapter.createArray() as CRDTUnknownArray; - if (view.columnOrder.length > 0) { - columnOrder.insert(0, view.columnOrder); - } - viewMap.set("columnOrder", columnOrder); - } - if (view.sort) { - const sort = this._adapter.createArray() as CRDTUnknownArray; - if (view.sort.length > 0) { - sort.insert(0, view.sort.map((entry) => this._createRecordMap(entry))); - } - viewMap.set("sort", sort); - } - if (view.filter) { - viewMap.set("filter", this._createNestedRecord(view.filter)); - } - if (view.groupBy !== undefined) { - if (view.groupBy === null) { - viewMap.set("groupBy", null); - } else { - viewMap.set("groupBy", view.groupBy); - } - } - if (view.rowPinning) { - viewMap.set("rowPinning", this._createNestedRecord(view.rowPinning)); - } - if (view.pageIndex !== undefined) { - viewMap.set("pageIndex", view.pageIndex); - } - if (view.pageSize !== undefined) { - viewMap.set("pageSize", view.pageSize); - } - return viewMap; - } - - private _createRecordMap(record: object): DatabaseViewMap { - const map = this._adapter.createMap() as DatabaseViewMap; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) { - map.set(key, value); - } - } - return map; - } - - private _createNestedRecord(record: object): DatabaseViewMap { - const map = this._adapter.createMap() as DatabaseViewMap; - for (const [key, value] of Object.entries(record)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - const array = this._adapter.createArray() as CRDTUnknownArray; - if (value.length > 0) { - array.insert( - 0, - value.map((entry) => - entry && typeof entry === "object" - ? this._createNestedRecord(entry) - : entry, - ), - ); - } - map.set(key, array); - continue; - } - if (value && typeof value === "object") { - map.set(key, this._createNestedRecord(value)); - continue; - } - map.set(key, value); - } - return map; - } } diff --git a/packages/core/src/editor/databaseViewHelpers.ts b/packages/core/src/editor/databaseViewHelpers.ts new file mode 100644 index 0000000..4d2b3ef --- /dev/null +++ b/packages/core/src/editor/databaseViewHelpers.ts @@ -0,0 +1,381 @@ +import type { + CRDTAdapter, + DatabaseRowPinning, + DatabaseSort, + DatabaseViewState, + FilterCondition, + FilterGroup, +} from "@pen/types"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + type DatabaseViewMap, + getDatabaseViews, + getStringProp, + getTableColumns, + getTableContent, + isCRDTArray, + isCRDTMap, +} from "./crdtShapes"; +import type { TableGridExecutor } from "./tableGridExecutor"; + +export class DatabaseViewHelpers { + constructor( + private readonly _adapter: CRDTAdapter, + private readonly _tableGrid: TableGridExecutor, + ) {} + + normalizeViewState( + blockMap: CRDTUnknownMap, + view: DatabaseViewState, + ): DatabaseViewState { + return { + ...view, + ...this.normalizeViewPatch(blockMap, view), + }; + } + + normalizeViewPatch( + blockMap: CRDTUnknownMap, + patch: Partial, + ): Partial { + const columnIds = this._readColumnIds(blockMap); + const columnIdSet = new Set(columnIds); + const rowIdSet = new Set(this._readRowIds(blockMap)); + const normalized: Partial = { ...patch }; + + if (patch.visibleColumnIds) { + normalized.visibleColumnIds = this._normalizeColumnIdList( + patch.visibleColumnIds, + columnIdSet, + ); + } + + if (patch.columnOrder) { + normalized.columnOrder = this._normalizeColumnIdList( + patch.columnOrder, + columnIdSet, + ); + } + + if (patch.sort) { + normalized.sort = this._normalizeSort(patch.sort, columnIdSet); + } + + if (patch.groupBy !== undefined && patch.groupBy !== null) { + normalized.groupBy = columnIdSet.has(patch.groupBy) + ? patch.groupBy + : null; + } + + if (patch.rowPinning) { + normalized.rowPinning = this._normalizeRowPinning( + patch.rowPinning, + rowIdSet, + ); + } + + if (patch.filter) { + normalized.filter = this._normalizeFilterGroup( + patch.filter, + columnIdSet, + ); + } + + return normalized; + } + + findDatabaseViewMap( + databaseViews: ReturnType, + viewId: string, + ): DatabaseViewMap | null { + if (!databaseViews) { + return null; + } + for (let index = 0; index < databaseViews.length; index++) { + const viewMap = databaseViews.get(index); + if (viewMap && isCRDTMap(viewMap) && getStringProp(viewMap, "id") === viewId) { + return viewMap; + } + } + return null; + } + + insertStringIntoViewArray( + viewMap: CRDTUnknownMap, + key: "columnOrder" | "visibleColumnIds", + value: string, + index: number, + ): void { + let arrayValue = viewMap.get(key); + if (!isCRDTArray(arrayValue)) { + arrayValue = this._adapter.createArray() as CRDTUnknownArray; + viewMap.set(key, arrayValue); + } + const array = arrayValue as CRDTUnknownArray; + for (let currentIndex = 0; currentIndex < array.length; currentIndex++) { + if (array.get(currentIndex) === value) { + array.delete(currentIndex, 1); + break; + } + } + array.insert(Math.max(0, Math.min(index, array.length)), [value]); + } + + removeStringFromViewArray( + viewMap: CRDTUnknownMap, + key: "columnOrder" | "visibleColumnIds", + value: string, + ): void { + const arrayValue = viewMap.get(key); + if (!isCRDTArray(arrayValue)) { + return; + } + const array = arrayValue as CRDTUnknownArray; + for (let index = array.length - 1; index >= 0; index--) { + if (array.get(index) === value) { + array.delete(index, 1); + } + } + } + + replaceViewStringArray( + viewMap: CRDTUnknownMap, + key: "columnOrder" | "visibleColumnIds", + values: string[], + ): void { + const array = this._adapter.createArray() as CRDTUnknownArray; + if (values.length > 0) { + array.insert(0, values); + } + viewMap.set(key, array); + } + + removeStringsFromNestedArray( + map: CRDTUnknownMap, + key: "top" | "bottom", + values: string[], + ): void { + const arrayValue = map.get(key); + if (!isCRDTArray(arrayValue)) { + return; + } + const array = arrayValue as CRDTUnknownArray; + const valueSet = new Set(values); + for (let index = array.length - 1; index >= 0; index--) { + if (valueSet.has(array.get(index))) { + array.delete(index, 1); + } + } + } + + createDatabaseViewMap(view: DatabaseViewState): DatabaseViewMap { + const viewMap = this._adapter.createMap() as DatabaseViewMap; + viewMap.set("id", view.id); + viewMap.set("type", view.type); + if (view.title) { + viewMap.set("title", view.title); + } + if (view.visibleColumnIds) { + const visibleColumnIds = this._adapter.createArray() as CRDTUnknownArray; + if (view.visibleColumnIds.length > 0) { + visibleColumnIds.insert(0, view.visibleColumnIds); + } + viewMap.set("visibleColumnIds", visibleColumnIds); + } + if (view.columnOrder) { + const columnOrder = this._adapter.createArray() as CRDTUnknownArray; + if (view.columnOrder.length > 0) { + columnOrder.insert(0, view.columnOrder); + } + viewMap.set("columnOrder", columnOrder); + } + if (view.sort) { + const sort = this._adapter.createArray() as CRDTUnknownArray; + if (view.sort.length > 0) { + sort.insert(0, view.sort.map((entry) => this.createRecordMap(entry))); + } + viewMap.set("sort", sort); + } + if (view.filter) { + viewMap.set("filter", this.createNestedRecord(view.filter)); + } + if (view.groupBy !== undefined) { + if (view.groupBy === null) { + viewMap.set("groupBy", null); + } else { + viewMap.set("groupBy", view.groupBy); + } + } + if (view.rowPinning) { + viewMap.set("rowPinning", this.createNestedRecord(view.rowPinning)); + } + if (view.pageIndex !== undefined) { + viewMap.set("pageIndex", view.pageIndex); + } + if (view.pageSize !== undefined) { + viewMap.set("pageSize", view.pageSize); + } + return viewMap; + } + + createRecordMap(record: object): DatabaseViewMap { + const map = this._adapter.createMap() as DatabaseViewMap; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) { + map.set(key, value); + } + } + return map; + } + + createNestedRecord(record: object): DatabaseViewMap { + const map = this._adapter.createMap() as DatabaseViewMap; + for (const [key, value] of Object.entries(record)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + const array = this._adapter.createArray() as CRDTUnknownArray; + if (value.length > 0) { + array.insert( + 0, + value.map((entry) => + entry && typeof entry === "object" + ? this.createNestedRecord(entry) + : entry, + ), + ); + } + map.set(key, array); + continue; + } + if (value && typeof value === "object") { + map.set(key, this.createNestedRecord(value)); + continue; + } + map.set(key, value); + } + return map; + } + + private _readColumnIds(blockMap: CRDTUnknownMap): string[] { + return this._tableGrid.readColumnIds(getTableColumns(blockMap)); + } + + private _readRowIds(blockMap: CRDTUnknownMap): string[] { + const tableContent = getTableContent(blockMap); + if (!tableContent) { + return []; + } + + const rowIds: string[] = []; + for (let index = 0; index < tableContent.length; index++) { + const row = tableContent.get(index); + if (!row || !isCRDTMap(row)) { + continue; + } + const rowId = getStringProp(row, "id"); + if (rowId) { + rowIds.push(rowId); + } + } + return rowIds; + } + + private _normalizeColumnIdList( + values: string[], + columnIdSet: Set, + ): string[] { + const seen = new Set(); + return values.filter((value) => { + if (!columnIdSet.has(value) || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }); + } + + private _normalizeSort( + sorts: DatabaseSort[], + columnIdSet: Set, + ): DatabaseSort[] { + const seen = new Set(); + return sorts.filter((sort) => { + if ( + !columnIdSet.has(sort.columnId) || + (sort.direction !== "asc" && sort.direction !== "desc") || + seen.has(sort.columnId) + ) { + return false; + } + seen.add(sort.columnId); + return true; + }); + } + + private _normalizeRowPinning( + rowPinning: DatabaseRowPinning, + rowIdSet: Set, + ): DatabaseRowPinning { + const top = this._normalizeUniqueIds(rowPinning.top ?? [], rowIdSet); + const bottom = this._normalizeUniqueIds( + (rowPinning.bottom ?? []).filter((rowId) => !top.includes(rowId)), + rowIdSet, + ); + return { + ...(top.length > 0 ? { top } : {}), + ...(bottom.length > 0 ? { bottom } : {}), + }; + } + + private _normalizeUniqueIds(values: string[], allowed: Set): string[] { + const seen = new Set(); + return values.filter((value) => { + if (!allowed.has(value) || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }); + } + + private _normalizeFilterGroup( + group: FilterGroup, + columnIdSet: Set, + ): FilterGroup | null { + const conditions: Array = + group.conditions.flatMap((condition) => { + if (this._isFilterGroup(condition)) { + const nestedGroup = this._normalizeFilterGroup(condition, columnIdSet); + return nestedGroup ? [nestedGroup] : []; + } + return this._isValidFilterCondition(condition, columnIdSet) + ? [condition] + : []; + }); + + if (conditions.length === 0) { + return null; + } + + return { + operator: group.operator === "or" ? "or" : "and", + conditions, + }; + } + + private _isFilterGroup( + value: FilterCondition | FilterGroup, + ): value is FilterGroup { + return Array.isArray((value as FilterGroup).conditions); + } + + private _isValidFilterCondition( + condition: FilterCondition, + columnIdSet: Set, + ): boolean { + return columnIdSet.has(condition.columnId); + } +} diff --git a/packages/core/src/editor/documentSession.ts b/packages/core/src/editor/documentSession.ts index b9f5580..c762976 100644 --- a/packages/core/src/editor/documentSession.ts +++ b/packages/core/src/editor/documentSession.ts @@ -2,10 +2,8 @@ import type { Awareness, CRDTAdapter, CRDTDocument, - CRDTEvent, CreateSubdocumentOptions, DocumentScope, - DocumentScopeInfo, DocumentScopeLookupOptions, DocumentScopeReplacementEvent, DocumentSessionAttachOptions, @@ -23,25 +21,23 @@ import { type YjsDoc, type YjsCRDTDocument, } from "@pen/crdt-yjs"; - -type ScopeListener = (event: CRDTEvent) => void; -type ScopeReplacementListener = (event: DocumentScopeReplacementEvent) => void; - -type ScopeReplacementTarget = { - previousScope: DocumentScope; - ownerPath: string[]; -}; - -type ScopeEntry = { - scope: DocumentScope; - awareness: Awareness | null; - observerUnsub: Unsubscribe; - subdocsHandler: ((event: { - added: Set; - loaded: Set; - removed: Set; - }) => void) | null; -}; +import { + cloneScope, + collectReplacementTargets, + emitScopeEvent, + findExistingScopeId, + findRegisteredScopeForBlock, + getDocumentGuid, + indexOwnerScope, + removeOwnerIndex, + resolveReplacementScope, + toOwnerKey, + toScopeId, + toScopeInfo, + type ScopeEntry, + type ScopeListener, + type ScopeReplacementListener, +} from "./documentSessionHelpers"; export interface CreateDocumentSessionOptions { adapter: CRDTAdapter; @@ -50,34 +46,6 @@ export interface CreateDocumentSessionOptions { ownsDocuments?: boolean; } -function getDocumentGuid(doc: YjsDoc): string { - const guid = (doc as YjsDoc & { guid?: string }).guid; - return typeof guid === "string" && guid.length > 0 - ? guid - : `doc-${doc.clientID}`; -} - -function toScopeId(doc: CRDTDocument): string { - if (!isYjsCRDTDocument(doc)) { - return `scope-${Math.random().toString(36).slice(2)}`; - } - return getDocumentGuid(doc.ydoc); -} - -function cloneScope(scope: DocumentScope): DocumentScope { - return { ...scope }; -} - -function toScopeInfo(scope: DocumentScope): DocumentScopeInfo { - return { - id: scope.id, - guid: scope.guid, - kind: scope.kind, - parentId: scope.parentId, - ownerBlockId: scope.ownerBlockId, - }; -} - export class DocumentSessionImpl implements DocumentSession { readonly adapter: CRDTAdapter; readonly rootScope: DocumentScope; @@ -121,12 +89,17 @@ export class DocumentSessionImpl implements DocumentSession { const scopeId = options?.scopeId; if (scopeId) { const ownedScopeId = this._scopeIdsByOwnerKey.get( - this._toOwnerKey(scopeId, blockId), + toOwnerKey(scopeId, blockId), ); if (ownedScopeId) { return this._getScope(ownedScopeId); } - return this._findRegisteredScopeForBlock(scopeId, blockId); + const parentEntry = this._getScopeEntry(scopeId); + return parentEntry + ? findRegisteredScopeForBlock(parentEntry.scope.doc, blockId, (guid) => + this.getScopeByGuid(guid), + ) + : null; } let match: DocumentScope | null = null; @@ -240,7 +213,10 @@ export class DocumentSessionImpl implements DocumentSession { const previousDoc = entry.scope.doc; const previousGuid = entry.scope.guid; - const replacementTargets = this._collectReplacementTargets(scopeId); + const replacementTargets = collectReplacementTargets( + entry.scope, + this._scopes.values(), + ); this._removeDescendantScopes(scopeId); entry.observerUnsub(); @@ -275,7 +251,12 @@ export class DocumentSessionImpl implements DocumentSession { for (const target of replacementTargets) { const event: DocumentScopeReplacementEvent = { previousScope: toScopeInfo(target.previousScope), - scope: this._resolveReplacementScope(scopeId, target.ownerPath), + scope: resolveReplacementScope( + this.getScope(scopeId) ?? cloneScope(this.rootScope), + target.ownerPath, + (ownerBlockId, currentScopeId) => + this.getScopeForBlock(ownerBlockId, { scopeId: currentScopeId }), + ), }; for (const listener of this._scopeReplacementListeners) { listener(event); @@ -333,14 +314,14 @@ export class DocumentSessionImpl implements DocumentSession { doc: CRDTDocument, location: { parentId: string | null; ownerBlockId: string | null }, ): DocumentScope { - const existingId = this._findExistingScopeId(doc); + const existingId = findExistingScopeId(doc, this._scopes.entries()); if (existingId) { const existing = this._scopes.get(existingId); if (existing) { - this._removeOwnerIndex(existing.scope); + removeOwnerIndex(this._scopeIdsByOwnerKey, existing.scope); existing.scope.parentId = location.parentId; existing.scope.ownerBlockId = location.ownerBlockId; - this._indexOwnerScope(existing.scope); + indexOwnerScope(this._scopeIdsByOwnerKey, existing.scope); return cloneScope(existing.scope); } } @@ -366,7 +347,7 @@ export class DocumentSessionImpl implements DocumentSession { this._scopes.set(scopeId, entry); this._guidToScopeId.set(scope.guid, scope.id); - this._indexOwnerScope(scope); + indexOwnerScope(this._scopeIdsByOwnerKey, scope); if (isYjsCRDTDocument(doc)) { this._attachSubdocumentDiscovery(entry, doc); @@ -443,7 +424,7 @@ export class DocumentSessionImpl implements DocumentSession { if (entry.subdocsHandler && isYjsCRDTDocument(entry.scope.doc)) { entry.scope.doc.ydoc.off("subdocs", entry.subdocsHandler); } - this._removeOwnerIndex(entry.scope); + removeOwnerIndex(this._scopeIdsByOwnerKey, entry.scope); this._scopes.delete(scopeId); this._guidToScopeId.delete(guid); this._listenersByScope.delete(scopeId); @@ -463,110 +444,6 @@ export class DocumentSessionImpl implements DocumentSession { } } - private _collectReplacementTargets(scopeId: string): ScopeReplacementTarget[] { - const rootEntry = this._getScopeEntry(scopeId); - if (!rootEntry) { - return []; - } - - const targets: ScopeReplacementTarget[] = []; - const walk = (currentScope: DocumentScope, ownerPath: string[]) => { - targets.push({ - previousScope: cloneScope(currentScope), - ownerPath: [...ownerPath], - }); - - const childScopes = Array.from(this._scopes.values()) - .filter((entry) => entry.scope.parentId === currentScope.id) - .map((entry) => cloneScope(entry.scope)); - - for (const childScope of childScopes) { - if (childScope.ownerBlockId == null) { - walk(childScope, ownerPath); - continue; - } - walk(childScope, [...ownerPath, childScope.ownerBlockId]); - } - }; - - walk(cloneScope(rootEntry.scope), []); - return targets; - } - - private _resolveReplacementScope( - scopeId: string, - ownerPath: readonly string[], - ): DocumentScope { - let resolvedScope = this.getScope(scopeId) ?? cloneScope(this.rootScope); - for (const ownerBlockId of ownerPath) { - const childScope = this.getScopeForBlock(ownerBlockId, { - scopeId: resolvedScope.id, - }); - if (!childScope) { - break; - } - resolvedScope = childScope; - } - return resolvedScope; - } - - private _emit(scope: DocumentScope, event: CRDTEvent): void { - const scopedEvent: CRDTEvent = { - ...event, - scope: { - id: scope.id, - guid: scope.guid, - kind: scope.kind, - parentId: scope.parentId, - ownerBlockId: scope.ownerBlockId, - }, - }; - - const scopeListeners = this._listenersByScope.get(scope.id); - if (scopeListeners) { - for (const listener of scopeListeners) { - listener(scopedEvent); - } - } - - for (const listener of this._allListeners) { - listener(scopedEvent); - } - } - - private _findExistingScopeId(doc: CRDTDocument): string | null { - for (const [scopeId, entry] of this._scopes.entries()) { - if (entry.scope.doc === doc) { - return scopeId; - } - if ( - isYjsCRDTDocument(entry.scope.doc) && - isYjsCRDTDocument(doc) && - entry.scope.doc.ydoc === doc.ydoc - ) { - return scopeId; - } - } - return null; - } - - private _findRegisteredScopeForBlock( - scopeId: string, - blockId: string, - ): DocumentScope | null { - const parentEntry = this._getScopeEntry(scopeId); - if (!parentEntry || !isYjsCRDTDocument(parentEntry.scope.doc)) { - return null; - } - const subdoc = parentEntry.scope.doc.penDocument.blocks.get(blockId)?.get( - SUBDOCUMENT, - ); - if (!isYjsDoc(subdoc)) { - return null; - } - return this.getScopeByGuid(getDocumentGuid(subdoc)); - } - private _syncOwnedSubdocumentScopes( entry: ScopeEntry, blockIds?: Iterable, @@ -591,29 +468,6 @@ export class DocumentSessionImpl implements DocumentSession { } } - private _indexOwnerScope(scope: DocumentScope): void { - if (!scope.parentId || !scope.ownerBlockId) { - return; - } - this._scopeIdsByOwnerKey.set( - this._toOwnerKey(scope.parentId, scope.ownerBlockId), - scope.id, - ); - } - - private _removeOwnerIndex(scope: DocumentScope): void { - if (!scope.parentId || !scope.ownerBlockId) { - return; - } - this._scopeIdsByOwnerKey.delete( - this._toOwnerKey(scope.parentId, scope.ownerBlockId), - ); - } - - private _toOwnerKey(scopeId: string, blockId: string): string { - return `${scopeId}:${blockId}`; - } - private _getScope(scopeId: string): DocumentScope | null { const entry = this._getScopeEntry(scopeId); return entry ? cloneScope(entry.scope) : null; @@ -626,7 +480,12 @@ export class DocumentSessionImpl implements DocumentSession { private _createScopeObserver(entry: ScopeEntry): Unsubscribe { return this.adapter.observe(entry.scope.doc, (event) => { this._syncOwnedSubdocumentScopes(entry, event.affectedBlocks); - this._emit(entry.scope, event); + emitScopeEvent( + entry.scope, + event, + this._listenersByScope, + this._allListeners, + ); }); } } diff --git a/packages/core/src/editor/documentSessionHelpers.ts b/packages/core/src/editor/documentSessionHelpers.ts new file mode 100644 index 0000000..e1a074a --- /dev/null +++ b/packages/core/src/editor/documentSessionHelpers.ts @@ -0,0 +1,193 @@ +import type { + Awareness, + CRDTDocument, + CRDTEvent, + DocumentScope, + DocumentScopeInfo, + DocumentScopeReplacementEvent, + Unsubscribe, +} from "@pen/types"; +import { + SUBDOCUMENT, + isYjsCRDTDocument, + isYjsDoc, + type YjsDoc, +} from "@pen/crdt-yjs"; + +export type ScopeListener = (event: CRDTEvent) => void; +export type ScopeReplacementListener = ( + event: DocumentScopeReplacementEvent, +) => void; + +export type ScopeReplacementTarget = { + previousScope: DocumentScope; + ownerPath: string[]; +}; + +export type ScopeEntry = { + scope: DocumentScope; + awareness: Awareness | null; + observerUnsub: Unsubscribe; + subdocsHandler: ((event: { + added: Set; + loaded: Set; + removed: Set; + }) => void) | null; +}; + +export function getDocumentGuid(doc: YjsDoc): string { + const guid = (doc as YjsDoc & { guid?: string }).guid; + return typeof guid === "string" && guid.length > 0 + ? guid + : `doc-${doc.clientID}`; +} + +export function toScopeId(doc: CRDTDocument): string { + if (!isYjsCRDTDocument(doc)) { + return `scope-${Math.random().toString(36).slice(2)}`; + } + return getDocumentGuid(doc.ydoc); +} + +export function cloneScope(scope: DocumentScope): DocumentScope { + return { ...scope }; +} + +export function toScopeInfo(scope: DocumentScope): DocumentScopeInfo { + return { + id: scope.id, + guid: scope.guid, + kind: scope.kind, + parentId: scope.parentId, + ownerBlockId: scope.ownerBlockId, + }; +} + +export function collectReplacementTargets( + rootScope: DocumentScope, + entries: Iterable, +): ScopeReplacementTarget[] { + const allEntries = Array.from(entries); + const targets: ScopeReplacementTarget[] = []; + const walk = (currentScope: DocumentScope, ownerPath: string[]) => { + targets.push({ + previousScope: cloneScope(currentScope), + ownerPath: [...ownerPath], + }); + + const childScopes = allEntries + .filter((entry) => entry.scope.parentId === currentScope.id) + .map((entry) => cloneScope(entry.scope)); + + for (const childScope of childScopes) { + if (childScope.ownerBlockId == null) { + walk(childScope, ownerPath); + continue; + } + walk(childScope, [...ownerPath, childScope.ownerBlockId]); + } + }; + + walk(cloneScope(rootScope), []); + return targets; +} + +export function resolveReplacementScope( + initialScope: DocumentScope, + ownerPath: readonly string[], + getChildScope: ( + ownerBlockId: string, + currentScopeId: string, + ) => DocumentScope | null, +): DocumentScope { + let resolvedScope = cloneScope(initialScope); + for (const ownerBlockId of ownerPath) { + const childScope = getChildScope(ownerBlockId, resolvedScope.id); + if (!childScope) { + break; + } + resolvedScope = childScope; + } + return resolvedScope; +} + +export function emitScopeEvent( + scope: DocumentScope, + event: CRDTEvent, + listenersByScope: Map>, + allListeners: Set, +): void { + const scopedEvent: CRDTEvent = { + ...event, + scope: toScopeInfo(scope), + }; + + const scopeListeners = listenersByScope.get(scope.id); + if (scopeListeners) { + for (const listener of scopeListeners) { + listener(scopedEvent); + } + } + + for (const listener of allListeners) { + listener(scopedEvent); + } +} + +export function findExistingScopeId( + doc: CRDTDocument, + entries: Iterable<[string, ScopeEntry]>, +): string | null { + for (const [scopeId, entry] of entries) { + if (entry.scope.doc === doc) { + return scopeId; + } + if ( + isYjsCRDTDocument(entry.scope.doc) && + isYjsCRDTDocument(doc) && + entry.scope.doc.ydoc === doc.ydoc + ) { + return scopeId; + } + } + return null; +} + +export function findRegisteredScopeForBlock( + parentDoc: CRDTDocument, + blockId: string, + getScopeByGuid: (guid: string) => DocumentScope | null, +): DocumentScope | null { + if (!isYjsCRDTDocument(parentDoc)) { + return null; + } + const subdoc = parentDoc.penDocument.blocks.get(blockId)?.get(SUBDOCUMENT); + if (!isYjsDoc(subdoc)) { + return null; + } + return getScopeByGuid(getDocumentGuid(subdoc)); +} + +export function indexOwnerScope( + scopeIdsByOwnerKey: Map, + scope: DocumentScope, +): void { + if (!scope.parentId || !scope.ownerBlockId) { + return; + } + scopeIdsByOwnerKey.set(toOwnerKey(scope.parentId, scope.ownerBlockId), scope.id); +} + +export function removeOwnerIndex( + scopeIdsByOwnerKey: Map, + scope: DocumentScope, +): void { + if (!scope.parentId || !scope.ownerBlockId) { + return; + } + scopeIdsByOwnerKey.delete(toOwnerKey(scope.parentId, scope.ownerBlockId)); +} + +export function toOwnerKey(scopeId: string, blockId: string): string { + return `${scopeId}:${blockId}`; +} diff --git a/packages/core/src/editor/editor.ts b/packages/core/src/editor/editor.ts index 7abb732..6f71d15 100644 --- a/packages/core/src/editor/editor.ts +++ b/packages/core/src/editor/editor.ts @@ -1,47 +1,5 @@ -import type { - Editor, - EditorInternals, - CreateEditorOptions, - PenEventMap, - DocumentCommitEvent, - CRDTAdapter, - CRDTDocument, - CRDTEvent, - PenDocument, - SchemaRegistry, - Awareness, - DocumentSession, - DocumentScope, - DocumentScopeReplacementEvent, - DocumentProfile, - Extension, - DocumentOp, - ApplyOptions, - OpOrigin, - MutationGroupMetadata, - SelectionState, - TextSelection, - DocumentRange, - BlockHandle, - Block, - DocumentState, - UndoManager, - Unsubscribe, - CRDTMap, - CRDTArray, - Position, - DecorationSet, - EditorViewMode, -} from "@pen/types"; -import { - AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, - COLLECT_KEY_BINDINGS_SLOT_KEY, - usesInlineTextSelection, - createMutationGroupMetadata, - getApplyOptionsGroupId, - MUTATION_GROUP_METADATA_KEY, - UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, -} from "@pen/types"; +import type { Editor, EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; import { yjsAdapter } from "@pen/crdt-yjs"; import { undoExtension } from "@pen/undo"; import { documentOpsExtension } from "@pen/document-ops"; @@ -55,12 +13,7 @@ import { ApplyPipeline } from "./apply"; import { resolveCellSelectionMatrix } from "./cellSelection"; import { filterOpsForDocumentProfile } from "./profilePolicy"; import type { CRDTUnknownMap } from "./crdtShapes"; -import { - getTextProp, - getTableContent, - getCellText as getCellTextFromRow, - isCRDTMap, -} from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; import { ExtensionManagerImpl } from "./extensionManager"; import { SelectionManagerImpl } from "./selection"; import { DocumentStateImpl } from "./documentState"; @@ -68,39 +21,13 @@ import { emptyDecorationSet } from "./decorations"; import { DocumentRangeImpl } from "./range"; import { createDocumentSession } from "./documentSession"; +import { getRawBlockMap, getEditorInternals, applyEditorOps, recordMutationGroupMetadata, loadEditorDocument, iterateBlocks, getEditorBlock, getFirstBlock, getLastBlock, getBlockCount, getEditorBlockRevision, destroyEditor } from "./editorApiHelpers"; +import { createPenDocumentForEditor, resolveEditorExtensions, installProfilePolicyHook, enforceDocumentProfileBoundary, refreshCoreSlots, bindEditorSession, bindEditorScope, handleEditorScopeReplacement, resolveEditorDocumentProfile, rebindActiveScope, refreshUndoManager, activateEditorExtensions, queueExtensionLifecycle, ensureInitialParagraph, createCommitEvent, dispatchCRDTEvent, syncDocumentProfileFromStorage, wireEditorObservation, teardownEditorObservation } from "./editorLifecycle"; +import { replaceEditorSelection, deleteEditorSelection, getTextForBlock, getSelectionRange, usesInlineTextSelectionForBlock, getBlockSelectionSpan, isWholeBlockSelection, collapseToPoint, sliceInlineDeltas, buildMultiBlockTextReplacement, deleteMultiBlockTextRange, replaceMultiBlockTextRange } from "./editorSelectionMutations"; type CRDTBlockMap = CRDTMap>; -type RawPenDocumentLike = { - getArray?(name: "blockOrder"): CRDTArray; - getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; - blockOrder?: CRDTArray; - blocks?: CRDTMap; - apps?: CRDTMap; - metadata?: CRDTMap; -}; - -let hasWarnedAboutWithoutOption = false; - -function createGeneratedBlockId(): string { - return crypto.randomUUID(); -} - -function missingPenDocumentRoot(name: string): never { - throw new Error(`CRDT document is missing required Pen root "${name}".`); -} - // Stub undo manager for when @pen/undo is excluded -const NOOP_UNDO: UndoManager = { - undo: () => false, - redo: () => false, - canUndo: () => false, - canRedo: () => false, - stopCapturing: () => {}, - syncExplicitUndoGroup: () => {}, - setGroupTimeout: () => {}, - registerTrackedOrigins: () => () => {}, - onStackChange: () => () => {}, -}; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; class EditorImpl implements Editor { private readonly _adapter: CRDTAdapter; @@ -234,119 +161,17 @@ class EditorImpl implements Editor { return this._documentState; } - private _getRawBlockMap(blockId: string): CRDTUnknownMap | null { - const blockMap = (this._doc.blocks as CRDTBlockMap).get(blockId); - return (blockMap as unknown as CRDTUnknownMap) ?? null; - } + private _getRawBlockMap(blockId: string): CRDTUnknownMap | null { return getRawBlockMap(this, blockId); } - get internals(): EditorInternals { - return { - adapter: this._adapter, - crdtDoc: this._crdtDoc, - doc: this._doc, - engine: this._engine, - awareness: this._awareness, - documentSession: this._documentSession, - documentScope: this._documentScope, - viewId: this._viewId, - emit: (event, ...args) => { - this._emitter.emit(event, ...args); - }, - onApplyBoundary: (hook) => - this._pipeline.addApplyBoundaryHook(hook), - getSlot: (key: string): T | undefined => - this._slots.get(key) as T | undefined, - setSlot: (key: string, value: unknown): void => { - this._slots.set(key, value); - if (key === "undo:manager") { - this._refreshUndoManager(); - } - }, - getBlockText: (blockId: string): unknown => { - const blockMap = this._getRawBlockMap(blockId); - if (!blockMap) return null; - return getTextProp(blockMap, "content"); - }, - getCellText: ( - blockId: string, - row: number, - col: number, - ): unknown => { - const blockMap = this._getRawBlockMap(blockId); - if (!blockMap) return null; - const tableContent = getTableContent(blockMap); - if (!tableContent || row < 0 || row >= tableContent.length) - return null; - const rowMap = tableContent.get(row); - if (!rowMap || !isCRDTMap(rowMap)) return null; - return getCellTextFromRow(rowMap, col); - }, - }; - } + get internals(): EditorInternals { return getEditorInternals(this); } // ── Mutations ──────────────────────────────────────────── - apply(ops: DocumentOp[], options?: ApplyOptions): void { - const origin = options?.origin ?? "user"; - const groupId = getApplyOptionsGroupId(origin, options); - const undo = this._slots.get("undo:manager") as UndoManager | undefined; + apply(ops: DocumentOp[], options?: ApplyOptions): void { applyEditorOps(this, ops, options); } - undo?.syncExplicitUndoGroup(groupId ?? null); + private _recordMutationGroupMetadata(origin: OpOrigin, groupId: string | undefined): void { recordMutationGroupMetadata(this, origin, groupId); } - if (options?.undoGroup && !groupId) { - undo?.stopCapturing(); - } - - this._pipeline.apply(ops, origin); - this._recordMutationGroupMetadata(origin, groupId); - } - - private _recordMutationGroupMetadata( - origin: OpOrigin, - groupId: string | undefined, - ): void { - if (!groupId) { - return; - } - const controller = this._slots.get( - UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, - ) as - | { - setCurrentEntryMetadata( - key: string, - value: { before: T | null; after: T | null }, - ): boolean; - } - | undefined; - controller?.setCurrentEntryMetadata( - MUTATION_GROUP_METADATA_KEY, - { - before: null, - after: createMutationGroupMetadata(origin, groupId), - }, - ); - } - - loadDocument(doc: CRDTDocument): void { - this._queueExtensionLifecycle(async () => { - await this._extensions.deactivateAll(this); - if (this._isDestroyed) { - return; - } - this._teardownObservation(); - this._releaseSession?.(); - this._releaseSession = null; - this._bindSession( - createDocumentSession({ - adapter: this._adapter, - document: doc, - destroyWhenIdle: true, - ownsDocuments: false, - }), - ); - await this._rebindActiveScope(); - }); - } + loadDocument(doc: CRDTDocument): void { loadEditorDocument(this, doc); } onBeforeApply( hook: (ops: DocumentOp[], options: ApplyOptions) => DocumentOp[], @@ -360,56 +185,17 @@ class EditorImpl implements Editor { // ── Block Traversal ────────────────────────────────────── - *blocks(type?: string): Iterable { - for (let i = 0; i < this._doc.blockOrder.length; i++) { - const id = (this._doc.blockOrder as CRDTArray).get( - i, - ) as string; - if (type) { - const blockMap = (this._doc.blocks as CRDTBlockMap).get(id); - if (!blockMap || blockMap.get("type") !== type) continue; - } - yield createBlockHandle( - id, - this._doc, - this._crdtDoc, - this._registry, - ); - } - } + *blocks(type?: string): Iterable { yield* iterateBlocks(this, type); } - getBlock(blockId: string): BlockHandle | null { - if (!(this._doc.blocks as CRDTBlockMap).has(blockId)) return null; - return createBlockHandle( - blockId, - this._doc, - this._crdtDoc, - this._registry, - ); - } + getBlock(blockId: string): BlockHandle | null { return getEditorBlock(this, blockId); } - firstBlock(): BlockHandle | null { - if (this._doc.blockOrder.length === 0) return null; - const id = (this._doc.blockOrder as CRDTArray).get(0) as string; - return createBlockHandle(id, this._doc, this._crdtDoc, this._registry); - } + firstBlock(): BlockHandle | null { return getFirstBlock(this); } - lastBlock(): BlockHandle | null { - const len = this._doc.blockOrder.length; - if (len === 0) return null; - const id = (this._doc.blockOrder as CRDTArray).get( - len - 1, - ) as string; - return createBlockHandle(id, this._doc, this._crdtDoc, this._registry); - } + lastBlock(): BlockHandle | null { return getLastBlock(this); } - blockCount(): number { - return this._doc.blockOrder.length; - } + blockCount(): number { return getBlockCount(this); } - getBlockRevision(blockId: string): number { - return this._blockRevisions.get(blockId) ?? 0; - } + getBlockRevision(blockId: string): number { return getEditorBlockRevision(this, blockId); } // ── Selection ──────────────────────────────────────────── @@ -464,210 +250,9 @@ class EditorImpl implements Editor { return this._selection.getSelectedBlocks(); } - replaceSelection(content: string | Block[]): void { - const sel = this._selection.getSelection(); - if (!sel) return; - - if (sel.type === "text") { - const range = this._getSelectionRange(sel); - if (range.isMultiBlock) { - if (typeof content === "string") { - this._replaceMultiBlockTextRange(range, content); - } - return; - } - - const from = range.start.offset; - const to = range.end.offset; - const ops: DocumentOp[] = []; - if (to > from) { - ops.push({ - type: "delete-text", - blockId: range.start.blockId, - offset: from, - length: to - from, - }); - } - if (typeof content === "string" && content.length > 0) { - ops.push({ - type: "insert-text", - blockId: range.start.blockId, - offset: from, - text: content, - }); - } - if (ops.length > 0) { - this.apply(ops); - } - const nextOffset = - typeof content === "string" ? from + content.length : from; - this._collapseToPoint({ - blockId: range.start.blockId, - offset: nextOffset, - }); - return; - } - - if (sel.type === "block" && sel.blockIds.length > 0) { - const firstId = sel.blockIds[0]; - const firstIndex = this._pipeline._resolvePosition({ - before: firstId, - }); - const ops: DocumentOp[] = []; - - for (const id of sel.blockIds) { - ops.push({ type: "delete-block", blockId: id }); - } - - const insertPosition: Position = - firstIndex === 0 - ? "first" - : { - after: ( - this._doc.blockOrder as CRDTArray - ).get(firstIndex - 1) as string, - }; - - if (typeof content === "string") { - const newId = createGeneratedBlockId(); - ops.push({ - type: "insert-block", - blockId: newId, - blockType: "paragraph", - props: {}, - position: insertPosition, - }); - if (content.length > 0) { - ops.push({ - type: "insert-text", - blockId: newId, - offset: 0, - text: content, - }); - } - } else if (Array.isArray(content)) { - let prevPosition = insertPosition; - for (const block of content) { - const newId = createGeneratedBlockId(); - ops.push({ - type: "insert-block", - blockId: newId, - blockType: block.type, - props: block.props ?? {}, - position: prevPosition, - }); - if ( - typeof block.content === "string" && - block.content.length > 0 - ) { - ops.push({ - type: "insert-text", - blockId: newId, - offset: 0, - text: block.content, - }); - } - prevPosition = { after: newId }; - } - } - - this.apply(ops); - } - } - - deleteSelection(options?: ApplyOptions): void { - const sel = this._selection.getSelection(); - if (!sel) return; - - if (sel.type === "text") { - const range = this._getSelectionRange(sel); - if (range.isMultiBlock) { - this._deleteMultiBlockTextRange(range, options); - return; - } - - if ( - !this._usesInlineTextSelection(range.start.blockId) && - this._isWholeBlockSelection( - range.start.blockId, - range.start.offset, - range.end.offset, - ) - ) { - this.apply( - [ - { - type: "delete-block", - blockId: range.start.blockId, - }, - ], - options, - ); - this.setSelection(null); - return; - } - - const from = range.start.offset; - const to = range.end.offset; - if (to > from) { - this.apply( - [ - { - type: "delete-text", - blockId: range.start.blockId, - offset: from, - length: to - from, - }, - ], - options, - ); - } - this._collapseToPoint({ - blockId: range.start.blockId, - offset: from, - }); - return; - } + replaceSelection(content: string | Block[]): void { replaceEditorSelection(this, content); } - if (sel.type === "block") { - const ops: DocumentOp[] = sel.blockIds.map((id) => ({ - type: "delete-block" as const, - blockId: id, - })); - this.apply(ops, options); - this.setSelection(null); - } - - if (sel.type === "cell") { - const block = this.getBlock(sel.blockId); - if (!block) return; - const ops: DocumentOp[] = []; - for (const rowCells of resolveCellSelectionMatrix(block, sel)) { - for (const cellCoord of rowCells) { - const cell = block.tableCell(cellCoord.row, cellCoord.col); - if (!cell) continue; - const len = cell.length(); - if (len > 0) { - ops.push({ - type: "delete-table-cell-text", - blockId: sel.blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: 0, - length: len, - } as DocumentOp); - } - } - } - if (ops.length > 0) { - this.apply(ops, options); - } - this.setSelection({ - ...sel, - head: sel.anchor, - }); - } - } + deleteSelection(options?: ApplyOptions): void { deleteEditorSelection(this, options); } // ── Decorations ────────────────────────────────────────── @@ -725,609 +310,68 @@ class EditorImpl implements Editor { // ── Destroy ────────────────────────────────────────────── - destroy(): void { - if (this._isDestroyed) { - return; - } - this._isDestroyed = true; - this._queueExtensionLifecycle(async () => { - await this._extensions.deactivateAll(this); - this._teardownObservation(); - this._releaseSession?.(); - this._releaseSession = null; - this._emitter.removeAllListeners(); - }); - } + destroy(): void { destroyEditor(this); } // ── Private ────────────────────────────────────────────── - private _createPenDocument(crdtDoc: CRDTDocument): PenDocument { - const wrapped = crdtDoc as CRDTDocument & { penDocument?: PenDocument }; - if (wrapped.penDocument) { - return wrapped.penDocument; - } - - const raw = this._adapter.raw(crdtDoc); - const blockOrder = - (raw.getArray ? raw.getArray("blockOrder") : raw.blockOrder) ?? - missingPenDocumentRoot("blockOrder"); - const blocks = - (raw.getMap ? raw.getMap("blocks") : raw.blocks) ?? - missingPenDocumentRoot("blocks"); - const apps = - (raw.getMap ? raw.getMap("apps") : raw.apps) ?? - missingPenDocumentRoot("apps"); - const metadata = - (raw.getMap ? raw.getMap("metadata") : raw.metadata) ?? - missingPenDocumentRoot("metadata"); - return { - blockOrder, - blocks, - apps, - metadata, - adapter: this._adapter, - }; - } - - private _resolveExtensions(options: CreateEditorOptions): Extension[] { - const without = new Set(options.without ?? []); - if (without.size > 0 && !hasWarnedAboutWithoutOption) { - hasWarnedAboutWithoutOption = true; - console.warn( - "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", - ); - } - const defaultExtensions = options.preset?.resolve({ - schema: this._registry, - documentProfile: this._documentProfile, - }).extensions ?? [ - documentOpsExtension(), - deltaStreamExtension(), - undoExtension(), - richTextShortcutsExtension(), - ]; - const defaults = defaultExtensions.filter( - (ext) => !without.has(ext.name), - ); - - const userExtensions = options.extensions ?? []; - return [...defaults, ...userExtensions]; - } - - private _installProfilePolicyHook(): void { - this._pipeline.setFinalBeforeApplyHook((ops) => - this._enforceDocumentProfileBoundary(ops), - ); - } - - private _enforceDocumentProfileBoundary(ops: DocumentOp[]): DocumentOp[] { - const result = filterOpsForDocumentProfile( - ops, - this._documentProfile, - this._registry, - ); - - for (const violation of result.violations) { - this._emitter.emit("diagnostic", { - code: "PEN_PROFILE_001", - level: "warn", - source: "profile-policy", - message: - `profile-policy: dropped ${violation.op.type} for disallowed ` + - `block type "${violation.blockType}" in ${violation.documentProfile} documents`, - remediation: - "Use a block type allowed by the active documentProfile or " + - "change the documentProfile before applying structural mutations.", - op: violation.op, - blockType: violation.blockType, - documentProfile: violation.documentProfile, - }); - } - - return result.ops; - } - - private _refreshCoreSlots(): void { - this._slots.set("core:engine", this._engine); - this._slots.set( - AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, - () => this._extensionLifecycle, - ); - this._slots.set( - COLLECT_KEY_BINDINGS_SLOT_KEY, - (registry: SchemaRegistry) => - this._extensions.collectKeyBindings(registry), - ); - } - - private _bindSession(session: DocumentSession, scopeId?: string): void { - this._bindScope(session, scopeId); - this._releaseSession = session.attachEditor({ - onScopeReplaced: (event) => { - this._handleScopeReplacement(session, event); - }, - }); - } - - private _bindScope(session: DocumentSession, scopeId?: string): void { - this._documentSession = session; - const scope = - (scopeId ? session.getScope(scopeId) : null) ?? session.rootScope; - this._documentScope = scope; - this._crdtDoc = scope.doc; - this._doc = this._createPenDocument(scope.doc); - this._awareness = session.getAwareness(scope.id); - } - - private _handleScopeReplacement( - session: DocumentSession, - event: DocumentScopeReplacementEvent, - ): void { - if (event.previousScope.id !== this._documentScope.id) { - return; - } - this._queueExtensionLifecycle(async () => { - await this._extensions.deactivateAll(this); - if (this._isDestroyed) { - return; - } - this._teardownObservation(); - this._bindScope(session, event.scope.id); - await this._rebindActiveScope(); - }); - } - - private _resolveDocumentProfile( - requestedProfile?: DocumentProfile, - ): DocumentProfile { - const persistedProfile = - this._adapter.getDocumentProfile?.(this._crdtDoc) ?? null; - const resolvedProfile = - persistedProfile ?? requestedProfile ?? "structured"; - if (persistedProfile == null) { - this._adapter.setDocumentProfile?.(this._crdtDoc, resolvedProfile); - } - return resolvedProfile; - } - - private async _rebindActiveScope(): Promise { - this._documentProfile = this._resolveDocumentProfile(); - this._editorViewMode = - this._explicitEditorViewMode ?? this._documentProfile; - this._clientId = this._adapter.getClientId(this._crdtDoc); - - this._engine = new SchemaEngineImpl( - this._registry, - this._doc, - this._crdtDoc, - ); - this._selection.updateDocument(this._doc, this._crdtDoc); - this._pipeline.updateDocument(this._doc, this._crdtDoc, this._engine); - this._documentState.updateDocument( - this._doc, - this._crdtDoc, - this._documentProfile, - ); - this._pipeline._init((event) => { - this._dispatchCRDTEvent(event); - }); - this._refreshCoreSlots(); + private _createPenDocument(crdtDoc: CRDTDocument): PenDocument { return createPenDocumentForEditor(this, crdtDoc); } - this._wireObservation(); - await this._activateExtensions(); - this._engine.normalizeAll(); - this._refreshDecorations(); - } + private _resolveExtensions(options: CreateEditorOptions): Extension[] { return resolveEditorExtensions(this, options); } - private _refreshUndoManager(): void { - const slotUndo = this._slots.get("undo:manager") as - | UndoManager - | undefined; - (this as { undoManager: UndoManager }).undoManager = - slotUndo ?? NOOP_UNDO; - } + private _installProfilePolicyHook(): void { installProfilePolicyHook(this); } - private async _activateExtensions(): Promise { - const activation = this._extensions.activateAll(this); - this._refreshUndoManager(); - await activation; - this._refreshUndoManager(); - } + private _enforceDocumentProfileBoundary(ops: DocumentOp[]): DocumentOp[] { return enforceDocumentProfileBoundary(this, ops); } - private _queueExtensionLifecycle(task: () => Promise): void { - const runTask = async (): Promise => { - try { - await task(); - } catch (error) { - if (this._isDestroyed) { - return; - } - this._emitter.emit("diagnostic", { - code: "PEN_EXT_006", - level: "error", - source: "extension", - message: "Editor extension lifecycle transition failed", - remediation: - "Inspect async extension activate/deactivate hooks involved in document reload or scope replacement and ensure they resolve safely.", - error, - }); - } - }; - - this._extensionLifecycle = this._extensionLifecycle.then( - runTask, - runTask, - ); - } + private _refreshCoreSlots(): void { refreshCoreSlots(this); } - private _ensureInitialParagraph(): void { - if (this._doc.blockOrder.length > 0) { - return; - } + private _bindSession(session: DocumentSession, scopeId?: string): void { bindEditorSession(this, session, scopeId); } - this.apply( - [ - { - type: "insert-block", - blockId: createGeneratedBlockId(), - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - } + private _bindScope(session: DocumentSession, scopeId?: string): void { bindEditorScope(this, session, scopeId); } - private _createCommitEvent(event: CRDTEvent): DocumentCommitEvent { - const blockRevisions: Record = {}; - for (const blockId of event.affectedBlocks) { - const nextRevision = (this._blockRevisions.get(blockId) ?? 0) + 1; - this._blockRevisions.set(blockId, nextRevision); - blockRevisions[blockId] = nextRevision; - } - this._commitId += 1; - return { - commitId: this._commitId, - ops: event.ops, - origin: event.origin, - affectedBlocks: [...event.affectedBlocks], - blockRevisions, - scope: this._documentScope, - }; - } + private _handleScopeReplacement(session: DocumentSession, event: DocumentScopeReplacementEvent): void { handleEditorScopeReplacement(this, session, event); } - private _dispatchCRDTEvent(event: CRDTEvent): void { - this._syncDocumentProfileFromStorage(); - const commitEvent = this._createCommitEvent(event); - this._documentState.incrementalUpdate(event.affectedBlocks); - this._extensions.dispatchObserve([event], this); - const previousDecorationGeneration = this._decorations.generation; - const nextDecorations = this._refreshDecorations(); - if (nextDecorations.generation !== previousDecorationGeneration) { - this._emitter.emit("decorationsChange", nextDecorations.generation); - } - this._emitter.emit("change", [event]); - this._emitter.emit("documentCommit", commitEvent); - } + private _resolveDocumentProfile(requestedProfile?: DocumentProfile): DocumentProfile { return resolveEditorDocumentProfile(this, requestedProfile); } - private _syncDocumentProfileFromStorage(): void { - const persistedProfile = - this._adapter.getDocumentProfile?.(this._crdtDoc) ?? null; - if (!persistedProfile || persistedProfile === this._documentProfile) { - return; - } + private async _rebindActiveScope(): Promise { await rebindActiveScope(this); } - this._documentProfile = persistedProfile; - if (this._explicitEditorViewMode == null) { - this._editorViewMode = persistedProfile; - } - this._documentState.setDocumentProfile(persistedProfile); - } + private _refreshUndoManager(): void { refreshUndoManager(this); } - private _wireObservation(): void { - if (this._documentSession) { - this._unsubObserve = this._documentSession.observe( - this._documentScope.id, - (event: CRDTEvent) => { - if (this._pipeline.suppressObserver) return; - this._dispatchCRDTEvent(event); - }, - ); - return; - } + private async _activateExtensions(): Promise { await activateEditorExtensions(this); } - this._unsubObserve = this._adapter.observe( - this._crdtDoc, - (event: CRDTEvent) => { - if (this._pipeline.suppressObserver) return; - this._dispatchCRDTEvent(event); - }, - ); - } + private _queueExtensionLifecycle(task: () => Promise): void { queueExtensionLifecycle(this, task); } - private _teardownObservation(): void { - if (this._unsubObserve) { - this._unsubObserve(); - this._unsubObserve = null; - } - } + private _ensureInitialParagraph(): void { ensureInitialParagraph(this); } - private _getTextForBlock(blockId: string): string { - return this.getBlock(blockId)?.textContent() ?? ""; - } + private _createCommitEvent(event: CRDTEvent): DocumentCommitEvent { return createCommitEvent(this, event); } - private _getSelectionRange(sel: TextSelection): DocumentRange { - return sel.toRange(); - } + private _dispatchCRDTEvent(event: CRDTEvent): void { dispatchCRDTEvent(this, event); } - private _usesInlineTextSelection(blockId: string): boolean { - const block = this.getBlock(blockId); - if (!block) { - return false; - } + private _syncDocumentProfileFromStorage(): void { syncDocumentProfileFromStorage(this); } - const schema = this._registry.resolve(block.type); - if (!schema) { - return false; - } + private _wireObservation(): void { wireEditorObservation(this); } - return usesInlineTextSelection(schema); - } + private _teardownObservation(): void { teardownEditorObservation(this); } - private _getBlockSelectionSpan(blockId: string): number { - if (this._usesInlineTextSelection(blockId)) { - return this._getTextForBlock(blockId).length; - } - return this.getBlock(blockId) ? 1 : 0; - } - - private _isWholeBlockSelection( - blockId: string, - startOffset: number, - endOffset: number, - ): boolean { - const span = this._getBlockSelectionSpan(blockId); - if (span <= 0) { - return false; - } - return startOffset <= 0 && endOffset >= span; - } - - private _collapseToPoint(point: { blockId: string; offset: number }): void { - this.selectTextRange(point, point); - } - - private _sliceInlineDeltas( - blockId: string, - startOffset: number, - ): Array<{ insert: string; attributes?: Record }> { - const handle = this.getBlock(blockId); - if (!handle) { - return []; - } - - const deltas = handle - .textDeltas() - .filter((delta) => delta.insert !== "\u200B"); - const sliced: Array<{ - insert: string; - attributes?: Record; - }> = []; - let offset = 0; - - for (const delta of deltas) { - const length = delta.insert.length; - if (startOffset >= offset + length) { - offset += length; - continue; - } - - const localStart = Math.max(0, startOffset - offset); - const text = delta.insert.slice(localStart); - if (text.length > 0) { - sliced.push({ - insert: text, - ...(delta.attributes - ? { attributes: delta.attributes } - : {}), - }); - } - offset += length; - } - - return sliced; - } - - private _buildMultiBlockTextReplacement( - range: DocumentRange, - insertedText: string, - ): { ops: DocumentOp[]; caret: { blockId: string; offset: number } } { - const startId = range.start.blockId; - const endId = range.end.blockId; - const startText = this._getTextForBlock(startId); - const middleIds = range.blockRange.slice(1, -1); - const suffixDeltas = this._sliceInlineDeltas(endId, range.end.offset); - const ops: DocumentOp[] = []; - - if (range.start.offset < startText.length) { - ops.push({ - type: "delete-text", - blockId: startId, - offset: range.start.offset, - length: startText.length - range.start.offset, - }); - } - - if (range.end.offset > 0) { - ops.push({ - type: "delete-text", - blockId: endId, - offset: 0, - length: range.end.offset, - }); - } - - for (const blockId of middleIds) { - ops.push({ - type: "delete-block", - blockId, - }); - } + private _getTextForBlock(blockId: string): string { return getTextForBlock(this, blockId); } - let insertionOffset = range.start.offset; - if (insertedText.length > 0) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: insertedText, - }); - insertionOffset += insertedText.length; - } + private _getSelectionRange(sel: TextSelection): DocumentRange { return getSelectionRange(this, sel); } - for (const delta of suffixDeltas) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: delta.insert, - marks: delta.attributes, - }); - insertionOffset += delta.insert.length; - } + private _usesInlineTextSelection(blockId: string): boolean { return usesInlineTextSelectionForBlock(this, blockId); } - ops.push({ - type: "delete-block", - blockId: endId, - }); + private _getBlockSelectionSpan(blockId: string): number { return getBlockSelectionSpan(this, blockId); } - return { - ops, - caret: { - blockId: startId, - offset: range.start.offset + insertedText.length, - }, - }; - } - - private _deleteMultiBlockTextRange( - range: DocumentRange, - options?: ApplyOptions, - ): { blockId: string; offset: number } | null { - const startId = range.start.blockId; - const endId = range.end.blockId; - if (startId === endId) { - const from = range.start.offset; - const to = range.end.offset; - if (to > from) { - this.apply( - [ - { - type: "delete-text", - blockId: startId, - offset: from, - length: to - from, - }, - ], - options, - ); - } - const caret = { blockId: startId, offset: from }; - this._collapseToPoint(caret); - return caret; - } - - const startInline = this._usesInlineTextSelection(startId); - const endInline = this._usesInlineTextSelection(endId); - if (startInline && endInline) { - const { ops, caret } = this._buildMultiBlockTextReplacement( - range, - "", - ); - this.apply(ops, options); - this._collapseToPoint(caret); - return caret; - } + private _isWholeBlockSelection(blockId: string, startOffset: number, endOffset: number): boolean { return isWholeBlockSelection(this, blockId, startOffset, endOffset); } - const middleIds = range.blockRange.slice(1, -1); - const ops: DocumentOp[] = []; - - if (startInline) { - const startText = this._getTextForBlock(startId); - if (range.start.offset < startText.length) { - ops.push({ - type: "delete-text", - blockId: startId, - offset: range.start.offset, - length: startText.length - range.start.offset, - }); - } - } else if ( - this._isWholeBlockSelection( - startId, - range.start.offset, - this._getBlockSelectionSpan(startId), - ) - ) { - ops.push({ - type: "delete-block", - blockId: startId, - }); - } + private _collapseToPoint(point: { blockId: string; offset: number }): void { return collapseToPoint(this, point); } - for (const blockId of middleIds) { - ops.push({ - type: "delete-block", - blockId, - }); - } + private _sliceInlineDeltas(blockId: string, startOffset: number): Array<{ insert: string; attributes?: Record }> { return sliceInlineDeltas(this, blockId, startOffset); } - if (endInline) { - if (range.end.offset > 0) { - ops.push({ - type: "delete-text", - blockId: endId, - offset: 0, - length: range.end.offset, - }); - } - } else if (this._isWholeBlockSelection(endId, 0, range.end.offset)) { - ops.push({ - type: "delete-block", - blockId: endId, - }); - } + private _buildMultiBlockTextReplacement(range: DocumentRange, insertedText: string): { ops: DocumentOp[]; caret: { blockId: string; offset: number } } { return buildMultiBlockTextReplacement(this, range, insertedText); } - if (ops.length > 0) { - this.apply(ops, options); - } + private _deleteMultiBlockTextRange(range: DocumentRange, options?: ApplyOptions): { blockId: string; offset: number } | null { return deleteMultiBlockTextRange(this, range, options); } - const caret = startInline - ? { blockId: startId, offset: range.start.offset } - : endInline - ? { blockId: endId, offset: 0 } - : null; - if (caret) { - this._collapseToPoint(caret); - } else { - this.setSelection(null); - } - return caret; - } + private _replaceMultiBlockTextRange(range: DocumentRange, text: string): { blockId: string; offset: number } { return replaceMultiBlockTextRange(this, range, text); } - private _replaceMultiBlockTextRange( - range: DocumentRange, - text: string, - ): { blockId: string; offset: number } { - const { ops, caret } = this._buildMultiBlockTextReplacement( - range, - text, - ); - this.apply(ops); - this._collapseToPoint(caret); - return caret; - } } export function createEditor(options?: CreateEditorOptions): Editor { diff --git a/packages/core/src/editor/editorApiHelpers.ts b/packages/core/src/editor/editorApiHelpers.ts new file mode 100644 index 0000000..e1166cb --- /dev/null +++ b/packages/core/src/editor/editorApiHelpers.ts @@ -0,0 +1,212 @@ +import type { EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; +import { undoExtension } from "@pen/undo"; +import { documentOpsExtension } from "@pen/document-ops"; +import { deltaStreamExtension } from "@pen/delta-stream"; +import { richTextShortcutsExtension } from "@pen/shortcuts"; +import { SchemaEngineImpl } from "../schema/normalize"; +import { createBlockHandle } from "../schema/handles"; +import { resolveCellSelectionMatrix } from "./cellSelection"; +import { filterOpsForDocumentProfile } from "./profilePolicy"; +import type { CRDTUnknownMap } from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { DocumentStateImpl } from "./documentState"; +import { createDocumentSession } from "./documentSession"; + +type EditorImplRuntime = any; +type CRDTBlockMap = CRDTMap>; +type RawPenDocumentLike = { getArray?(name: "blockOrder"): CRDTArray; getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; blockOrder?: CRDTArray; blocks?: CRDTMap; apps?: CRDTMap; metadata?: CRDTMap; }; +function createGeneratedBlockId(): string { return crypto.randomUUID(); } +function missingPenDocumentRoot(name: string): never { throw new Error(`CRDT document is missing required Pen root "${name}".`); } +let hasWarnedAboutWithoutOption = false; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; + + +export function getRawBlockMap(editor: EditorImplRuntime, blockId: string): CRDTUnknownMap | null { + const self = editor as EditorImplRuntime; +const blockMap = (self._doc.blocks as CRDTBlockMap).get(blockId); +return (blockMap as unknown as CRDTUnknownMap) ?? null; +} + +export function getEditorInternals(editor: EditorImplRuntime, ): EditorInternals { + const self = editor as EditorImplRuntime; +return { + adapter: self._adapter, + crdtDoc: self._crdtDoc, + doc: self._doc, + engine: self._engine, + awareness: self._awareness, + documentSession: self._documentSession, + documentScope: self._documentScope, + viewId: self._viewId, + emit: (event, ...args) => { + self._emitter.emit(event, ...args); + }, + onApplyBoundary: (hook) => + self._pipeline.addApplyBoundaryHook(hook), + getSlot: (key: string): T | undefined => + self._slots.get(key) as T | undefined, + setSlot: (key: string, value: unknown): void => { + self._slots.set(key, value); + if (key === "undo:manager") { + self._refreshUndoManager(); + } + }, + getBlockText: (blockId: string): unknown => { + const blockMap = self._getRawBlockMap(blockId); + if (!blockMap) return null; + return getTextProp(blockMap, "content"); + }, + getCellText: ( + blockId: string, + row: number, + col: number, + ): unknown => { + const blockMap = self._getRawBlockMap(blockId); + if (!blockMap) return null; + const tableContent = getTableContent(blockMap); + if (!tableContent || row < 0 || row >= tableContent.length) + return null; + const rowMap = tableContent.get(row); + if (!rowMap || !isCRDTMap(rowMap)) return null; + return getCellTextFromRow(rowMap, col); + }, +}; +} + +export function applyEditorOps(editor: EditorImplRuntime, ops: DocumentOp[], options?: ApplyOptions): void { + const self = editor as EditorImplRuntime; +const origin = options?.origin ?? "user"; +const groupId = getApplyOptionsGroupId(origin, options); +const undo = self._slots.get("undo:manager") as UndoManager | undefined; + +undo?.syncExplicitUndoGroup(groupId ?? null); + +if (options?.undoGroup && !groupId) { + undo?.stopCapturing(); +} + +self._pipeline.apply(ops, origin); +self._recordMutationGroupMetadata(origin, groupId); +} + +export function recordMutationGroupMetadata(editor: EditorImplRuntime, + origin: OpOrigin, + groupId: string | undefined, +): void { + const self = editor as EditorImplRuntime; +if (!groupId) { + return; +} +const controller = self._slots.get( + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, +) as + | { + setCurrentEntryMetadata( + key: string, + value: { before: T | null; after: T | null }, + ): boolean; + } + | undefined; +controller?.setCurrentEntryMetadata( + MUTATION_GROUP_METADATA_KEY, + { + before: null, + after: createMutationGroupMetadata(origin, groupId), + }, +); +} + +export function loadEditorDocument(editor: EditorImplRuntime, doc: CRDTDocument): void { + const self = editor as EditorImplRuntime; +self._queueExtensionLifecycle(async () => { + await self._extensions.deactivateAll(self); + if (self._isDestroyed) { + return; + } + self._teardownObservation(); + self._releaseSession?.(); + self._releaseSession = null; + self._bindSession( + createDocumentSession({ + adapter: self._adapter, + document: doc, + destroyWhenIdle: true, + ownsDocuments: false, + }), + ); + await self._rebindActiveScope(); +}); +} + +export function* iterateBlocks(editor: EditorImplRuntime, type?: string): Iterable { + const self = editor as EditorImplRuntime; +for (let i = 0; i < self._doc.blockOrder.length; i++) { + const id = (self._doc.blockOrder as CRDTArray).get( + i, + ) as string; + if (type) { + const blockMap = (self._doc.blocks as CRDTBlockMap).get(id); + if (!blockMap || blockMap.get("type") !== type) continue; + } + yield createBlockHandle( + id, + self._doc, + self._crdtDoc, + self._registry, + ); +} +} + +export function getEditorBlock(editor: EditorImplRuntime, blockId: string): BlockHandle | null { + const self = editor as EditorImplRuntime; +if (!(self._doc.blocks as CRDTBlockMap).has(blockId)) return null; +return createBlockHandle( + blockId, + self._doc, + self._crdtDoc, + self._registry, +); +} + +export function getFirstBlock(editor: EditorImplRuntime, ): BlockHandle | null { + const self = editor as EditorImplRuntime; +if (self._doc.blockOrder.length === 0) return null; +const id = (self._doc.blockOrder as CRDTArray).get(0) as string; +return createBlockHandle(id, self._doc, self._crdtDoc, self._registry); +} + +export function getLastBlock(editor: EditorImplRuntime, ): BlockHandle | null { + const self = editor as EditorImplRuntime; +const len = self._doc.blockOrder.length; +if (len === 0) return null; +const id = (self._doc.blockOrder as CRDTArray).get( + len - 1, +) as string; +return createBlockHandle(id, self._doc, self._crdtDoc, self._registry); +} + +export function getBlockCount(editor: EditorImplRuntime, ): number { + const self = editor as EditorImplRuntime; +return self._doc.blockOrder.length; +} + +export function getEditorBlockRevision(editor: EditorImplRuntime, blockId: string): number { + const self = editor as EditorImplRuntime; +return self._blockRevisions.get(blockId) ?? 0; +} + +export function destroyEditor(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._isDestroyed) { + return; +} +self._isDestroyed = true; +self._queueExtensionLifecycle(async () => { + await self._extensions.deactivateAll(self); + self._teardownObservation(); + self._releaseSession?.(); + self._releaseSession = null; + self._emitter.removeAllListeners(); +}); +} diff --git a/packages/core/src/editor/editorLifecycle.ts b/packages/core/src/editor/editorLifecycle.ts new file mode 100644 index 0000000..135b200 --- /dev/null +++ b/packages/core/src/editor/editorLifecycle.ts @@ -0,0 +1,354 @@ +import type { EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; +import { undoExtension } from "@pen/undo"; +import { documentOpsExtension } from "@pen/document-ops"; +import { deltaStreamExtension } from "@pen/delta-stream"; +import { richTextShortcutsExtension } from "@pen/shortcuts"; +import { SchemaEngineImpl } from "../schema/normalize"; +import { createBlockHandle } from "../schema/handles"; +import { resolveCellSelectionMatrix } from "./cellSelection"; +import { filterOpsForDocumentProfile } from "./profilePolicy"; +import type { CRDTUnknownMap } from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { DocumentStateImpl } from "./documentState"; +import { createDocumentSession } from "./documentSession"; + +type EditorImplRuntime = any; +type CRDTBlockMap = CRDTMap>; +type RawPenDocumentLike = { getArray?(name: "blockOrder"): CRDTArray; getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; blockOrder?: CRDTArray; blocks?: CRDTMap; apps?: CRDTMap; metadata?: CRDTMap; }; +function createGeneratedBlockId(): string { return crypto.randomUUID(); } +function missingPenDocumentRoot(name: string): never { throw new Error(`CRDT document is missing required Pen root "${name}".`); } +let hasWarnedAboutWithoutOption = false; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; + + +export function createPenDocumentForEditor(editor: EditorImplRuntime, crdtDoc: CRDTDocument): PenDocument { + const self = editor as EditorImplRuntime; +const wrapped = crdtDoc as CRDTDocument & { penDocument?: PenDocument }; +if (wrapped.penDocument) { + return wrapped.penDocument; +} + +const raw = (self._adapter.raw as (doc: CRDTDocument) => T)(crdtDoc); +const blockOrder = + (raw.getArray ? raw.getArray("blockOrder") : raw.blockOrder) ?? + missingPenDocumentRoot("blockOrder"); +const blocks = + (raw.getMap ? raw.getMap("blocks") : raw.blocks) ?? + missingPenDocumentRoot("blocks"); +const apps = + (raw.getMap ? raw.getMap("apps") : raw.apps) ?? + missingPenDocumentRoot("apps"); +const metadata = + (raw.getMap ? raw.getMap("metadata") : raw.metadata) ?? + missingPenDocumentRoot("metadata"); +return { + blockOrder, + blocks, + apps, + metadata, + adapter: self._adapter, +}; +} + +export function resolveEditorExtensions(editor: EditorImplRuntime, options: CreateEditorOptions): Extension[] { + const self = editor as EditorImplRuntime; +const without = new Set(options.without ?? []); +if (without.size > 0 && !hasWarnedAboutWithoutOption) { + hasWarnedAboutWithoutOption = true; + console.warn( + "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", + ); +} +const defaultExtensions = options.preset?.resolve({ + schema: self._registry, + documentProfile: self._documentProfile, +}).extensions ?? [ + documentOpsExtension(), + deltaStreamExtension(), + undoExtension(), + richTextShortcutsExtension(), +]; +const defaults = defaultExtensions.filter( + (ext) => !without.has(ext.name), +); + +const userExtensions = options.extensions ?? []; +return [...defaults, ...userExtensions]; +} + +export function installProfilePolicyHook(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +self._pipeline.setFinalBeforeApplyHook((ops: DocumentOp[]) => + self._enforceDocumentProfileBoundary(ops), +); +} + +export function enforceDocumentProfileBoundary(editor: EditorImplRuntime, ops: DocumentOp[]): DocumentOp[] { + const self = editor as EditorImplRuntime; +const result = filterOpsForDocumentProfile( + ops, + self._documentProfile, + self._registry, +); + +for (const violation of result.violations) { + self._emitter.emit("diagnostic", { + code: "PEN_PROFILE_001", + level: "warn", + source: "profile-policy", + message: + `profile-policy: dropped ${violation.op.type} for disallowed ` + + `block type "${violation.blockType}" in ${violation.documentProfile} documents`, + remediation: + "Use a block type allowed by the active documentProfile or " + + "change the documentProfile before applying structural mutations.", + op: violation.op, + blockType: violation.blockType, + documentProfile: violation.documentProfile, + }); +} + +return result.ops; +} + +export function refreshCoreSlots(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +self._slots.set("core:engine", self._engine); +self._slots.set( + AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, + () => self._extensionLifecycle, +); +self._slots.set( + COLLECT_KEY_BINDINGS_SLOT_KEY, + (registry: SchemaRegistry) => + self._extensions.collectKeyBindings(registry), +); +} + +export function bindEditorSession(editor: EditorImplRuntime, session: DocumentSession, scopeId?: string): void { + const self = editor as EditorImplRuntime; +self._bindScope(session, scopeId); +self._releaseSession = session.attachEditor({ + onScopeReplaced: (event) => { + self._handleScopeReplacement(session, event); + }, +}); +} + +export function bindEditorScope(editor: EditorImplRuntime, session: DocumentSession, scopeId?: string): void { + const self = editor as EditorImplRuntime; +self._documentSession = session; +const scope = + (scopeId ? session.getScope(scopeId) : null) ?? session.rootScope; +self._documentScope = scope; +self._crdtDoc = scope.doc; +self._doc = self._createPenDocument(scope.doc); +self._awareness = session.getAwareness(scope.id); +} + +export function handleEditorScopeReplacement(editor: EditorImplRuntime, + session: DocumentSession, + event: DocumentScopeReplacementEvent, +): void { + const self = editor as EditorImplRuntime; +if (event.previousScope.id !== self._documentScope.id) { + return; +} +self._queueExtensionLifecycle(async () => { + await self._extensions.deactivateAll(self); + if (self._isDestroyed) { + return; + } + self._teardownObservation(); + self._bindScope(session, event.scope.id); + await self._rebindActiveScope(); +}); +} + +export function resolveEditorDocumentProfile(editor: EditorImplRuntime, + requestedProfile?: DocumentProfile, +): DocumentProfile { + const self = editor as EditorImplRuntime; +const persistedProfile = + self._adapter.getDocumentProfile?.(self._crdtDoc) ?? null; +const resolvedProfile = + persistedProfile ?? requestedProfile ?? "structured"; +if (persistedProfile == null) { + self._adapter.setDocumentProfile?.(self._crdtDoc, resolvedProfile); +} +return resolvedProfile; +} + +export async function rebindActiveScope(editor: EditorImplRuntime, ): Promise { + const self = editor as EditorImplRuntime; +self._documentProfile = self._resolveDocumentProfile(); +self._editorViewMode = + self._explicitEditorViewMode ?? self._documentProfile; +self._clientId = self._adapter.getClientId(self._crdtDoc); + +self._engine = new SchemaEngineImpl( + self._registry, + self._doc, + self._crdtDoc, +); +self._selection.updateDocument(self._doc, self._crdtDoc); +self._pipeline.updateDocument(self._doc, self._crdtDoc, self._engine); +self._documentState.updateDocument( + self._doc, + self._crdtDoc, + self._documentProfile, +); +self._pipeline._init((event: CRDTEvent) => { + self._dispatchCRDTEvent(event); +}); +self._refreshCoreSlots(); + +self._wireObservation(); +await self._activateExtensions(); +self._engine.normalizeAll(); +self._refreshDecorations(); +} + +export function refreshUndoManager(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +const slotUndo = self._slots.get("undo:manager") as + | UndoManager + | undefined; +(self as { undoManager: UndoManager }).undoManager = + slotUndo ?? NOOP_UNDO; +} + +export async function activateEditorExtensions(editor: EditorImplRuntime, ): Promise { + const self = editor as EditorImplRuntime; +const activation = self._extensions.activateAll(self); +self._refreshUndoManager(); +await activation; +self._refreshUndoManager(); +} + +export function queueExtensionLifecycle(editor: EditorImplRuntime, task: () => Promise): void { + const self = editor as EditorImplRuntime; +const runTask = async (): Promise => { + try { + await task(); + } catch (error) { + if (self._isDestroyed) { + return; + } + self._emitter.emit("diagnostic", { + code: "PEN_EXT_006", + level: "error", + source: "extension", + message: "Editor extension lifecycle transition failed", + remediation: + "Inspect async extension activate/deactivate hooks involved in document reload or scope replacement and ensure they resolve safely.", + error, + }); + } +}; + +self._extensionLifecycle = self._extensionLifecycle.then( + runTask, + runTask, +); +} + +export function ensureInitialParagraph(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._doc.blockOrder.length > 0) { + return; +} + +self.apply( + [ + { + type: "insert-block", + blockId: createGeneratedBlockId(), + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "system" }, +); +} + +export function createCommitEvent(editor: EditorImplRuntime, event: CRDTEvent): DocumentCommitEvent { + const self = editor as EditorImplRuntime; +const blockRevisions: Record = {}; +for (const blockId of event.affectedBlocks) { + const nextRevision = (self._blockRevisions.get(blockId) ?? 0) + 1; + self._blockRevisions.set(blockId, nextRevision); + blockRevisions[blockId] = nextRevision; +} +self._commitId += 1; +return { + commitId: self._commitId, + ops: event.ops, + origin: event.origin, + affectedBlocks: [...event.affectedBlocks], + blockRevisions, + scope: self._documentScope, +}; +} + +export function dispatchCRDTEvent(editor: EditorImplRuntime, event: CRDTEvent): void { + const self = editor as EditorImplRuntime; +self._syncDocumentProfileFromStorage(); +const commitEvent = self._createCommitEvent(event); +self._documentState.incrementalUpdate(event.affectedBlocks); +self._extensions.dispatchObserve([event], self); +const previousDecorationGeneration = self._decorations.generation; +const nextDecorations = self._refreshDecorations(); +if (nextDecorations.generation !== previousDecorationGeneration) { + self._emitter.emit("decorationsChange", nextDecorations.generation); +} +self._emitter.emit("change", [event]); +self._emitter.emit("documentCommit", commitEvent); +} + +export function syncDocumentProfileFromStorage(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +const persistedProfile = + self._adapter.getDocumentProfile?.(self._crdtDoc) ?? null; +if (!persistedProfile || persistedProfile === self._documentProfile) { + return; +} + +self._documentProfile = persistedProfile; +if (self._explicitEditorViewMode == null) { + self._editorViewMode = persistedProfile; +} +self._documentState.setDocumentProfile(persistedProfile); +} + +export function wireEditorObservation(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._documentSession) { + self._unsubObserve = self._documentSession.observe( + self._documentScope.id, + (event: CRDTEvent) => { + if (self._pipeline.suppressObserver) return; + self._dispatchCRDTEvent(event); + }, + ); + return; +} + +self._unsubObserve = self._adapter.observe( + self._crdtDoc, + (event: CRDTEvent) => { + if (self._pipeline.suppressObserver) return; + self._dispatchCRDTEvent(event); + }, +); +} + +export function teardownEditorObservation(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._unsubObserve) { + self._unsubObserve(); + self._unsubObserve = null; +} +} diff --git a/packages/core/src/editor/editorSelectionMutations.ts b/packages/core/src/editor/editorSelectionMutations.ts new file mode 100644 index 0000000..0432834 --- /dev/null +++ b/packages/core/src/editor/editorSelectionMutations.ts @@ -0,0 +1,397 @@ +import type { EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; +import { undoExtension } from "@pen/undo"; +import { documentOpsExtension } from "@pen/document-ops"; +import { deltaStreamExtension } from "@pen/delta-stream"; +import { richTextShortcutsExtension } from "@pen/shortcuts"; +import { SchemaEngineImpl } from "../schema/normalize"; +import { createBlockHandle } from "../schema/handles"; +import { resolveCellSelectionMatrix } from "./cellSelection"; +import { filterOpsForDocumentProfile } from "./profilePolicy"; +import type { CRDTUnknownMap } from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { DocumentStateImpl } from "./documentState"; +import { createDocumentSession } from "./documentSession"; + +type EditorImplRuntime = any; +type CRDTBlockMap = CRDTMap>; +type RawPenDocumentLike = { getArray?(name: "blockOrder"): CRDTArray; getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; blockOrder?: CRDTArray; blocks?: CRDTMap; apps?: CRDTMap; metadata?: CRDTMap; }; +function createGeneratedBlockId(): string { return crypto.randomUUID(); } +function missingPenDocumentRoot(name: string): never { throw new Error(`CRDT document is missing required Pen root "${name}".`); } +let hasWarnedAboutWithoutOption = false; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; + + +export function replaceEditorSelection(editor: EditorImplRuntime, content: string | Block[]): void { + const self = editor as EditorImplRuntime; +const sel = self._selection.getSelection(); +if (!sel) return; + +if (sel.type === "text") { + const range = self._getSelectionRange(sel); + if (range.isMultiBlock) { + if (typeof content === "string") { + self._replaceMultiBlockTextRange(range, content); + } + return; + } + + const from = range.start.offset; + const to = range.end.offset; + const ops: DocumentOp[] = []; + if (to > from) { + ops.push({ + type: "delete-text", + blockId: range.start.blockId, + offset: from, + length: to - from, + }); + } + if (typeof content === "string" && content.length > 0) { + ops.push({ + type: "insert-text", + blockId: range.start.blockId, + offset: from, + text: content, + }); + } + if (ops.length > 0) { + self.apply(ops); + } + const nextOffset = + typeof content === "string" ? from + content.length : from; + self._collapseToPoint({ + blockId: range.start.blockId, + offset: nextOffset, + }); + return; +} + +if (sel.type === "block" && sel.blockIds.length > 0) { + const firstId = sel.blockIds[0]; + const firstIndex = self._pipeline._resolvePosition({ + before: firstId, + }); + const ops: DocumentOp[] = []; + + for (const id of sel.blockIds) { + ops.push({ type: "delete-block", blockId: id }); + } + + const insertPosition: Position = + firstIndex === 0 + ? "first" + : { + after: ( + self._doc.blockOrder as CRDTArray + ).get(firstIndex - 1) as string, + }; + + if (typeof content === "string") { + const newId = createGeneratedBlockId(); + ops.push({ + type: "insert-block", + blockId: newId, + blockType: "paragraph", + props: {}, + position: insertPosition, + }); + if (content.length > 0) { + ops.push({ + type: "insert-text", + blockId: newId, + offset: 0, + text: content, + }); + } + } else if (Array.isArray(content)) { + let prevPosition = insertPosition; + for (const block of content) { + const newId = createGeneratedBlockId(); + ops.push({ + type: "insert-block", + blockId: newId, + blockType: block.type, + props: block.props ?? {}, + position: prevPosition, + }); + if ( + typeof block.content === "string" && + block.content.length > 0 + ) { + ops.push({ + type: "insert-text", + blockId: newId, + offset: 0, + text: block.content, + }); + } + prevPosition = { after: newId }; + } + } + + self.apply(ops); +} +} + +export function deleteEditorSelection(editor: EditorImplRuntime, options?: ApplyOptions): void { + const self = editor as EditorImplRuntime; +const sel = self._selection.getSelection(); +if (!sel) return; + +if (sel.type === "text") { + const range = self._getSelectionRange(sel); + if (range.isMultiBlock) { + self._deleteMultiBlockTextRange(range, options); + return; + } + + if ( + !self._usesInlineTextSelection(range.start.blockId) && + self._isWholeBlockSelection( + range.start.blockId, + range.start.offset, + range.end.offset, + ) + ) { + self.apply( + [ + { + type: "delete-block", + blockId: range.start.blockId, + }, + ], + options, + ); + self.setSelection(null); + return; + } + + const from = range.start.offset; + const to = range.end.offset; + if (to > from) { + self.apply( + [ + { + type: "delete-text", + blockId: range.start.blockId, + offset: from, + length: to - from, + }, + ], + options, + ); + } + self._collapseToPoint({ + blockId: range.start.blockId, + offset: from, + }); + return; +} + +if (sel.type === "block") { + const ops: DocumentOp[] = sel.blockIds.map((id: string) => ({ + type: "delete-block" as const, + blockId: id, + })); + self.apply(ops, options); + self.setSelection(null); +} + +if (sel.type === "cell") { + const block = self.getBlock(sel.blockId); + if (!block) return; + const ops: DocumentOp[] = []; + for (const rowCells of resolveCellSelectionMatrix(block, sel)) { + for (const cellCoord of rowCells) { + const cell = block.tableCell(cellCoord.row, cellCoord.col); + if (!cell) continue; + const len = cell.length(); + if (len > 0) { + ops.push({ + type: "delete-table-cell-text", + blockId: sel.blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: 0, + length: len, + } as DocumentOp); + } + } + } + if (ops.length > 0) { + self.apply(ops, options); + } + self.setSelection({ + ...sel, + head: sel.anchor, + }); +} +} + +export function getTextForBlock(editor: EditorImplRuntime, blockId: string): string { + const self = editor as EditorImplRuntime; +return self.getBlock(blockId)?.textContent() ?? ""; +} + +export function getSelectionRange(editor: EditorImplRuntime, sel: TextSelection): DocumentRange { + const self = editor as EditorImplRuntime; +return sel.toRange(); +} + +export function usesInlineTextSelectionForBlock(editor: EditorImplRuntime, blockId: string): boolean { + const self = editor as EditorImplRuntime; +const block = self.getBlock(blockId); +if (!block) { + return false; +} + +const schema = self._registry.resolve(block.type); +if (!schema) { + return false; +} + +return usesInlineTextSelection(schema); +} + +export function getBlockSelectionSpan(editor: EditorImplRuntime, blockId: string): number { + const self = editor as EditorImplRuntime; +if (self._usesInlineTextSelection(blockId)) { + return self._getTextForBlock(blockId).length; +} +return self.getBlock(blockId) ? 1 : 0; +} + +export function isWholeBlockSelection(editor: EditorImplRuntime, + blockId: string, + startOffset: number, + endOffset: number, +): boolean { + const self = editor as EditorImplRuntime; +const span = self._getBlockSelectionSpan(blockId); +if (span <= 0) { + return false; +} +return startOffset <= 0 && endOffset >= span; +} + +export function collapseToPoint(editor: EditorImplRuntime, point: { blockId: string; offset: number }): void { + const self = editor as EditorImplRuntime; + self.selectTextRange(point, point); +} + +export function sliceInlineDeltas( + editor: EditorImplRuntime, + blockId: string, + startOffset: number, +): Array<{ insert: string; attributes?: Record }> { + const self = editor as EditorImplRuntime; + const handle = self.getBlock(blockId); + if (!handle) return []; + const deltas = handle.textDeltas().filter((delta: { insert: string }) => delta.insert !== "\u200B"); + const sliced: Array<{ insert: string; attributes?: Record }> = []; + let offset = 0; + for (const delta of deltas) { + const length = delta.insert.length; + if (startOffset >= offset + length) { + offset += length; + continue; + } + const localStart = Math.max(0, startOffset - offset); + const text = delta.insert.slice(localStart); + if (text.length > 0) { + sliced.push({ insert: text, ...(delta.attributes ? { attributes: delta.attributes } : {}) }); + } + offset += length; + } + return sliced; +} + +export function buildMultiBlockTextReplacement( + editor: EditorImplRuntime, + range: DocumentRange, + insertedText: string, +): { ops: DocumentOp[]; caret: { blockId: string; offset: number } } { + const self = editor as EditorImplRuntime; + const startId = range.start.blockId; + const endId = range.end.blockId; + const startText = self._getTextForBlock(startId); + const middleIds = range.blockRange.slice(1, -1); + const suffixDeltas = self._sliceInlineDeltas(endId, range.end.offset); + const ops: DocumentOp[] = []; + if (range.start.offset < startText.length) { + ops.push({ type: "delete-text", blockId: startId, offset: range.start.offset, length: startText.length - range.start.offset }); + } + if (range.end.offset > 0) { + ops.push({ type: "delete-text", blockId: endId, offset: 0, length: range.end.offset }); + } + for (const blockId of middleIds) ops.push({ type: "delete-block", blockId }); + let insertionOffset = range.start.offset; + if (insertedText.length > 0) { + ops.push({ type: "insert-text", blockId: startId, offset: insertionOffset, text: insertedText }); + insertionOffset += insertedText.length; + } + for (const delta of suffixDeltas) { + ops.push({ type: "insert-text", blockId: startId, offset: insertionOffset, text: delta.insert, marks: delta.attributes }); + insertionOffset += delta.insert.length; + } + ops.push({ type: "delete-block", blockId: endId }); + return { ops, caret: { blockId: startId, offset: range.start.offset + insertedText.length } }; +} + +export function deleteMultiBlockTextRange( + editor: EditorImplRuntime, + range: DocumentRange, + options?: ApplyOptions, +): { blockId: string; offset: number } | null { + const self = editor as EditorImplRuntime; + const startId = range.start.blockId; + const endId = range.end.blockId; + if (startId === endId) { + const from = range.start.offset; + const to = range.end.offset; + if (to > from) self.apply([{ type: "delete-text", blockId: startId, offset: from, length: to - from }], options); + const caret = { blockId: startId, offset: from }; + self._collapseToPoint(caret); + return caret; + } + const startInline = self._usesInlineTextSelection(startId); + const endInline = self._usesInlineTextSelection(endId); + if (startInline && endInline) { + const { ops, caret } = self._buildMultiBlockTextReplacement(range, ""); + self.apply(ops, options); + self._collapseToPoint(caret); + return caret; + } + const middleIds = range.blockRange.slice(1, -1); + const ops: DocumentOp[] = []; + if (startInline) { + const startText = self._getTextForBlock(startId); + if (range.start.offset < startText.length) ops.push({ type: "delete-text", blockId: startId, offset: range.start.offset, length: startText.length - range.start.offset }); + } else if (self._isWholeBlockSelection(startId, range.start.offset, self._getBlockSelectionSpan(startId))) { + ops.push({ type: "delete-block", blockId: startId }); + } + for (const blockId of middleIds) ops.push({ type: "delete-block", blockId }); + if (endInline) { + if (range.end.offset > 0) ops.push({ type: "delete-text", blockId: endId, offset: 0, length: range.end.offset }); + } else if (self._isWholeBlockSelection(endId, 0, range.end.offset)) { + ops.push({ type: "delete-block", blockId: endId }); + } + if (ops.length > 0) self.apply(ops, options); + const caret = startInline ? { blockId: startId, offset: range.start.offset } : endInline ? { blockId: endId, offset: 0 } : null; + if (caret) self._collapseToPoint(caret); + else self.setSelection(null); + return caret; +} + +export function replaceMultiBlockTextRange( + editor: EditorImplRuntime, + range: DocumentRange, + text: string, +): { blockId: string; offset: number } { + const self = editor as EditorImplRuntime; + const { ops, caret } = self._buildMultiBlockTextReplacement(range, text); + self.apply(ops); + self._collapseToPoint(caret); + return caret; +} diff --git a/packages/core/src/editor/inlineCompletion.ts b/packages/core/src/editor/inlineCompletion.ts index 90e0ec8..9594ac5 100644 --- a/packages/core/src/editor/inlineCompletion.ts +++ b/packages/core/src/editor/inlineCompletion.ts @@ -59,6 +59,12 @@ class InlineCompletionControllerImpl implements InlineCompletionController { visibleSuggestion: null, }; + if (suggestion.accept) { + const accepted = suggestion.accept(this._editor, suggestion); + this._emit(); + return accepted; + } + if (suggestion.type === "inline") { const nextOffset = suggestion.offset + suggestion.text.length; this._editor.apply( diff --git a/packages/core/src/editor/tableGridCellHelpers.ts b/packages/core/src/editor/tableGridCellHelpers.ts new file mode 100644 index 0000000..a6578f4 --- /dev/null +++ b/packages/core/src/editor/tableGridCellHelpers.ts @@ -0,0 +1,128 @@ +import type { TableColumnSchema } from "@pen/types"; +import { + type CRDTTextLike, + type CRDTUnknownMap, + getCellText, + getRowCells, + getStringProp, + isCRDTMap, +} from "./crdtShapes"; + +export type CRDTDelta = { + insert: string | Record; + attributes?: Record; +}; + +export type TableRowSnapshot = { + rowId?: string; + cells: Array<{ + cellId?: string; + deltas: CRDTDelta[]; + }>; +}; + +type CRDTTextWithDelta = CRDTTextLike & { + toDelta?: () => CRDTDelta[]; + insertEmbed?: (offset: number, value: Record) => void; +}; + +export function getCellContent( + rowMap: CRDTUnknownMap, + columnIndex: number, +): CRDTTextLike | null { + return getCellText(rowMap, columnIndex); +} + +export function ensureCellContent( + rowMap: CRDTUnknownMap, + columnIndex: number, + createCell: () => CRDTUnknownMap, +): CRDTTextLike | null { + const cells = getRowCells(rowMap); + if (!cells || columnIndex < 0) { + return null; + } + while (cells.length <= columnIndex) { + cells.insert(cells.length, [createCell()]); + } + return getCellText(rowMap, columnIndex); +} + +export function captureTableRowSnapshot( + sourceRow: CRDTUnknownMap, +): TableRowSnapshot { + const sourceCells = getRowCells(sourceRow); + const snapshot: TableRowSnapshot = { + rowId: getStringProp(sourceRow, "id"), + cells: [], + }; + if (!sourceCells) { + return snapshot; + } + + for (let columnIndex = 0; columnIndex < sourceCells.length; columnIndex++) { + const sourceCell = sourceCells.get(columnIndex); + if (!sourceCell || !isCRDTMap(sourceCell)) { + snapshot.cells.push({ deltas: [] }); + continue; + } + snapshot.cells.push({ + cellId: getStringProp(sourceCell, "id"), + deltas: readTableCellDeltas(sourceCell), + }); + } + + return snapshot; +} + +export function writeCellDeltas( + cellMap: CRDTUnknownMap, + deltas: CRDTDelta[], +): void { + const targetContent = cellMap.get("content") as CRDTTextWithDelta | undefined; + if (!targetContent) { + return; + } + + let offset = 0; + for (const delta of deltas) { + if (typeof delta.insert === "string") { + if (delta.insert.length > 0) { + targetContent.insert(offset, delta.insert, delta.attributes); + offset += delta.insert.length; + } + continue; + } + + if (typeof targetContent.insertEmbed === "function") { + targetContent.insertEmbed(offset, delta.insert); + if (delta.attributes) { + targetContent.format(offset, 1, delta.attributes); + } + offset += 1; + } + } +} + +export function readTableCellDeltas(cellMap: CRDTUnknownMap): CRDTDelta[] { + const sourceContent = cellMap.get("content") as CRDTTextWithDelta | undefined; + if (!sourceContent) { + return []; + } + return typeof sourceContent.toDelta === "function" + ? sourceContent.toDelta() + : [{ insert: sourceContent.toString() }]; +} + +export function createRecordMap( + createMap: () => CRDTUnknownMap, + record: TableColumnSchema | Record, +): CRDTUnknownMap { + const map = createMap(); + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) { + map.set(key, value); + } + } + return map; +} diff --git a/packages/core/src/editor/tableGridExecutor.ts b/packages/core/src/editor/tableGridExecutor.ts index 42c049b..db331ba 100644 --- a/packages/core/src/editor/tableGridExecutor.ts +++ b/packages/core/src/editor/tableGridExecutor.ts @@ -13,16 +13,22 @@ import type { } from "@pen/types"; import { generateId } from "@pen/types"; import { - type CRDTTextLike, type CRDTUnknownArray, type CRDTUnknownMap, - getCellText, getRowCells, getStringProp, getTableColumns, getTableContent, isCRDTMap, } from "./crdtShapes"; +import { + captureTableRowSnapshot, + createRecordMap, + ensureCellContent, + getCellContent, + type TableRowSnapshot, + writeCellDeltas, +} from "./tableGridCellHelpers"; const ZERO_WIDTH_SPACE = "\u200B"; @@ -31,24 +37,6 @@ export type TableCellDelta = { attributes?: Record; }; -type CRDTDelta = { - insert: string | Record; - attributes?: Record; -}; - -type TableRowSnapshot = { - rowId?: string; - cells: Array<{ - cellId?: string; - deltas: CRDTDelta[]; - }>; -}; - -type CRDTTextWithDelta = CRDTTextLike & { - toDelta?: () => CRDTDelta[]; - insertEmbed?: (offset: number, value: Record) => void; -}; - export class TableGridExecutor { private readonly _adapter: CRDTAdapter; @@ -123,9 +111,10 @@ export class TableGridExecutor { break; case "insert-table-cell-text": { const cellOp = op as InsertTableCellTextOp; - const content = this._ensureCellContent( + const content = ensureCellContent( tableContent.get(cellOp.row), cellOp.col, + () => this.createTableCell(), ); if (content && typeof content.insert === "function") { content.insert(cellOp.offset, cellOp.text); @@ -134,7 +123,7 @@ export class TableGridExecutor { } case "delete-table-cell-text": { const cellOp = op as DeleteTableCellTextOp; - const content = this._getCellContent( + const content = getCellContent( tableContent.get(cellOp.row), cellOp.col, ); @@ -145,7 +134,7 @@ export class TableGridExecutor { } case "format-table-cell-text": { const cellOp = op as FormatTableCellTextOp; - const content = this._getCellContent( + const content = getCellContent( tableContent.get(cellOp.row), cellOp.col, ); @@ -211,7 +200,7 @@ export class TableGridExecutor { col: number, deltas: TableCellDelta[], ): void { - const content = getCellText(row, col); + const content = getCellContent(row, col); if (!content) { return; } @@ -222,7 +211,7 @@ export class TableGridExecutor { } readTableCellText(rowMap: CRDTUnknownMap, columnIndex: number): string { - const content = this._getCellContent(rowMap, columnIndex); + const content = getCellContent(rowMap, columnIndex); if (content && typeof content.toString === "function") { const text = content.toString(); return text === ZERO_WIDTH_SPACE ? "" : text; @@ -235,7 +224,9 @@ export class TableGridExecutor { columnIndex: number, value: string, ): void { - const content = this._ensureCellContent(rowMap, columnIndex); + const content = ensureCellContent(rowMap, columnIndex, () => + this.createTableCell(), + ); if (!content) { return; } @@ -310,28 +301,7 @@ export class TableGridExecutor { } captureTableRowSnapshot(sourceRow: CRDTUnknownMap): TableRowSnapshot { - const sourceCells = getRowCells(sourceRow); - const snapshot: TableRowSnapshot = { - rowId: getStringProp(sourceRow, "id"), - cells: [], - }; - if (!sourceCells) { - return snapshot; - } - - for (let columnIndex = 0; columnIndex < sourceCells.length; columnIndex++) { - const sourceCell = sourceCells.get(columnIndex); - if (!sourceCell || !isCRDTMap(sourceCell)) { - snapshot.cells.push({ deltas: [] }); - continue; - } - snapshot.cells.push({ - cellId: getStringProp(sourceCell, "id"), - deltas: this.readTableCellDeltas(sourceCell), - }); - } - - return snapshot; + return captureTableRowSnapshot(sourceRow); } applyTableRowSnapshot( @@ -363,7 +333,7 @@ export class TableGridExecutor { targetCell.set("id", cellSnapshot.cellId); } - this.writeCellDeltas(targetCell, cellSnapshot.deltas); + writeCellDeltas(targetCell, cellSnapshot.deltas); } } @@ -420,7 +390,10 @@ export class TableGridExecutor { optionsArray.insert( 0, value.map((option) => - this._createRecordMap(option as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + option as Record, + ), ), ); columnMap.set(key, optionsArray); @@ -429,7 +402,10 @@ export class TableGridExecutor { if (key === "format" && value && typeof value === "object") { columnMap.set( key, - this._createRecordMap(value as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + value as Record, + ), ); continue; } @@ -453,7 +429,10 @@ export class TableGridExecutor { optionsArray.insert( 0, value.map((option: unknown) => - this._createRecordMap(option as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + option as Record, + ), ), ); } @@ -463,7 +442,10 @@ export class TableGridExecutor { if (key === "format" && value && typeof value === "object") { columnMap.set( key, - this._createRecordMap(value as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + value as Record, + ), ); return; } @@ -487,80 +469,4 @@ export class TableGridExecutor { } return ids; } - - private _getCellContent( - rowMap: CRDTUnknownMap, - columnIndex: number, - ): CRDTTextLike | null { - return getCellText(rowMap, columnIndex); - } - - private _ensureCellContent( - rowMap: CRDTUnknownMap, - columnIndex: number, - ): CRDTTextLike | null { - const cells = getRowCells(rowMap); - if (!cells || columnIndex < 0) { - return null; - } - while (cells.length <= columnIndex) { - cells.insert(cells.length, [this.createTableCell()]); - } - return getCellText(rowMap, columnIndex); - } - - private cloneTableCellContent( - sourceCell: CRDTUnknownMap, - targetCell: CRDTUnknownMap, - ): void { - this.writeCellDeltas(targetCell, this.readTableCellDeltas(sourceCell)); - } - - private readTableCellDeltas(cellMap: CRDTUnknownMap): CRDTDelta[] { - const sourceContent = cellMap.get("content") as CRDTTextWithDelta | undefined; - if (!sourceContent) { - return []; - } - return typeof sourceContent.toDelta === "function" - ? sourceContent.toDelta() - : [{ insert: sourceContent.toString() }]; - } - - private writeCellDeltas(cellMap: CRDTUnknownMap, deltas: CRDTDelta[]): void { - const targetContent = cellMap.get("content") as CRDTTextWithDelta | undefined; - if (!targetContent) { - return; - } - - let offset = 0; - for (const delta of deltas) { - if (typeof delta.insert === "string") { - if (delta.insert.length > 0) { - targetContent.insert(offset, delta.insert, delta.attributes); - offset += delta.insert.length; - } - continue; - } - - if (typeof targetContent.insertEmbed === "function") { - targetContent.insertEmbed(offset, delta.insert); - if (delta.attributes) { - targetContent.format(offset, 1, delta.attributes); - } - offset += 1; - } - } - } - - private _createRecordMap( - record: TableColumnSchema | Record, - ): CRDTUnknownMap { - const map = this._adapter.createMap() as CRDTUnknownMap; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) { - map.set(key, value); - } - } - return map; - } } diff --git a/packages/core/src/schema/appHandleImpl.ts b/packages/core/src/schema/appHandleImpl.ts new file mode 100644 index 0000000..4470944 --- /dev/null +++ b/packages/core/src/schema/appHandleImpl.ts @@ -0,0 +1,66 @@ +import type { + AppHandle, + AppPlacement, + BlockHandle, + CRDTDocument, + PenDocument, + SchemaRegistry, +} from "@pen/types"; +import { + crdtMapToPlainRecord, + getMapProp, + isCRDTMap, + type CRDTUnknownMap, +} from "../editor/crdtShapes"; + +type CreateBlockHandle = ( + blockId: string, + doc: PenDocument, + crdtDoc: CRDTDocument, + registry: SchemaRegistry, +) => BlockHandle; + +export class AppHandleImpl implements AppHandle { + constructor( + private readonly _id: string, + private readonly _doc: PenDocument, + private readonly _crdtDoc: CRDTDocument, + private readonly _registry: SchemaRegistry, + private readonly _createBlockHandle: CreateBlockHandle, + ) {} + + get id(): string { + return this._id; + } + + get type(): string { + return this.appMap.get("type") as string; + } + + get placement(): AppPlacement { + return this.appMap.get("placement") as AppPlacement; + } + + get config(): Readonly> { + return crdtMapToPlainRecord(getMapProp(this.appMap, "config")) ?? {}; + } + + get anchorBlock(): BlockHandle | null { + const placement = this.placement; + if (placement && "blockId" in placement && placement.blockId) { + return this._createBlockHandle( + placement.blockId as string, + this._doc, + this._crdtDoc, + this._registry, + ); + } + return null; + } + + private get appMap(): CRDTUnknownMap { + const map = this._doc.apps.get(this._id); + if (!isCRDTMap(map)) throw new Error(`App not found: ${this._id}`); + return map; + } +} diff --git a/packages/core/src/schema/handleValueHelpers.ts b/packages/core/src/schema/handleValueHelpers.ts new file mode 100644 index 0000000..ad9e704 --- /dev/null +++ b/packages/core/src/schema/handleValueHelpers.ts @@ -0,0 +1,229 @@ +import type { + DatabaseViewState, + InlineDelta, + InlineNodeDeltaInsert, + TableColumnSchema, +} from "@pen/types"; +import { + crdtMapToPlainRecord, + crdtValueToPlain, + getArrayProp, + getMapProp, + type CRDTTextLike, + type CRDTUnknownArray, + type CRDTUnknownMap, +} from "../editor/crdtShapes"; + +type TextDelta = { + insert: unknown; + attributes?: Record; +}; + +export function getMapEntries( + map: CRDTUnknownMap | null, +): Iterable<[string, unknown]> { + return map?.entries?.() ?? []; +} + +export function getChildrenArray( + blockMap: CRDTUnknownMap, +): CRDTUnknownArray | null { + return getArrayProp(blockMap, "children"); +} + +export function getPropsMap(blockMap: CRDTUnknownMap): CRDTUnknownMap | null { + return getMapProp(blockMap, "props"); +} + +export function getDeltaFragments(text: CRDTTextLike | null): TextDelta[] { + return typeof text?.toDelta === "function" ? text.toDelta() : []; +} + +function toInlineDeltaInsert(value: unknown): string | InlineNodeDeltaInsert { + if (typeof value === "string") { + return value; + } + if (!value || typeof value !== "object") { + return ""; + } + const record = value as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (!type) { + return ""; + } + const props: Record = {}; + for (const [key, entry] of Object.entries(record)) { + if (key === "type") { + continue; + } + props[key] = entry; + } + return { type, props }; +} + +export function toInlineDeltas(content: CRDTTextLike | null): InlineDelta[] { + if (typeof content?.toDelta !== "function") { + return []; + } + return getDeltaFragments(content).map((delta) => ({ + insert: toInlineDeltaInsert(delta.insert), + ...(delta.attributes ? { attributes: delta.attributes } : {}), + })); +} + +export function arrayValues(array: CRDTUnknownArray): T[] { + return ( + array.toArray?.() ?? + Array.from({ length: array.length }, (_, index) => array.get(index)) + ); +} + +export function resolveText(content: CRDTTextLike): string { + const deltas = getDeltaFragments(content); + let result = ""; + for (const d of deltas) { + if (typeof d.insert !== "string") continue; + const suggestion = d.attributes?.suggestion as + | { action?: string } + | undefined; + if (suggestion?.action === "delete") continue; + result += d.insert; + } + return result; +} + +export function toTableColumnSchema(column: unknown): TableColumnSchema | null { + if (!column || typeof column !== "object") return null; + const mapLike = column as { + get?: (key: string) => unknown; + entries?: () => IterableIterator<[string, unknown]>; + }; + const id = mapLike.get?.("id"); + const title = mapLike.get?.("title"); + const type = mapLike.get?.("type"); + if ( + typeof id !== "string" || + typeof title !== "string" || + typeof type !== "string" + ) { + return null; + } + const options = toPlainArray(mapLike.get?.("options")); + return { + id, + title, + type: type as TableColumnSchema["type"], + width: toNumber(mapLike.get?.("width")), + hidden: toBoolean(mapLike.get?.("hidden")), + pinned: toPinned(mapLike.get?.("pinned")), + options, + format: (toPlainObject(mapLike.get?.("format")) ?? + undefined) as TableColumnSchema["format"], + readonly: toBoolean(mapLike.get?.("readonly")), + }; +} + +export function toDatabaseViewState(view: unknown): DatabaseViewState | null { + if (!view || typeof view !== "object") return null; + const mapLike = view as { + get?: (key: string) => unknown; + }; + const id = mapLike.get?.("id"); + const type = mapLike.get?.("type"); + if (typeof id !== "string" || typeof type !== "string") { + return null; + } + + const filterValue = toPlainObject(mapLike.get?.("filter")); + + return { + id, + title: toString(mapLike.get?.("title")), + type: type as DatabaseViewState["type"], + visibleColumnIds: toStringArray(mapLike.get?.("visibleColumnIds")), + columnOrder: toStringArray(mapLike.get?.("columnOrder")), + sort: toPlainArray(mapLike.get?.("sort")) as DatabaseViewState["sort"], + filter: (filterValue as DatabaseViewState["filter"] | null) ?? null, + groupBy: toNullableString(mapLike.get?.("groupBy")), + rowPinning: toDatabaseRowPinning(mapLike.get?.("rowPinning")), + pageIndex: toNumber(mapLike.get?.("pageIndex")), + pageSize: toNumber(mapLike.get?.("pageSize")), + }; +} + +function toDatabaseRowPinning( + value: unknown, +): DatabaseViewState["rowPinning"] { + if (!value || typeof value !== "object") { + return undefined; + } + const mapLike = value as { + get?: (key: string) => unknown; + }; + const topValues = toStringArray(mapLike.get?.("top")); + const bottomValues = toStringArray(mapLike.get?.("bottom")); + const top = topValues && topValues.length > 0 ? topValues : undefined; + const bottom = + bottomValues && bottomValues.length > 0 ? bottomValues : undefined; + if (!top && !bottom) { + return undefined; + } + return { + top, + bottom, + }; +} + +function toPlainArray(value: unknown): TableColumnSchema["options"] { + if ( + !value || + typeof (value as { toArray?: () => unknown[] }).toArray !== "function" + ) { + return undefined; + } + const items = (value as { toArray: () => unknown[] }).toArray(); + return items + .map((item) => crdtValueToPlain(item)) + .filter((item): item is Record => item !== null) + .map( + (item) => + item as unknown as NonNullable[number], + ); +} + +function toPlainObject(value: unknown): Record | null { + return crdtMapToPlainRecord(value); +} + +function toNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function toString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function toNullableString(value: unknown): string | null | undefined { + if (value === null) return null; + return typeof value === "string" ? value : undefined; +} + +function toStringArray(value: unknown): string[] | undefined { + if ( + !value || + typeof (value as { toArray?: () => unknown[] }).toArray !== "function" + ) { + return undefined; + } + return (value as { toArray: () => unknown[] }) + .toArray() + .filter((entry): entry is string => typeof entry === "string"); +} + +function toBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function toPinned(value: unknown): "left" | "right" | undefined { + return value === "left" || value === "right" ? value : undefined; +} diff --git a/packages/core/src/schema/handles.ts b/packages/core/src/schema/handles.ts index ccf204c..786d406 100644 --- a/packages/core/src/schema/handles.ts +++ b/packages/core/src/schema/handles.ts @@ -1,13 +1,12 @@ import type { + AppHandle, AppPlacement, BlockHandle, - AppHandle, + DatabaseViewState, InlineDelta, - InlineNodeDeltaInsert, TableCellHandle, TableColumnSchema, TableRowHandle, - DatabaseViewState, CRDTDocument, LayoutProps, PenDocument, @@ -15,10 +14,8 @@ import type { } from "@pen/types"; import { crdtMapToPlainRecord, - crdtValueToPlain, - getArrayProp, - getCellMap, getDatabaseViews, + getCellMap, getMapProp, getRowCells, getStringProp, @@ -26,11 +23,21 @@ import { getTableContent, getTextProp, isCRDTMap, - type CRDTTextLike, - type CRDTUnknownArray, type CRDTUnknownMap, - type TableCellMap, } from "../editor/crdtShapes"; +import { AppHandleImpl } from "./appHandleImpl"; +import { + arrayValues, + getChildrenArray, + getDeltaFragments, + getMapEntries, + getPropsMap, + resolveText, + toDatabaseViewState, + toInlineDeltas, + toTableColumnSchema, +} from "./handleValueHelpers"; +import { TableCellHandleImpl } from "./tableCellHandleImpl"; // ── Factory Functions ─────────────────────────────────────── @@ -49,76 +56,13 @@ export function createAppHandle( crdtDoc: CRDTDocument, registry: SchemaRegistry, ): AppHandle { - return new AppHandleImpl(appId, doc, crdtDoc, registry); + return new AppHandleImpl(appId, doc, crdtDoc, registry, createBlockHandle); } // ── BlockHandleImpl ───────────────────────────────────────── const EMPTY_TABLE_COLUMNS: readonly TableColumnSchema[] = []; const EMPTY_DATABASE_VIEWS: readonly DatabaseViewState[] = []; -type TextDelta = { - insert: unknown; - attributes?: Record; -}; - -function getMapEntries( - map: CRDTUnknownMap | null, -): Iterable<[string, unknown]> { - return map?.entries?.() ?? []; -} - -function getChildrenArray( - blockMap: CRDTUnknownMap, -): CRDTUnknownArray | null { - return getArrayProp(blockMap, "children"); -} - -function getPropsMap(blockMap: CRDTUnknownMap): CRDTUnknownMap | null { - return getMapProp(blockMap, "props"); -} - -function getDeltaFragments(text: CRDTTextLike | null): TextDelta[] { - return typeof text?.toDelta === "function" ? text.toDelta() : []; -} - -function toInlineDeltaInsert(value: unknown): string | InlineNodeDeltaInsert { - if (typeof value === "string") { - return value; - } - if (!value || typeof value !== "object") { - return ""; - } - const record = value as Record; - const type = typeof record.type === "string" ? record.type : ""; - if (!type) { - return ""; - } - const props: Record = {}; - for (const [key, entry] of Object.entries(record)) { - if (key === "type") { - continue; - } - props[key] = entry; - } - return { type, props }; -} - -function toInlineDeltas(content: CRDTTextLike | null): InlineDelta[] { - if (typeof content?.toDelta !== "function") { - return []; - } - return getDeltaFragments(content).map((delta) => ({ - insert: toInlineDeltaInsert(delta.insert), - ...(delta.attributes ? { attributes: delta.attributes } : {}), - })); -} - -function arrayValues(array: CRDTUnknownArray): T[] { - return ( - array.toArray?.() ?? - Array.from({ length: array.length }, (_, index) => array.get(index)) - ); -} class TableRowHandleImpl implements TableRowHandle { constructor( @@ -355,6 +299,7 @@ class BlockHandleImpl implements BlockHandle { this._doc, this._crdtDoc, this._registry, + createBlockHandle, ), ); } @@ -370,7 +315,7 @@ class BlockHandleImpl implements BlockHandle { const text = content.toString(); if (text === "\u200B") return ""; if (options?.resolved) { - return this.resolveText(content); + return resolveText(content); } return text; } @@ -482,7 +427,7 @@ class BlockHandleImpl implements BlockHandle { const columns = getTableColumns(this.blockMap); if (!columns) return EMPTY_TABLE_COLUMNS; return arrayValues(columns) - .map((column) => this.toTableColumnSchema(column)) + .map((column) => toTableColumnSchema(column)) .filter((column): column is TableColumnSchema => column !== null); } @@ -491,7 +436,7 @@ class BlockHandleImpl implements BlockHandle { const views = getDatabaseViews(this.blockMap); if (!views) return EMPTY_DATABASE_VIEWS; return arrayValues(views) - .map((view) => this.toDatabaseViewState(view)) + .map((view) => toDatabaseViewState(view)) .filter((view): view is DatabaseViewState => view !== null); } @@ -518,168 +463,6 @@ class BlockHandleImpl implements BlockHandle { return this.type === "table" || this.type === "database"; } - private resolveText(content: CRDTTextLike): string { - const deltas = getDeltaFragments(content); - let result = ""; - for (const d of deltas) { - if (typeof d.insert !== "string") continue; - const suggestion = d.attributes?.suggestion as - | { action?: string } - | undefined; - if (suggestion?.action === "delete") continue; - result += d.insert; - } - return result; - } - - private toTableColumnSchema(column: unknown): TableColumnSchema | null { - if (!column || typeof column !== "object") return null; - const mapLike = column as { - get?: (key: string) => unknown; - entries?: () => IterableIterator<[string, unknown]>; - }; - const id = mapLike.get?.("id"); - const title = mapLike.get?.("title"); - const type = mapLike.get?.("type"); - if ( - typeof id !== "string" || - typeof title !== "string" || - typeof type !== "string" - ) { - return null; - } - const options = this.toPlainArray(mapLike.get?.("options")); - return { - id, - title, - type: type as TableColumnSchema["type"], - width: this.toNumber(mapLike.get?.("width")), - hidden: this.toBoolean(mapLike.get?.("hidden")), - pinned: this.toPinned(mapLike.get?.("pinned")), - options, - format: (this.toPlainObject(mapLike.get?.("format")) ?? - undefined) as TableColumnSchema["format"], - readonly: this.toBoolean(mapLike.get?.("readonly")), - }; - } - - private toDatabaseViewState(view: unknown): DatabaseViewState | null { - if (!view || typeof view !== "object") return null; - const mapLike = view as { - get?: (key: string) => unknown; - }; - const id = mapLike.get?.("id"); - const type = mapLike.get?.("type"); - if (typeof id !== "string" || typeof type !== "string") { - return null; - } - - const filterValue = this.toPlainObject(mapLike.get?.("filter")); - - return { - id, - title: this.toString(mapLike.get?.("title")), - type: type as DatabaseViewState["type"], - visibleColumnIds: this.toStringArray( - mapLike.get?.("visibleColumnIds"), - ), - columnOrder: this.toStringArray(mapLike.get?.("columnOrder")), - sort: this.toPlainArray( - mapLike.get?.("sort"), - ) as DatabaseViewState["sort"], - filter: (filterValue as DatabaseViewState["filter"] | null) ?? null, - groupBy: this.toNullableString(mapLike.get?.("groupBy")), - rowPinning: this.toDatabaseRowPinning(mapLike.get?.("rowPinning")), - pageIndex: this.toNumber(mapLike.get?.("pageIndex")), - pageSize: this.toNumber(mapLike.get?.("pageSize")), - }; - } - - private toDatabaseRowPinning( - value: unknown, - ): DatabaseViewState["rowPinning"] { - if (!value || typeof value !== "object") { - return undefined; - } - const mapLike = value as { - get?: (key: string) => unknown; - }; - const topValues = this.toStringArray(mapLike.get?.("top")); - const bottomValues = this.toStringArray(mapLike.get?.("bottom")); - const top = topValues && topValues.length > 0 ? topValues : undefined; - const bottom = - bottomValues && bottomValues.length > 0 ? bottomValues : undefined; - if (!top && !bottom) { - return undefined; - } - return { - top, - bottom, - }; - } - - private toPlainArray(value: unknown): TableColumnSchema["options"] { - if ( - !value || - typeof (value as { toArray?: () => unknown[] }).toArray !== - "function" - ) { - return undefined; - } - const items = (value as { toArray: () => unknown[] }).toArray(); - return items - .map((item) => this.toPlainValue(item)) - .filter((item): item is Record => item !== null) - .map( - (item) => - item as unknown as NonNullable< - TableColumnSchema["options"] - >[number], - ); - } - - private toPlainObject(value: unknown): Record | null { - return crdtMapToPlainRecord(value); - } - - private toPlainValue(value: unknown): unknown { - return crdtValueToPlain(value); - } - - private toNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; - } - - private toString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; - } - - private toNullableString(value: unknown): string | null | undefined { - if (value === null) return null; - return typeof value === "string" ? value : undefined; - } - - private toStringArray(value: unknown): string[] | undefined { - if ( - !value || - typeof (value as { toArray?: () => unknown[] }).toArray !== - "function" - ) { - return undefined; - } - return (value as { toArray: () => unknown[] }) - .toArray() - .filter((entry): entry is string => typeof entry === "string"); - } - - private toBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; - } - - private toPinned(value: unknown): "left" | "right" | undefined { - return value === "left" || value === "right" ? value : undefined; - } - private get blockMap(): CRDTUnknownMap { const map = this._doc.blocks.get(this._id); if (!isCRDTMap(map)) throw new Error(`Block not found: ${this._id}`); @@ -687,108 +470,3 @@ class BlockHandleImpl implements BlockHandle { } } -// ── AppHandleImpl ─────────────────────────────────────────── - -class AppHandleImpl implements AppHandle { - constructor( - private readonly _id: string, - private readonly _doc: PenDocument, - private readonly _crdtDoc: CRDTDocument, - private readonly _registry: SchemaRegistry, - ) {} - - get id(): string { - return this._id; - } - - get type(): string { - return this.appMap.get("type") as string; - } - - get placement(): AppPlacement { - return this.appMap.get("placement") as AppPlacement; - } - - get config(): Readonly> { - return crdtMapToPlainRecord(getMapProp(this.appMap, "config")) ?? {}; - } - - get anchorBlock(): BlockHandle | null { - const placement = this.placement; - if (placement && "blockId" in placement && placement.blockId) { - return createBlockHandle( - placement.blockId as string, - this._doc, - this._crdtDoc, - this._registry, - ); - } - return null; - } - - private get appMap(): CRDTUnknownMap { - const map = this._doc.apps.get(this._id); - if (!isCRDTMap(map)) throw new Error(`App not found: ${this._id}`); - return map; - } -} - -// ── TableCellHandleImpl ──────────────────────────────────── - -class TableCellHandleImpl implements TableCellHandle { - constructor( - private readonly _cellMap: TableCellMap, - private readonly _row: number, - private readonly _col: number, - ) {} - - get id(): string { - return getStringProp(this._cellMap, "id") ?? ""; - } - - get row(): number { - return this._row; - } - - get col(): number { - return this._col; - } - - textContent(): string { - const content = getTextProp(this._cellMap, "content"); - if (content) { - const text = content.toString(); - if (text === "\u200B") return ""; - return text; - } - return ""; - } - - length(): number { - const content = getTextProp(this._cellMap, "content"); - if (typeof content?.toDelta === "function") { - return getDeltaFragments(content).reduce((total: number, delta) => { - if (typeof delta.insert === "string") { - return total + delta.insert.length; - } - return total + 1; - }, 0); - } - return this.textContent().length; - } - - inlineDeltas(): InlineDelta[] { - const content = getTextProp(this._cellMap, "content"); - return toInlineDeltas(content); - } - - textDeltas(): Array<{ - insert: string; - attributes?: Record; - }> { - return this.inlineDeltas().map((delta) => ({ - insert: typeof delta.insert === "string" ? delta.insert : "", - ...(delta.attributes ? { attributes: delta.attributes } : {}), - })); - } -} diff --git a/packages/core/src/schema/tableCellHandleImpl.ts b/packages/core/src/schema/tableCellHandleImpl.ts new file mode 100644 index 0000000..0f72122 --- /dev/null +++ b/packages/core/src/schema/tableCellHandleImpl.ts @@ -0,0 +1,65 @@ +import type { InlineDelta, TableCellHandle } from "@pen/types"; +import { + getStringProp, + getTextProp, + type TableCellMap, +} from "../editor/crdtShapes"; +import { getDeltaFragments, toInlineDeltas } from "./handleValueHelpers"; + +export class TableCellHandleImpl implements TableCellHandle { + constructor( + private readonly _cellMap: TableCellMap, + private readonly _row: number, + private readonly _col: number, + ) {} + + get id(): string { + return getStringProp(this._cellMap, "id") ?? ""; + } + + get row(): number { + return this._row; + } + + get col(): number { + return this._col; + } + + textContent(): string { + const content = getTextProp(this._cellMap, "content"); + if (content) { + const text = content.toString(); + if (text === "\u200B") return ""; + return text; + } + return ""; + } + + length(): number { + const content = getTextProp(this._cellMap, "content"); + if (typeof content?.toDelta === "function") { + return getDeltaFragments(content).reduce((total: number, delta) => { + if (typeof delta.insert === "string") { + return total + delta.insert.length; + } + return total + 1; + }, 0); + } + return this.textContent().length; + } + + inlineDeltas(): InlineDelta[] { + const content = getTextProp(this._cellMap, "content"); + return toInlineDeltas(content); + } + + textDeltas(): Array<{ + insert: string; + attributes?: Record; + }> { + return this.inlineDeltas().map((delta) => ({ + insert: typeof delta.insert === "string" ? delta.insert : "", + ...(delta.attributes ? { attributes: delta.attributes } : {}), + })); + } +} diff --git a/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts b/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts index 03d1b4b..c74335c 100644 --- a/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts +++ b/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts @@ -20,6 +20,8 @@ describe("extensionRoots", () => { }); expect(root.map.get("version")).toBe(1); + expect(doc.getMap("extensionRoots").get("example.tags")).toBe(root.map); + expect(doc.getMap("apps").get("example.tags")).toBeUndefined(); expect(root.map.get("title")).toBeInstanceOf(Y.Text); expect(root.map.get("tags")).toBeInstanceOf(Y.Array); expect(root.map.get("settings")).toBeInstanceOf(Y.Map); diff --git a/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts b/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts index 7a6744a..4293e3f 100644 --- a/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts +++ b/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts @@ -16,6 +16,10 @@ describe("fieldAdapters", () => { it("reads, writes, normalizes, and observes Y.Text fields", () => { const doc = new Y.Doc(); const root = doc.getMap("fields"); + const origins: unknown[] = []; + doc.on("afterTransaction", (transaction) => { + origins.push(transaction.origin); + }); const onChange = vi.fn(); const field = createYTextFieldAdapter({ doc, @@ -27,6 +31,7 @@ describe("fieldAdapters", () => { const unsubscribe = field.observe(onChange); field.replace(" Hello "); + expect(origins).toContain("pen:y-text-field:title:ensure"); expect(field.read()).toBe("Hello"); expect(onChange).toHaveBeenCalledTimes(1); unsubscribe(); @@ -104,4 +109,28 @@ describe("fieldAdapters", () => { field.update("a", { label: "A++" }); expect(onChange).toHaveBeenCalledTimes(1); }); + + it("fails safely when existing fields have the wrong Yjs type", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + root.set("title", new Y.Array()); + root.set("items", new Y.Text()); + + expect(() => + createYTextFieldAdapter({ + doc, + root, + key: "title", + }), + ).toThrow('Yjs field "title" exists but is not text'); + + expect(() => + createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + }), + ).toThrow('Yjs field "items" exists but is not array'); + }); }); diff --git a/packages/crdt/yjs/src/extensionRoots.ts b/packages/crdt/yjs/src/extensionRoots.ts index b767802..ab0584d 100644 --- a/packages/crdt/yjs/src/extensionRoots.ts +++ b/packages/crdt/yjs/src/extensionRoots.ts @@ -32,7 +32,7 @@ export class YjsExtensionRootError extends Error { } } -const DEFAULT_ROOT_NAME = "apps"; +const DEFAULT_ROOT_NAME = "extensionRoots"; const VERSION_KEY = "version"; export function ensureExtensionRoot( diff --git a/packages/crdt/yjs/src/fieldAdapters.ts b/packages/crdt/yjs/src/fieldAdapters.ts index 37b4566..2951ab9 100644 --- a/packages/crdt/yjs/src/fieldAdapters.ts +++ b/packages/crdt/yjs/src/fieldAdapters.ts @@ -37,10 +37,17 @@ export interface CreateYArrayFieldAdapterOptions { origin?: unknown; } +export class YjsFieldAdapterError extends Error { + constructor(message: string) { + super(message); + this.name = "YjsFieldAdapterError"; + } +} + export function createYTextFieldAdapter( options: CreateYTextFieldAdapterOptions, ): YTextFieldAdapter { - const text = ensureYText(options.root, options.key); + const text = ensureYText(options); return { read() { @@ -66,7 +73,7 @@ export function createYTextFieldAdapter( export function createYArrayFieldAdapter( options: CreateYArrayFieldAdapterOptions, ): YArrayFieldAdapter { - const array = ensureYArray>(options.root, options.key); + const array = ensureYArray(options); const readItem = options.fromYMap ?? defaultFromYMap; const serializeItem = options.toYMap ?? defaultToYMap; const writeItem = (item: T) => @@ -144,23 +151,50 @@ export function createYArrayFieldAdapter( }; } -function ensureYText(root: Y.Map, key: string): Y.Text { - const current = root.get(key); - if (current instanceof Y.Text) { - return current; +function ensureYText(options: CreateYTextFieldAdapterOptions): Y.Text { + const current = options.root.get(options.key); + if (current !== undefined) { + if (current instanceof Y.Text) { + return current; + } + throw new YjsFieldAdapterError( + `Yjs field "${options.key}" exists but is not text.`, + ); } + const next = new Y.Text(); - root.set(key, next); + options.doc.transact( + () => { + options.root.set(options.key, next); + }, + options.origin ?? `pen:y-text-field:${options.key}:ensure`, + ); return next; } -function ensureYArray(root: Y.Map, key: string): Y.Array { - const current = root.get(key); - if (current instanceof Y.Array) { - return current as Y.Array; +function ensureYArray(options: { + doc: Y.Doc; + root: Y.Map; + key: string; + origin?: unknown; +}): Y.Array> { + const current = options.root.get(options.key); + if (current !== undefined) { + if (current instanceof Y.Array) { + return current as Y.Array>; + } + throw new YjsFieldAdapterError( + `Yjs field "${options.key}" exists but is not array.`, + ); } - const next = new Y.Array(); - root.set(key, next); + + const next = new Y.Array>(); + options.doc.transact( + () => { + options.root.set(options.key, next); + }, + options.origin ?? `pen:y-array-field:${options.key}:ensure`, + ); return next; } diff --git a/packages/crdt/yjs/src/undo.ts b/packages/crdt/yjs/src/undo.ts index 6ff18a1..cdc2406 100644 --- a/packages/crdt/yjs/src/undo.ts +++ b/packages/crdt/yjs/src/undo.ts @@ -1,7 +1,6 @@ import type { CRDTUndoManager, CRDTUndoStackItem, - OpOrigin, UndoManagerOptions, } from "@pen/types"; import { HISTORY_ORIGIN_TAG } from "@pen/types"; @@ -14,8 +13,8 @@ export function createYjsUndoManager( options?: UndoManagerOptions, ): CRDTUndoManager { const { blockOrder, blocks } = doc.penDocument; - const trackedOrigins = new Set( - options?.trackedOrigins ?? ["user", "ai"], + const trackedOrigins = new Set( + options?.trackedOriginTypes ?? ["user", "ai"], ); const undoManager = new Y.UndoManager([blockOrder, blocks], { diff --git a/packages/extensions/ai-autocomplete/package.json b/packages/extensions/ai-autocomplete/package.json index 56c118e..d8a8bac 100644 --- a/packages/extensions/ai-autocomplete/package.json +++ b/packages/extensions/ai-autocomplete/package.json @@ -36,7 +36,7 @@ "README.md", "LICENSE.md" ], - "sideEffects": false, + "sideEffects": true, "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts new file mode 100644 index 0000000..8be6808 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("accepts the whole visible suggestion and places the caret at the end", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot( + FIELD_EDITOR_SLOT_KEY, + fieldEditor, + ); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller).toBeTruthy(); + expect(inlineCompletion).toBeTruthy(); + + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " world from pen", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); + expect(editor.selection).toMatchObject({ + type: "text", + isCollapsed: true, + focus: { + blockId, + offset: 20, + }, + }); + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + editor.destroy(); + }); + + it("anchors end-of-line suggestions to the previous character for rendering", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world!" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world!", + ); + + expect(inlineCompletion?.buildDecorations()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "inline", + blockId, + from: 4, + to: 5, + attributes: expect.objectContaining({ + "data-suggestion-placement": "after", + }), + }), + ]), + ); + + editor.destroy(); + }); + + it("adds a separating space to prose suggestions when the model omits it", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "today, with more detail" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); + editor.selectText(blockId, 11, 11); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " today, with more detail", + ); + + editor.destroy(); + }); + + it("does not split a short partial word when normalizing prose suggestions", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "nd timeline" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "a" }]); + editor.selectText(blockId, 1, 1); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === "nd timeline", + ); + + editor.destroy(); + }); + + it("rejects tiny single-word prose suggestions", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "go" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); + editor.selectText(blockId, 11, 11); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition(() => controller?.getState().status === "idle"); + + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + expect(controller?.getState().visibleSuggestionId).toBeNull(); + + editor.destroy(); + }); + + it("accepts the full remaining completion in one step when full acceptance is enabled", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + acceptanceStrategy: "full", + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + expect(controller?.getState().settings.acceptanceStrategy).toBe("full"); + expect(controller?.acceptVisibleSuggestion()).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + expect(controller?.getState().metrics.acceptCount).toBe(1); + + editor.destroy(); + }); + + it("keeps scheduled requests alive across selection sync events", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 10, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request()).toBe(true); + expect(controller?.getState().status).toBe("scheduled"); + + editor.selectText(blockId, 5, 5); + + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + expect(controller?.getState().metrics.successCount).toBe(1); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts new file mode 100644 index 0000000..d880651 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("dismisses visible suggestions when the selection changes after showing", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + editor.selectText(blockId, 0, 0); + + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "selection-change", + ); + + editor.destroy(); + }); + + it("keeps visible suggestions when selection-change keeps the same caret", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + editor.selectText(blockId, 5, 5); + + expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( + " world from pen", + ); + expect(controller?.getState().visibleSuggestionId).not.toBeNull(); + expect(controller?.getState().status).toBe("showing"); + + editor.destroy(); + }); + + it("drops stale results and records the stale dismissal reason", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + staleAfterMs: 1, + model: { + async *stream() { + await new Promise((resolve) => setTimeout(resolve, 5)); + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => controller?.getState().metrics.staleDropCount === 1, + ); + + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().diagnostics.lastDismissReason).toBe("stale"); + + editor.destroy(); + }); + + it("blocks requests in code blocks when the block policy disables them", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowInCodeBlocks: false, + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " never runs" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(false); + expect(modelCalled).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + + editor.destroy(); + }); + + it("respects allowed block type policies before scheduling a request", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowedBlockTypes: ["heading"], + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " blocked" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(false); + expect(modelCalled).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "block-type-not-allowed", + ); + + editor.destroy(); + }); + + it("updates block policy at runtime without recreating the controller", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowInCodeBlocks: false, + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(false); + expect(controller?.request({ explicit: true })).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + + controller?.updateBlockPolicy({ allowInCodeBlocks: true }); + expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(true); + expect(controller?.getBlockPolicy().allowInCodeBlocks).toBe(true); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition(() => modelCalled); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts new file mode 100644 index 0000000..9e47707 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("cancels a scheduled request when runtime policy becomes ineligible", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 50, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request()).toBe(true); + expect(controller?.getState().status).toBe("scheduled"); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + await new Promise((resolve) => setTimeout(resolve, 70)); + + expect(modelCalled).toBe(false); + expect(controller?.getState().status).toBe("idle"); + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( + "scheduled", + ); + expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(1); + expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); + + controller?.updateBlockPolicy({ allowInCodeBlocks: true }); + expect(controller?.request({ explicit: true })).toBe(true); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBeNull(); + + editor.destroy(); + }); + + it("cancels an in-flight request when runtime policy becomes ineligible", async () => { + let activeEditor: ReturnType | null = null; + let streamStarted = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + streamStarted = true; + await new Promise((resolve) => setTimeout(resolve, 20)); + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition(() => streamStarted); + expect(controller?.getState().status).toBe("requesting"); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(controller?.getState().status).toBe("idle"); + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().metrics.successCount).toBe(0); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( + "requesting", + ); + expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(1); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); + + editor.destroy(); + }); + + it("dismisses a visible suggestion when runtime policy becomes ineligible", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => controller?.getState().visibleSuggestionId !== null, + ); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().status).toBe("idle"); + expect(controller?.hasVisibleSuggestion()).toBe(false); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( + "showing", + ); + expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(1); + + const controllerImpl = controller as unknown as { + _state: { + blockPolicy: { + allowInCodeBlocks?: boolean; + allowInTables?: boolean; + allowedBlockTypes?: readonly string[]; + deniedBlockTypes?: readonly string[]; + }; + }; + _continuation: { + setSequence(sequence: { + requestId: string; + blockId: string; + startOffset: number; + candidate: { + rawText: string; + inlineText: string; + appendedBlocks: readonly []; + previewBlocks: readonly []; + }; + continuationDepth: number; + }): void; + }; + _setState: (nextState: { + status: "showing"; + activeRequestId: string; + visibleSuggestionId: string; + }) => void; + }; + controllerImpl._state.blockPolicy = { + ...controller!.getBlockPolicy(), + allowInCodeBlocks: false, + }; + controllerImpl._continuation.setSequence({ + requestId: "manual-policy-recheck", + blockId: codeBlockId, + startOffset: 14, + candidate: { + rawText: " value", + inlineText: " value", + appendedBlocks: [], + previewBlocks: [], + }, + continuationDepth: 0, + }); + controllerImpl._setState({ + status: "showing", + activeRequestId: "manual-policy-recheck", + visibleSuggestionId: "manual-policy-recheck", + }); + expect(controller?.acceptVisibleSuggestion()).toBe(false); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(2); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + + editor.destroy(); + }); + + it("blocks table-cell autocomplete when tables are disabled", () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: { blockId: "table-1", row: 0, col: 0 }, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowInTables: false, + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " cell" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + + editor.apply([ + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + fieldEditor.focusBlockId = "table-1"; + editor.selectText("table-1", 0, 0); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(false); + expect(modelCalled).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "table-disabled", + ); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts new file mode 100644 index 0000000..ef0702a --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("returns defensive block policy snapshots from both getters", () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + blockPolicy: { + allowedBlockTypes: ["paragraph"], + deniedBlockTypes: ["database"], + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + + const controller = getAutocompleteController(editor); + const snapshot = controller?.getBlockPolicy(); + const stateSnapshot = controller?.getState(); + expect(snapshot).toEqual({ + allowInCodeBlocks: true, + allowInTables: false, + allowedBlockTypes: ["paragraph"], + deniedBlockTypes: ["database"], + }); + + expect(() => { + if (snapshot?.allowedBlockTypes) { + (snapshot.allowedBlockTypes as string[]).push("heading"); + } + }).toThrow(); + expect(() => { + if (stateSnapshot?.blockPolicy.allowedBlockTypes) { + (stateSnapshot.blockPolicy.allowedBlockTypes as string[]).push("callout"); + } + }).toThrow(); + + expect(controller?.getBlockPolicy().allowedBlockTypes).toEqual(["paragraph"]); + expect(controller?.getState().blockPolicy.allowedBlockTypes).toEqual([ + "paragraph", + ]); + expect(stateSnapshot?.diagnostics.lastPolicyInvalidationStage).toBeNull(); + expect(stateSnapshot?.metrics.policyInvalidationScheduledCount).toBe(0); + + editor.destroy(); + }); + + it("returns stable cached snapshots until controller state changes", () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + blockPolicy: { + allowedBlockTypes: ["paragraph"], + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + + const controller = getAutocompleteController(editor); + const firstSnapshot = controller?.getSnapshot(); + const secondSnapshot = controller?.getSnapshot(); + const firstState = controller?.getState(); + const secondState = controller?.getState(); + const firstPolicy = controller?.getBlockPolicy(); + const secondPolicy = controller?.getBlockPolicy(); + const firstProviders = controller?.listProviderDescriptors(); + const secondProviders = controller?.listProviderDescriptors(); + + expect(firstSnapshot).toBe(secondSnapshot); + expect(firstState).toBe(secondState); + expect(firstPolicy).toBe(secondPolicy); + expect(firstProviders).toBe(secondProviders); + expect(firstSnapshot?.state).toBe(firstState); + expect(firstSnapshot?.state.blockPolicy).toBe(firstPolicy); + expect(firstSnapshot?.providerDescriptors).toBe(firstProviders); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + + const thirdSnapshot = controller?.getSnapshot(); + const thirdState = controller?.getState(); + const thirdPolicy = controller?.getBlockPolicy(); + + expect(thirdSnapshot).not.toBe(firstSnapshot); + expect(thirdState).not.toBe(firstState); + expect(thirdPolicy).not.toBe(firstPolicy); + expect(thirdSnapshot?.state).toBe(thirdState); + expect(thirdSnapshot?.state.blockPolicy).toBe(thirdPolicy); + expect(thirdSnapshot?.providerDescriptors).toBe(firstProviders); + expect(thirdState?.blockPolicy.allowInCodeBlocks).toBe(false); + expect(thirdPolicy?.allowInCodeBlocks).toBe(false); + + editor.destroy(); + }); + + it("prefetches a continuation after accepting the current suggestion", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + const requestModes: Array = []; + let secondPrompt = ""; + let thirdPrompt = ""; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream(options) { + callCount += 1; + requestModes.push(options.requestMode); + if (callCount === 1) { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + return; + } + if (callCount === 2) { + secondPrompt = String(options.messages[1]?.content ?? ""); + yield { + type: "text-delta" as const, + delta: + ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + }; + yield { type: "done" as const }; + return; + } + if (callCount === 3) { + thirdPrompt = String(options.messages[1]?.content ?? ""); + yield { + type: "text-delta" as const, + delta: + " The photos alone could fill a journal.\n\nYou should turn the trip into a full essay while the details are still vivid.\n\nStart with the beach at sunset and the best meal of the week.", + }; + yield { type: "done" as const }; + return; + } + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + ); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); + expect(secondPrompt).toContain('prefix="Hello world from pen"'); + expect(secondPrompt).toContain("target_scope=finish-paragraph"); + expect(requestModes).toEqual([ + "inline-autocomplete", + "inline-autocomplete", + ]); + expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( + ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " The photos alone could fill a journal.", + ); + expect(editor.getBlock(blockId)?.textContent()).toBe( + "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + ); + expect(thirdPrompt).toContain( + 'prefix="Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell."', + ); + expect(thirdPrompt).toContain("target_scope=continue-across-paragraphs"); + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "You should turn the trip into a full essay while the details are still vivid.", + blockType: "paragraph", + }), + expect.objectContaining({ + text: "Start with the beach at sunset and the best meal of the week.", + blockType: "paragraph", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(secondBlock).toBeTruthy(); + expect(thirdBlock).toBeTruthy(); + expect(editor.getBlock(blockId)?.textContent()).toBe( + "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell. The photos alone could fill a journal.", + ); + expect(secondBlock?.textContent()).toBe( + "You should turn the trip into a full essay while the details are still vivid.", + ); + expect(thirdBlock?.textContent()).toBe( + "Start with the beach at sunset and the best meal of the week.", + ); + expect(editor.selection).toMatchObject({ + type: "text", + isCollapsed: true, + focus: { + blockId: thirdBlock?.id, + offset: 61, + }, + }); + expect(requestModes).toEqual([ + "inline-autocomplete", + "inline-autocomplete", + "inline-autocomplete", + ]); + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + + editor.destroy(); + }); + + it("accepts markdown continuation tails as structured blocks", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: " with a plan\n- Book flights\n- Reserve the hotel", + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip" }]); + editor.selectText(blockId, 4, 4); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " with a plan", + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "Book flights", + blockType: "bulletListItem", + }), + expect.objectContaining({ + text: "Reserve the hotel", + blockType: "bulletListItem", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(editor.getBlock(blockId)?.textContent()).toBe("Trip with a plan"); + expect(secondBlock?.type).toBe("bulletListItem"); + expect(secondBlock?.textContent()).toBe("Book flights"); + expect(thirdBlock?.type).toBe("bulletListItem"); + expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); + expect(editor.selection).toMatchObject({ + type: "text", + isCollapsed: true, + focus: { + blockId: thirdBlock?.id, + offset: 17, + }, + }); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts new file mode 100644 index 0000000..0e417d6 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("preserves a leading newline when a continuation starts with markdown blocks", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "\n- Book flights\n- Reserve the hotel", + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); + editor.selectText(blockId, 9, 9); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 2, + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe(""); + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "Book flights", + blockType: "bulletListItem", + }), + expect.objectContaining({ + text: "Reserve the hotel", + blockType: "bulletListItem", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan"); + expect(secondBlock?.type).toBe("bulletListItem"); + expect(secondBlock?.textContent()).toBe("Book flights"); + expect(thirdBlock?.type).toBe("bulletListItem"); + expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); + + editor.destroy(); + }); + + it("builds continuation context from the newly inserted block after structured accept", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + let secondPrompt = ""; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream(options) { + callCount += 1; + if (callCount === 1) { + yield { + type: "text-delta" as const, + delta: "\n- Book flights", + }; + yield { type: "done" as const }; + return; + } + if (callCount === 2) { + secondPrompt = String(options.messages[1]?.content ?? ""); + yield { + type: "text-delta" as const, + delta: "\n- Reserve the hotel", + }; + yield { type: "done" as const }; + return; + } + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); + editor.selectText(blockId, 9, 9); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 1, + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.[0]?.text === + "Reserve the hotel", + ); + + expect(secondPrompt).toContain("block_type=bulletListItem"); + expect(secondPrompt).toContain('prefix="Book flights"'); + + editor.destroy(); + }); + + it("treats multiline prose continuations as appended paragraph blocks", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: + " with notes\nBook flights this week.\nReserve the hotel before Friday.", + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); + editor.selectText(blockId, 9, 9); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " with notes", + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "Book flights this week.", + blockType: "paragraph", + }), + expect.objectContaining({ + text: "Reserve the hotel before Friday.", + blockType: "paragraph", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan with notes"); + expect(secondBlock?.type).toBe("paragraph"); + expect(secondBlock?.textContent()).toBe("Book flights this week."); + expect(thirdBlock?.type).toBe("paragraph"); + expect(thirdBlock?.textContent()).toBe("Reserve the hotel before Friday."); + + editor.destroy(); + }); + + it("converts deep single-line prose continuations into appended paragraph blocks", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream() { + callCount += 1; + if (callCount === 1) { + yield { + type: "text-delta" as const, + delta: " find his family waiting for him.", + }; + yield { type: "done" as const }; + return; + } + if (callCount === 2) { + yield { + type: "text-delta" as const, + delta: + ", but they were not the welcoming party he had expected. Instead, he found them in a state of distress, with worried expressions on their faces.", + }; + yield { type: "done" as const }; + return; + } + yield { + type: "text-delta" as const, + delta: + ' He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers. For a moment, no one spoke, and the silence made the room feel even heavier. Then his mother stepped forward and told him everything that had changed while he was away.', + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ + type: "insert-text", + blockId, + offset: 0, + text: "He came home to", + }]); + editor.selectText(blockId, 16, 16); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " find his family waiting for him.", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text?.includes( + "welcoming party", + ) === true, + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) > 0, + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: expect.stringContaining( + "For a moment, no one spoke, and the silence made the room feel even heavier.", + ), + blockType: "paragraph", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(secondBlock?.type).toBe("paragraph"); + expect(secondBlock?.textContent()).toContain( + "Instead, he found them in a state of distress, with worried expressions on their faces.", + ); + expect(secondBlock?.textContent()).toContain( + 'He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers.', + ); + expect(thirdBlock?.type).toBe("paragraph"); + expect(thirdBlock?.textContent()).toContain( + "For a moment, no one spoke, and the silence made the room feel even heavier.", + ); + expect(thirdBlock?.textContent()).toContain( + "Then his mother stepped forward and told him everything that had changed while he was away.", + ); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts new file mode 100644 index 0000000..85c31c7 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("promotes long depth-two prose continuations into a new paragraph earlier", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream() { + callCount += 1; + if (callCount === 1) { + yield { + type: "text-delta" as const, + delta: '", tired from a long day at work."', + }; + yield { type: "done" as const }; + return; + } + yield { + type: "text-delta" as const, + delta: + '", but happy to be back. He looked forward to a quiet evening at home, away from the hustle and bustle of the office."', + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ + type: "insert-text", + blockId, + offset: 0, + text: "He came home ", + }]); + editor.selectText(blockId, 13, 13); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + "tired from a long day at work.", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) === 1, + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + blockType: "paragraph", + text: expect.stringContaining( + "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", + ), + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + expect(secondBlock?.type).toBe("paragraph"); + expect(secondBlock?.textContent()).toContain( + "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", + ); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts index ab33f7e..b61dde4 100644 --- a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts @@ -405,1883 +405,4 @@ describe("@pen/ai-autocomplete", () => { editor.destroy(); }); - it("accepts the whole visible suggestion and places the caret at the end", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot( - FIELD_EDITOR_SLOT_KEY, - fieldEditor, - ); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello" }, - ]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller).toBeTruthy(); - expect(inlineCompletion).toBeTruthy(); - - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " world from pen", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); - expect(editor.selection).toMatchObject({ - type: "text", - isCollapsed: true, - focus: { - blockId, - offset: 20, - }, - }); - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - editor.destroy(); - }); - - it("anchors end-of-line suggestions to the previous character for rendering", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world!" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world!", - ); - - expect(inlineCompletion?.buildDecorations()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "inline", - blockId, - from: 4, - to: 5, - attributes: expect.objectContaining({ - "data-suggestion-placement": "after", - }), - }), - ]), - ); - - editor.destroy(); - }); - - it("adds a separating space to prose suggestions when the model omits it", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "today, with more detail" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); - editor.selectText(blockId, 11, 11); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " today, with more detail", - ); - - editor.destroy(); - }); - - it("does not split a short partial word when normalizing prose suggestions", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "nd timeline" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "a" }]); - editor.selectText(blockId, 1, 1); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === "nd timeline", - ); - - editor.destroy(); - }); - - it("rejects tiny single-word prose suggestions", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "go" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); - editor.selectText(blockId, 11, 11); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition(() => controller?.getState().status === "idle"); - - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - expect(controller?.getState().visibleSuggestionId).toBeNull(); - - editor.destroy(); - }); - - it("accepts the full remaining completion in one step when full acceptance is enabled", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - acceptanceStrategy: "full", - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - expect(controller?.getState().settings.acceptanceStrategy).toBe("full"); - expect(controller?.acceptVisibleSuggestion()).toBe(true); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - expect(controller?.getState().metrics.acceptCount).toBe(1); - - editor.destroy(); - }); - - it("keeps scheduled requests alive across selection sync events", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 10, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request()).toBe(true); - expect(controller?.getState().status).toBe("scheduled"); - - editor.selectText(blockId, 5, 5); - - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - expect(controller?.getState().metrics.successCount).toBe(1); - - editor.destroy(); - }); - - it("dismisses visible suggestions when the selection changes after showing", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - editor.selectText(blockId, 0, 0); - - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "selection-change", - ); - - editor.destroy(); - }); - - it("keeps visible suggestions when selection-change keeps the same caret", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - editor.selectText(blockId, 5, 5); - - expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( - " world from pen", - ); - expect(controller?.getState().visibleSuggestionId).not.toBeNull(); - expect(controller?.getState().status).toBe("showing"); - - editor.destroy(); - }); - - it("drops stale results and records the stale dismissal reason", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - staleAfterMs: 1, - model: { - async *stream() { - await new Promise((resolve) => setTimeout(resolve, 5)); - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => controller?.getState().metrics.staleDropCount === 1, - ); - - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().diagnostics.lastDismissReason).toBe("stale"); - - editor.destroy(); - }); - - it("blocks requests in code blocks when the block policy disables them", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowInCodeBlocks: false, - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " never runs" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(false); - expect(modelCalled).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - - editor.destroy(); - }); - - it("respects allowed block type policies before scheduling a request", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowedBlockTypes: ["heading"], - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " blocked" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(false); - expect(modelCalled).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "block-type-not-allowed", - ); - - editor.destroy(); - }); - - it("updates block policy at runtime without recreating the controller", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowInCodeBlocks: false, - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(false); - expect(controller?.request({ explicit: true })).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - - controller?.updateBlockPolicy({ allowInCodeBlocks: true }); - expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(true); - expect(controller?.getBlockPolicy().allowInCodeBlocks).toBe(true); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition(() => modelCalled); - - editor.destroy(); - }); - - it("cancels a scheduled request when runtime policy becomes ineligible", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 50, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request()).toBe(true); - expect(controller?.getState().status).toBe("scheduled"); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - await new Promise((resolve) => setTimeout(resolve, 70)); - - expect(modelCalled).toBe(false); - expect(controller?.getState().status).toBe("idle"); - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( - "scheduled", - ); - expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(1); - expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); - - controller?.updateBlockPolicy({ allowInCodeBlocks: true }); - expect(controller?.request({ explicit: true })).toBe(true); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBeNull(); - - editor.destroy(); - }); - - it("cancels an in-flight request when runtime policy becomes ineligible", async () => { - let activeEditor: ReturnType | null = null; - let streamStarted = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - streamStarted = true; - await new Promise((resolve) => setTimeout(resolve, 20)); - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition(() => streamStarted); - expect(controller?.getState().status).toBe("requesting"); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(controller?.getState().status).toBe("idle"); - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().metrics.successCount).toBe(0); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( - "requesting", - ); - expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(1); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); - - editor.destroy(); - }); - - it("dismisses a visible suggestion when runtime policy becomes ineligible", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => controller?.getState().visibleSuggestionId !== null, - ); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().status).toBe("idle"); - expect(controller?.hasVisibleSuggestion()).toBe(false); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( - "showing", - ); - expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(1); - - const controllerImpl = controller as unknown as { - _state: { - blockPolicy: { - allowInCodeBlocks?: boolean; - allowInTables?: boolean; - allowedBlockTypes?: readonly string[]; - deniedBlockTypes?: readonly string[]; - }; - }; - _continuation: { - setSequence(sequence: { - requestId: string; - blockId: string; - startOffset: number; - candidate: { - rawText: string; - inlineText: string; - appendedBlocks: readonly []; - previewBlocks: readonly []; - }; - continuationDepth: number; - }): void; - }; - _setState: (nextState: { - status: "showing"; - activeRequestId: string; - visibleSuggestionId: string; - }) => void; - }; - controllerImpl._state.blockPolicy = { - ...controller!.getBlockPolicy(), - allowInCodeBlocks: false, - }; - controllerImpl._continuation.setSequence({ - requestId: "manual-policy-recheck", - blockId: codeBlockId, - startOffset: 14, - candidate: { - rawText: " value", - inlineText: " value", - appendedBlocks: [], - previewBlocks: [], - }, - continuationDepth: 0, - }); - controllerImpl._setState({ - status: "showing", - activeRequestId: "manual-policy-recheck", - visibleSuggestionId: "manual-policy-recheck", - }); - expect(controller?.acceptVisibleSuggestion()).toBe(false); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(2); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - - editor.destroy(); - }); - - it("blocks table-cell autocomplete when tables are disabled", () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: { blockId: "table-1", row: 0, col: 0 }, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowInTables: false, - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " cell" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - - editor.apply([ - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - fieldEditor.focusBlockId = "table-1"; - editor.selectText("table-1", 0, 0); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(false); - expect(modelCalled).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "table-disabled", - ); - - editor.destroy(); - }); - - it("returns defensive block policy snapshots from both getters", () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - blockPolicy: { - allowedBlockTypes: ["paragraph"], - deniedBlockTypes: ["database"], - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - - const controller = getAutocompleteController(editor); - const snapshot = controller?.getBlockPolicy(); - const stateSnapshot = controller?.getState(); - expect(snapshot).toEqual({ - allowInCodeBlocks: true, - allowInTables: false, - allowedBlockTypes: ["paragraph"], - deniedBlockTypes: ["database"], - }); - - expect(() => { - if (snapshot?.allowedBlockTypes) { - (snapshot.allowedBlockTypes as string[]).push("heading"); - } - }).toThrow(); - expect(() => { - if (stateSnapshot?.blockPolicy.allowedBlockTypes) { - (stateSnapshot.blockPolicy.allowedBlockTypes as string[]).push("callout"); - } - }).toThrow(); - - expect(controller?.getBlockPolicy().allowedBlockTypes).toEqual(["paragraph"]); - expect(controller?.getState().blockPolicy.allowedBlockTypes).toEqual([ - "paragraph", - ]); - expect(stateSnapshot?.diagnostics.lastPolicyInvalidationStage).toBeNull(); - expect(stateSnapshot?.metrics.policyInvalidationScheduledCount).toBe(0); - - editor.destroy(); - }); - - it("returns stable cached snapshots until controller state changes", () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - blockPolicy: { - allowedBlockTypes: ["paragraph"], - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - - const controller = getAutocompleteController(editor); - const firstSnapshot = controller?.getSnapshot(); - const secondSnapshot = controller?.getSnapshot(); - const firstState = controller?.getState(); - const secondState = controller?.getState(); - const firstPolicy = controller?.getBlockPolicy(); - const secondPolicy = controller?.getBlockPolicy(); - const firstProviders = controller?.listProviderDescriptors(); - const secondProviders = controller?.listProviderDescriptors(); - - expect(firstSnapshot).toBe(secondSnapshot); - expect(firstState).toBe(secondState); - expect(firstPolicy).toBe(secondPolicy); - expect(firstProviders).toBe(secondProviders); - expect(firstSnapshot?.state).toBe(firstState); - expect(firstSnapshot?.state.blockPolicy).toBe(firstPolicy); - expect(firstSnapshot?.providerDescriptors).toBe(firstProviders); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - - const thirdSnapshot = controller?.getSnapshot(); - const thirdState = controller?.getState(); - const thirdPolicy = controller?.getBlockPolicy(); - - expect(thirdSnapshot).not.toBe(firstSnapshot); - expect(thirdState).not.toBe(firstState); - expect(thirdPolicy).not.toBe(firstPolicy); - expect(thirdSnapshot?.state).toBe(thirdState); - expect(thirdSnapshot?.state.blockPolicy).toBe(thirdPolicy); - expect(thirdSnapshot?.providerDescriptors).toBe(firstProviders); - expect(thirdState?.blockPolicy.allowInCodeBlocks).toBe(false); - expect(thirdPolicy?.allowInCodeBlocks).toBe(false); - - editor.destroy(); - }); - - it("prefetches a continuation after accepting the current suggestion", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - const requestModes: Array = []; - let secondPrompt = ""; - let thirdPrompt = ""; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream(options) { - callCount += 1; - requestModes.push(options.requestMode); - if (callCount === 1) { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - return; - } - if (callCount === 2) { - secondPrompt = String(options.messages[1]?.content ?? ""); - yield { - type: "text-delta" as const, - delta: - ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - }; - yield { type: "done" as const }; - return; - } - if (callCount === 3) { - thirdPrompt = String(options.messages[1]?.content ?? ""); - yield { - type: "text-delta" as const, - delta: - " The photos alone could fill a journal.\n\nYou should turn the trip into a full essay while the details are still vivid.\n\nStart with the beach at sunset and the best meal of the week.", - }; - yield { type: "done" as const }; - return; - } - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - ); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); - expect(secondPrompt).toContain('prefix="Hello world from pen"'); - expect(secondPrompt).toContain("target_scope=finish-paragraph"); - expect(requestModes).toEqual([ - "inline-autocomplete", - "inline-autocomplete", - ]); - expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( - ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " The photos alone could fill a journal.", - ); - expect(editor.getBlock(blockId)?.textContent()).toBe( - "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - ); - expect(thirdPrompt).toContain( - 'prefix="Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell."', - ); - expect(thirdPrompt).toContain("target_scope=continue-across-paragraphs"); - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "You should turn the trip into a full essay while the details are still vivid.", - blockType: "paragraph", - }), - expect.objectContaining({ - text: "Start with the beach at sunset and the best meal of the week.", - blockType: "paragraph", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(secondBlock).toBeTruthy(); - expect(thirdBlock).toBeTruthy(); - expect(editor.getBlock(blockId)?.textContent()).toBe( - "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell. The photos alone could fill a journal.", - ); - expect(secondBlock?.textContent()).toBe( - "You should turn the trip into a full essay while the details are still vivid.", - ); - expect(thirdBlock?.textContent()).toBe( - "Start with the beach at sunset and the best meal of the week.", - ); - expect(editor.selection).toMatchObject({ - type: "text", - isCollapsed: true, - focus: { - blockId: thirdBlock?.id, - offset: 61, - }, - }); - expect(requestModes).toEqual([ - "inline-autocomplete", - "inline-autocomplete", - "inline-autocomplete", - ]); - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - - editor.destroy(); - }); - - it("accepts markdown continuation tails as structured blocks", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: " with a plan\n- Book flights\n- Reserve the hotel", - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip" }]); - editor.selectText(blockId, 4, 4); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " with a plan", - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "Book flights", - blockType: "bulletListItem", - }), - expect.objectContaining({ - text: "Reserve the hotel", - blockType: "bulletListItem", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(editor.getBlock(blockId)?.textContent()).toBe("Trip with a plan"); - expect(secondBlock?.type).toBe("bulletListItem"); - expect(secondBlock?.textContent()).toBe("Book flights"); - expect(thirdBlock?.type).toBe("bulletListItem"); - expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); - expect(editor.selection).toMatchObject({ - type: "text", - isCollapsed: true, - focus: { - blockId: thirdBlock?.id, - offset: 17, - }, - }); - - editor.destroy(); - }); - - it("preserves a leading newline when a continuation starts with markdown blocks", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "\n- Book flights\n- Reserve the hotel", - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); - editor.selectText(blockId, 9, 9); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 2, - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe(""); - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "Book flights", - blockType: "bulletListItem", - }), - expect.objectContaining({ - text: "Reserve the hotel", - blockType: "bulletListItem", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan"); - expect(secondBlock?.type).toBe("bulletListItem"); - expect(secondBlock?.textContent()).toBe("Book flights"); - expect(thirdBlock?.type).toBe("bulletListItem"); - expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); - - editor.destroy(); - }); - - it("builds continuation context from the newly inserted block after structured accept", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - let secondPrompt = ""; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream(options) { - callCount += 1; - if (callCount === 1) { - yield { - type: "text-delta" as const, - delta: "\n- Book flights", - }; - yield { type: "done" as const }; - return; - } - if (callCount === 2) { - secondPrompt = String(options.messages[1]?.content ?? ""); - yield { - type: "text-delta" as const, - delta: "\n- Reserve the hotel", - }; - yield { type: "done" as const }; - return; - } - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); - editor.selectText(blockId, 9, 9); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 1, - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.[0]?.text === - "Reserve the hotel", - ); - - expect(secondPrompt).toContain("block_type=bulletListItem"); - expect(secondPrompt).toContain('prefix="Book flights"'); - - editor.destroy(); - }); - - it("treats multiline prose continuations as appended paragraph blocks", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: - " with notes\nBook flights this week.\nReserve the hotel before Friday.", - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); - editor.selectText(blockId, 9, 9); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " with notes", - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "Book flights this week.", - blockType: "paragraph", - }), - expect.objectContaining({ - text: "Reserve the hotel before Friday.", - blockType: "paragraph", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan with notes"); - expect(secondBlock?.type).toBe("paragraph"); - expect(secondBlock?.textContent()).toBe("Book flights this week."); - expect(thirdBlock?.type).toBe("paragraph"); - expect(thirdBlock?.textContent()).toBe("Reserve the hotel before Friday."); - - editor.destroy(); - }); - - it("converts deep single-line prose continuations into appended paragraph blocks", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream() { - callCount += 1; - if (callCount === 1) { - yield { - type: "text-delta" as const, - delta: " find his family waiting for him.", - }; - yield { type: "done" as const }; - return; - } - if (callCount === 2) { - yield { - type: "text-delta" as const, - delta: - ", but they were not the welcoming party he had expected. Instead, he found them in a state of distress, with worried expressions on their faces.", - }; - yield { type: "done" as const }; - return; - } - yield { - type: "text-delta" as const, - delta: - ' He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers. For a moment, no one spoke, and the silence made the room feel even heavier. Then his mother stepped forward and told him everything that had changed while he was away.', - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ - type: "insert-text", - blockId, - offset: 0, - text: "He came home to", - }]); - editor.selectText(blockId, 16, 16); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " find his family waiting for him.", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text?.includes( - "welcoming party", - ) === true, - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) > 0, - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: expect.stringContaining( - "For a moment, no one spoke, and the silence made the room feel even heavier.", - ), - blockType: "paragraph", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(secondBlock?.type).toBe("paragraph"); - expect(secondBlock?.textContent()).toContain( - "Instead, he found them in a state of distress, with worried expressions on their faces.", - ); - expect(secondBlock?.textContent()).toContain( - 'He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers.', - ); - expect(thirdBlock?.type).toBe("paragraph"); - expect(thirdBlock?.textContent()).toContain( - "For a moment, no one spoke, and the silence made the room feel even heavier.", - ); - expect(thirdBlock?.textContent()).toContain( - "Then his mother stepped forward and told him everything that had changed while he was away.", - ); - - editor.destroy(); - }); - - it("promotes long depth-two prose continuations into a new paragraph earlier", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream() { - callCount += 1; - if (callCount === 1) { - yield { - type: "text-delta" as const, - delta: '", tired from a long day at work."', - }; - yield { type: "done" as const }; - return; - } - yield { - type: "text-delta" as const, - delta: - '", but happy to be back. He looked forward to a quiet evening at home, away from the hustle and bustle of the office."', - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ - type: "insert-text", - blockId, - offset: 0, - text: "He came home ", - }]); - editor.selectText(blockId, 13, 13); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - "tired from a long day at work.", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) === 1, - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - blockType: "paragraph", - text: expect.stringContaining( - "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", - ), - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - expect(secondBlock?.type).toBe("paragraph"); - expect(secondBlock?.textContent()).toContain( - "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", - ); - - editor.destroy(); - }); }); diff --git a/packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts b/packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts new file mode 100644 index 0000000..4cabdf0 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts @@ -0,0 +1,275 @@ +import type { ModelStreamEvent } from "@pen/types"; +import type { AutocompleteRequestContext } from "./types"; +import { previewAutocompleteTextForLog } from "./autocompleteDebug"; + +const PROSE_BLOCK_TYPES = new Set([ + "paragraph", + "heading", + "blockquote", + "callout", +]); +const MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS = 3; + +export function handleModelEvent( + event: ModelStreamEvent, + onTextDelta: (delta: string) => void, +): boolean { + if (event.type === "text-delta") { + onTextDelta(event.delta); + return true; + } + if (event.type === "done" || event.type === "error") { + return false; + } + return true; +} + +export function normalizeCompletionText( + context: AutocompleteRequestContext, + text: string, +): string { + const normalized = text.replace(/\r/g, ""); + const withoutFence = normalized + .replace(/^```[a-zA-Z0-9_-]*\n?/, "") + .replace(/```$/, ""); + const withoutWrappedQuotes = stripWrappedCompletionQuotes( + context, + withoutFence, + ); + const trimmedLeading = + withoutWrappedQuotes.startsWith("\n\n") || + startsWithStructuredBlockContinuation(withoutWrappedQuotes) + ? withoutWrappedQuotes + : withoutWrappedQuotes.replace(/^\s*\n/, ""); + if (!trimmedLeading) { + return ""; + } + let candidate = trimmedLeading; + const suffixEcho = longestCommonPrefix(context.suffixText, trimmedLeading); + if (suffixEcho.length > 0) { + candidate = trimmedLeading.slice(suffixEcho.length); + } else if (context.suffixText.length === 0) { + const prefixEcho = longestSuffixPrefixOverlap( + context.prefixText, + trimmedLeading, + ); + if (prefixEcho.length > 0) { + candidate = trimmedLeading.slice(prefixEcho.length); + } + } + candidate = maybeInsertMissingBoundarySpace(context, candidate); + candidate = stripLeadingBoundaryPunctuationArtifacts(context, candidate); + candidate = collapseDuplicateBoundaryWhitespace(context, candidate); + candidate = maybeCapitalizeSentenceStart(context, candidate); + if (shouldRejectLowQualityCompletion(context, candidate)) { + return ""; + } + return candidate; +} + +function startsWithStructuredBlockContinuation(text: string): boolean { + return /^\s*\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test( + text, + ); +} + +function longestCommonPrefix(left: string, right: string): string { + const maxLength = Math.min(left.length, right.length); + let index = 0; + while (index < maxLength && left[index] === right[index]) { + index += 1; + } + return left.slice(0, index); +} + +function longestSuffixPrefixOverlap(left: string, right: string): string { + const maxLength = Math.min(left.length, right.length); + for (let length = maxLength; length > 0; length -= 1) { + const overlap = right.slice(0, length); + if (left.endsWith(overlap)) { + return overlap; + } + } + return ""; +} + +function maybeInsertMissingBoundarySpace( + context: AutocompleteRequestContext, + completion: string, +): string { + if ( + !completion || + context.suffixText.length > 0 || + !PROSE_BLOCK_TYPES.has(context.blockType ?? "") + ) { + return completion; + } + const lastPrefixChar = context.prefixText.slice(-1); + const firstCompletionChar = completion[0]; + if ( + !isWordLikeChar(lastPrefixChar) || + !isWordLikeChar(firstCompletionChar) + ) { + return completion; + } + if (!hasLikelyWordBoundary(completion)) { + return completion; + } + const leadingWord = completion.match(/^[A-Za-z0-9_'-]+/)?.[0] ?? ""; + if (leadingWord.length > 0 && leadingWord.length <= 2) { + return completion; + } + return ` ${completion}`; +} + +function stripWrappedCompletionQuotes( + context: AutocompleteRequestContext, + completion: string, +): string { + if (!completion || context.suffixText.length > 0) { + return completion; + } + const trimmed = completion.trim(); + if (trimmed.length < 2 || isLikelyInsideOpenQuote(context.prefixText)) { + return completion; + } + const unwrapped = unwrapMatchingQuotes(trimmed); + if (unwrapped == null) { + return completion; + } + const leadingWhitespace = completion.match(/^\s*/)?.[0] ?? ""; + const trailingWhitespace = completion.match(/\s*$/)?.[0] ?? ""; + return `${leadingWhitespace}${unwrapped}${trailingWhitespace}`; +} + +function stripLeadingBoundaryPunctuationArtifacts( + context: AutocompleteRequestContext, + completion: string, +): string { + if ( + !completion || + context.suffixText.length > 0 || + !PROSE_BLOCK_TYPES.has(context.blockType ?? "") + ) { + return completion; + } + const prefixEndsWithWhitespace = /\s$/.test(context.prefixText); + const prefixEndsSentence = /[.!?]["')\]]*\s*$/.test(context.prefixText); + if (!prefixEndsWithWhitespace && !prefixEndsSentence) { + return completion; + } + if (prefixEndsWithWhitespace) { + return completion.replace(/^([ \t]*)([,.;:!?]+)(?=\s|["'A-Z])/u, "$1"); + } + if (prefixEndsSentence) { + return completion.replace(/^([ \t]*)([,;:]+)(?=\s|["'A-Z])/u, "$1"); + } + return completion; +} + +function collapseDuplicateBoundaryWhitespace( + context: AutocompleteRequestContext, + completion: string, +): string { + if (!completion || context.suffixText.length > 0) { + return completion; + } + if (!/\s$/.test(context.prefixText)) { + return completion; + } + return completion.replace(/^[ \t]+/u, ""); +} + +function maybeCapitalizeSentenceStart( + context: AutocompleteRequestContext, + completion: string, +): string { + if ( + !completion || + context.suffixText.length > 0 || + !PROSE_BLOCK_TYPES.has(context.blockType ?? "") || + !/[.!?]["')\]]*\s*$/.test(context.prefixText) + ) { + return completion; + } + return completion.replace( + /^(\s*["'([{“‘-]*)([a-z])/u, + (_, prefix: string, character: string) => + `${prefix}${character.toUpperCase()}`, + ); +} + +function shouldRejectLowQualityCompletion( + context: AutocompleteRequestContext, + completion: string, +): boolean { + const trimmed = completion.trim(); + if (!trimmed) { + return true; + } + if ( + PROSE_BLOCK_TYPES.has(context.blockType ?? "") && + context.suffixText.length === 0 && + countWordLikeTokens(trimmed) === 1 && + trimmed.length < MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS && + !/[.!?]$/.test(trimmed) + ) { + // Single-character or two-character prose guesses tend to feel like flicker. + // Allow short but still meaningful continuations such as "cat", "the", or "and". + return true; + } + return false; +} + +function countWordLikeTokens(value: string): number { + return value.match(/[A-Za-z0-9_'-]+/g)?.length ?? 0; +} + +function hasLikelyWordBoundary(value: string): boolean { + return /[\s.,!?;:]/.test(value.slice(1)); +} + +function isWordLikeChar(value: string): boolean { + return /[A-Za-z0-9]/.test(value); +} + +function unwrapMatchingQuotes(value: string): string | null { + const quotePairs: Array<[string, string]> = [ + ['"', '"'], + ["'", "'"], + ["“", "”"], + ["‘", "’"], + ]; + for (const [open, close] of quotePairs) { + if (value.startsWith(open) && value.endsWith(close)) { + const inner = value + .slice(open.length, value.length - close.length) + .trim(); + return inner.length > 0 ? inner : null; + } + } + return null; +} + +function isLikelyInsideOpenQuote(value: string): boolean { + const asciiDoubleQuotes = value.match(/"/g)?.length ?? 0; + const asciiSingleQuotes = value.match(/'/g)?.length ?? 0; + const smartOpenQuotes = value.match(/“/g)?.length ?? 0; + const smartCloseQuotes = value.match(/”/g)?.length ?? 0; + const smartOpenSingles = value.match(/‘/g)?.length ?? 0; + const smartCloseSingles = value.match(/’/g)?.length ?? 0; + return ( + asciiDoubleQuotes % 2 === 1 || + asciiSingleQuotes % 2 === 1 || + smartOpenQuotes > smartCloseQuotes || + smartOpenSingles > smartCloseSingles + ); +} + +export function head(value: string, maxChars: number): string { + return value.length <= maxChars ? value : value.slice(0, maxChars); +} + +export function tail(value: string, maxChars: number): string { + return value.length <= maxChars ? value : value.slice(-maxChars); +} diff --git a/packages/extensions/ai-autocomplete/src/autocompleteController.ts b/packages/extensions/ai-autocomplete/src/autocompleteController.ts new file mode 100644 index 0000000..a6cafbe --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteController.ts @@ -0,0 +1,6 @@ +import "./autocompleteControllerLifecycle"; +import "./autocompleteControllerRequest"; +import "./autocompleteControllerContinuation"; +import "./autocompleteControllerState"; + +export { AutocompleteControllerImpl } from "./autocompleteControllerCore"; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts new file mode 100644 index 0000000..70471c8 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts @@ -0,0 +1,245 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype._showSequenceSuggestion = function _showSequenceSuggestion(this: AutocompleteControllerRuntime): void { + const sequence = this._continuation.sequence; + if (!sequence) { + return; + } + const suggestionId = sequence.requestId; + const preview = sequence.candidate; + this._inlineCompletion.showSuggestion({ + id: suggestionId, + blockId: sequence.blockId, + offset: sequence.startOffset, + text: preview.inlineText, + type: "inline", + previewBlocks: preview.previewBlocks, + accept: () => + this._acceptFullVisibleSuggestion({ + activateContinuation: true, + }), + }); + this._setState({ + status: "showing", + activeRequestId: sequence.requestId, + visibleSuggestionId: suggestionId, + }); +} +; +ControllerPrototype._startPrefetchForAcceptedContinuation = function _startPrefetchForAcceptedContinuation(this: AutocompleteControllerRuntime, options: { + sourceRequestId: string; + blockId: string; + startOffset: number; + continuationDepth: number; +}): void { + if (!this._prefetchAfterAccept) { + return; + } + const context = this._buildContextForPosition( + options.blockId, + options.startOffset, + ); + if (!context) { + return; + } + this._prefetchAbortController?.abort(); + const abortController = new AbortController(); + this._prefetchAbortController = abortController; + void this._runPrefetchRequest({ + abortController, + context, + continuationDepth: options.continuationDepth, + sourceRequestId: options.sourceRequestId, + }); +} +; +ControllerPrototype._runPrefetchRequest = async function _runPrefetchRequest(this: AutocompleteControllerRuntime, options: { + abortController: AbortController; + context: AutocompleteRequestContext; + continuationDepth: number; + sourceRequestId: string; +}): Promise { + if (!this._model) { + return; + } + const { abortController, context, continuationDepth, sourceRequestId } = + options; + const requestId = crypto.randomUUID(); + const { messages } = await buildAutocompleteMessages({ + context, + registry: this._providerRegistry, + maxProviderChars: this._maxProviderChars, + maxProviderTimeMs: this._maxProviderTimeMs, + mode: "continuation", + continuationDepth, + }); + if (abortController.signal.aborted) { + return; + } + + let text = ""; + try { + for await (const event of this._model.stream({ + messages, + tools: [], + signal: abortController.signal, + requestMode: AUTOCOMPLETE_REQUEST_MODE, + })) { + if (abortController.signal.aborted) { + return; + } + if ( + !handleModelEvent(event, (delta) => { + text += delta; + }) + ) { + break; + } + } + } catch { + return; + } + + if (abortController.signal.aborted) { + return; + } + const normalizedText = normalizeCompletionText(context, text); + if (!normalizedText) { + logAutocompleteEvent("prefetch produced empty normalized text", { + requestId, + sourceRequestId, + blockType: context.blockType, + rawLength: text.length, + rawPreview: previewAutocompleteTextForLog(text), + }); + return; + } + const candidate = createAutocompleteStructuredCandidate( + this._editor, + normalizedText, + { + activeBlockType: context.blockType, + continuationDepth, + }, + ); + logAutocompleteEvent("prefetch produced suggestion", { + requestId, + sourceRequestId, + blockType: context.blockType, + rawLength: text.length, + rawPreview: previewAutocompleteTextForLog(text), + normalizedLength: normalizedText.length, + normalizedPreview: previewAutocompleteTextForLog(normalizedText), + inlineLength: candidate.inlineText.length, + inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), + appendedBlockCount: candidate.appendedBlocks.length, + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), + previewBlockCount: candidate.previewBlocks.length, + }); + this._continuation.setPrefetchedContinuation({ + sourceRequestId, + requestId, + blockId: context.blockId, + startOffset: context.offset, + candidate, + continuationDepth, + }); + this._activatePendingAcceptedContinuation(); +} +; +ControllerPrototype._activatePendingAcceptedContinuation = function _activatePendingAcceptedContinuation(this: AutocompleteControllerRuntime): boolean { + if ( + !this._continuation.activatePendingAcceptedContinuation( + this._editor.selection, + ) + ) { + return false; + } + this._showSequenceSuggestion(); + return true; +} +; +ControllerPrototype._clearSequence = function _clearSequence(this: AutocompleteControllerRuntime): void { + this._continuation.clearSequence(); +} +; +ControllerPrototype._clearVisibleSuggestionAfterAccept = function _clearVisibleSuggestionAfterAccept(this: AutocompleteControllerRuntime): void { + this._clearSequence(); + this._setState({ + status: "idle", + activeRequestId: null, + visibleSuggestionId: null, + diagnostics: { + ...this._state.diagnostics, + lastDismissReason: "accept", + }, + }); + this._inlineCompletion.dismissSuggestion(); +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts new file mode 100644 index 0000000..e04d3f6 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts @@ -0,0 +1,241 @@ +import type { + Editor, + FieldEditor, + InlineCompletionController, + ModelAdapter, +} from "@pen/types"; +import { getOpOriginType } from "@pen/types"; +import { + DEFAULT_DEBOUNCE_MS, + DEFAULT_ACCEPTANCE_STRATEGY, + DEFAULT_MAX_NEIGHBOR_CHARS, + DEFAULT_MAX_PREFIX_CHARS, + DEFAULT_MAX_PROVIDER_CHARS, + DEFAULT_MAX_PROVIDER_TIME_MS, + DEFAULT_MAX_SUFFIX_CHARS, + DEFAULT_PREFETCH_AFTER_ACCEPT, + DEFAULT_STALE_AFTER_MS, +} from "./constants"; +import { builtinAutocompleteProviders } from "./providers/builtins"; +import { AutocompleteProviderRegistry } from "./providers/registry"; +import type { + AutocompleteContextProvider, + AutocompleteProviderDescriptor, +} from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteController, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { AutocompleteContinuationState } from "./continuationState"; + +export interface AutocompleteControllerImpl { + destroy(): void; + getSnapshot(): AutocompleteControllerSnapshot; + getState(): AutocompleteControllerState; + getBlockPolicy(): Readonly; + subscribe(listener: () => void): () => void; + setEnabled(enabled: boolean): void; + request(options?: { explicit?: boolean }): boolean; + acceptVisibleSuggestion(): boolean; + _acceptFullVisibleSuggestion(options?: { + activateContinuation?: boolean; + }): boolean; + hasVisibleSuggestion(): boolean; + registerProvider(provider: AutocompleteContextProvider): () => void; + listProviderDescriptors(): readonly AutocompleteProviderDescriptor[]; + updateRuntimeSettings( + settings: Partial, +): void; + updateBlockPolicy(policy: Partial): void; + dismiss(reason?: AutocompleteDismissReason): void; + _runRequest(requestId: string): Promise; + _buildContext(): AutocompleteRequestContext | null; + _buildContextForPosition( + blockId: string, + offset: number, +): AutocompleteRequestContext | null; + _shouldContinueRequest( + requestId: string, + context: AutocompleteRequestContext, +): boolean; + _shouldDismissForExternalCommit( + affectedBlocks: readonly string[], +): boolean; + _shouldDismissForSelectionChange(): boolean; + _getFieldEditor(): FieldEditor | null; + _showSequenceSuggestion(): void; + _startPrefetchForAcceptedContinuation(options: { + sourceRequestId: string; + blockId: string; + startOffset: number; + continuationDepth: number; + }): void; + _runPrefetchRequest(options: { + abortController: AbortController; + context: AutocompleteRequestContext; + continuationDepth: number; + sourceRequestId: string; + }): Promise; + _activatePendingAcceptedContinuation(): boolean; + _clearSequence(): void; + _clearVisibleSuggestionAfterAccept(): void; + _setBlockedReason(reason: AutocompleteBlockedReason): void; + _recordPolicyInvalidation( + policyFailure: AutocompleteBlockedReason, + invalidationStage: AutocompletePolicyInvalidationStage | null, +): void; + _invalidateForPolicyChange(): void; + _getActiveSelectionBlockId(): string | null; + _getPolicyInvalidationStage(): AutocompletePolicyInvalidationStage | null; + _resolveCurrentBlockFailure( + blockId: string, +): AutocompleteBlockedReason | null; + _resolveContextEligibilityFailure( + blockId: string, + blockType: string | null, +): AutocompleteBlockedReason | null; + _resolveBlockPolicyFailure( + blockType: string | null, +): AutocompleteBlockedReason | null; + _clearDebounceTimer(): void; + _setState(next: Partial): void; + _getProviderDescriptorsSnapshot(): readonly AutocompleteProviderDescriptor[]; + _invalidateSnapshot(): void; + _invalidateProviderDescriptorsSnapshot(): void; + _emit(): void; +} + +export class AutocompleteControllerImpl implements AutocompleteController { + private readonly _editor: Editor; + private readonly _model: ModelAdapter | undefined; + private _debounceMs: number; + private _acceptanceStrategy: AutocompleteAcceptanceStrategy; + private _staleAfterMs: number; + private readonly _maxPrefixChars: number; + private readonly _maxSuffixChars: number; + private readonly _maxNeighborChars: number; + private readonly _maxProviderChars: number; + private readonly _maxProviderTimeMs: number; + private _prefetchAfterAccept: boolean; + private readonly _providerRegistry: AutocompleteProviderRegistry; + private readonly _inlineCompletion: InlineCompletionController; + private readonly _listeners = new Set<() => void>(); + private _snapshot: AutocompleteControllerSnapshot | null = null; + private _providerDescriptorsSnapshot: + | readonly AutocompleteProviderDescriptor[] + | null = null; + private _state: AutocompleteControllerState = { + enabled: true, + status: "idle", + activeRequestId: null, + visibleSuggestionId: null, + settings: { + debounceMs: DEFAULT_DEBOUNCE_MS, + prefetchAfterAccept: DEFAULT_PREFETCH_AFTER_ACCEPT, + acceptanceStrategy: "full", + staleAfterMs: DEFAULT_STALE_AFTER_MS, + }, + blockPolicy: { + allowInCodeBlocks: true, + allowInTables: false, + deniedBlockTypes: ["database"], + }, + metrics: { + requestCount: 0, + successCount: 0, + cancelCount: 0, + staleDropCount: 0, + explicitTabTriggerCount: 0, + acceptCount: 0, + policyInvalidationScheduledCount: 0, + policyInvalidationRequestingCount: 0, + policyInvalidationShowingCount: 0, + }, + providerTimings: [], + diagnostics: { + lastDismissReason: null, + lastBlockedReason: null, + lastPolicyInvalidationStage: null, + }, + }; + private _debounceTimer: ReturnType | null = null; + private _abortController: AbortController | null = null; + private _unsubscribeSelection: (() => void) | null = null; + private _unsubscribeCommit: (() => void) | null = null; + private readonly _continuation = new AutocompleteContinuationState(); + private _prefetchAbortController: AbortController | null = null; + + constructor( + editor: Editor, + config: AutocompleteExtensionConfig, + services: { inlineCompletion: InlineCompletionController }, + ) { + this._editor = editor; + this._inlineCompletion = services.inlineCompletion; + this._model = config.model; + this._debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; + this._acceptanceStrategy = config.acceptanceStrategy ?? "full"; + this._staleAfterMs = config.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; + this._state.blockPolicy = { + allowInCodeBlocks: true, + allowInTables: false, + deniedBlockTypes: ["database"], + ...config.blockPolicy, + }; + this._maxPrefixChars = + config.maxPrefixChars ?? DEFAULT_MAX_PREFIX_CHARS; + this._maxSuffixChars = + config.maxSuffixChars ?? DEFAULT_MAX_SUFFIX_CHARS; + this._maxNeighborChars = + config.maxNeighborChars ?? DEFAULT_MAX_NEIGHBOR_CHARS; + this._maxProviderChars = + config.maxProviderChars ?? DEFAULT_MAX_PROVIDER_CHARS; + this._maxProviderTimeMs = + config.maxProviderTimeMs ?? DEFAULT_MAX_PROVIDER_TIME_MS; + this._prefetchAfterAccept = + config.prefetchAfterAccept ?? DEFAULT_PREFETCH_AFTER_ACCEPT; + this._providerRegistry = new AutocompleteProviderRegistry([ + ...builtinAutocompleteProviders, + ...(config.providers ?? []), + ]); + this._state.enabled = config.enabled ?? true; + this._state.settings = { + debounceMs: this._debounceMs, + prefetchAfterAccept: this._prefetchAfterAccept, + acceptanceStrategy: this._acceptanceStrategy, + staleAfterMs: this._staleAfterMs, + }; + + this._unsubscribeSelection = this._editor.onSelectionChange(() => { + if (this._shouldDismissForSelectionChange()) { + this.dismiss("selection-change"); + } + }); + this._unsubscribeCommit = this._editor.onDocumentCommit((event) => { + if (!this._state.enabled) { + return; + } + if (this._continuation.consumeAcceptedAiCommit(event.origin)) { + return; + } + const originType = getOpOriginType(event.origin); + if (originType !== "user" && originType !== "input-rule") { + if ( + this._shouldDismissForExternalCommit(event.affectedBlocks) + ) { + this.dismiss("external-edit"); + } + return; + } + this.request(); + }); + } +} diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts new file mode 100644 index 0000000..0a9b361 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts @@ -0,0 +1,427 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype.destroy = function destroy(this: AutocompleteControllerRuntime): void { + this._unsubscribeSelection?.(); + this._unsubscribeSelection = null; + this._unsubscribeCommit?.(); + this._unsubscribeCommit = null; + this._clearDebounceTimer(); + this._abortController?.abort(); + this._abortController = null; + this._prefetchAbortController?.abort(); + this._prefetchAbortController = null; + this._continuation.clearContinuations(); +} +; +ControllerPrototype.getSnapshot = function getSnapshot(this: AutocompleteControllerRuntime): AutocompleteControllerSnapshot { + if (this._snapshot === null) { + const state = cloneAutocompleteControllerState(this._state); + this._snapshot = freezeAutocompleteControllerSnapshot({ + state: freezeAutocompleteControllerState(state), + providerDescriptors: this._getProviderDescriptorsSnapshot(), + }); + } + return this._snapshot; +} +; +ControllerPrototype.getState = function getState(this: AutocompleteControllerRuntime): AutocompleteControllerState { + return this.getSnapshot().state; +} +; +ControllerPrototype.getBlockPolicy = function getBlockPolicy(this: AutocompleteControllerRuntime): Readonly { + return this.getSnapshot().state.blockPolicy; +} +; +ControllerPrototype.subscribe = function subscribe(this: AutocompleteControllerRuntime, listener: () => void): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); +} +; +ControllerPrototype.setEnabled = function setEnabled(this: AutocompleteControllerRuntime, enabled: boolean): void { + if (this._state.enabled === enabled) { + return; + } + this._state = { + ...this._state, + enabled, + status: enabled ? "idle" : "idle", + activeRequestId: null, + }; + this._invalidateSnapshot(); + if (!enabled) { + this.dismiss("disabled"); + } + this._emit(); +} +; +ControllerPrototype.request = function request(this: AutocompleteControllerRuntime, options?: { explicit?: boolean }): boolean { + if (!this._state.enabled) { + this._setBlockedReason("disabled"); + return false; + } + if (!this._model) { + this._setBlockedReason("missing-model"); + return false; + } + // Validate that autocomplete is currently eligible, but defer reading the + // exact caret context until the debounced request actually runs. + if (!this._buildContext()) { + return false; + } + this.dismiss("request-replaced"); + const requestId = crypto.randomUUID(); + this._setState({ + status: "scheduled", + activeRequestId: requestId, + metrics: { + ...this._state.metrics, + requestCount: this._state.metrics.requestCount + 1, + explicitTabTriggerCount: + this._state.metrics.explicitTabTriggerCount + + (options?.explicit ? 1 : 0), + }, + diagnostics: { + ...this._state.diagnostics, + lastBlockedReason: null, + lastPolicyInvalidationStage: null, + }, + }); + this._clearDebounceTimer(); + const delay = options?.explicit ? 0 : this._debounceMs; + logAutocompleteEvent("request scheduled", { + requestId, + explicit: options?.explicit ?? false, + debounceMs: delay, + }); + this._debounceTimer = setTimeout(() => { + void this._runRequest(requestId); + }, delay); + return true; +} +; +ControllerPrototype.acceptVisibleSuggestion = function acceptVisibleSuggestion(this: AutocompleteControllerRuntime): boolean { + const sequence = this._continuation.sequence; + if (!sequence || !this.hasVisibleSuggestion()) { + return false; + } + const policyFailure = this._resolveCurrentBlockFailure( + sequence.blockId, + ); + if (policyFailure) { + this._recordPolicyInvalidation(policyFailure, "showing"); + return false; + } + return this._acceptFullVisibleSuggestion({ + activateContinuation: true, + }); +} +; +ControllerPrototype._acceptFullVisibleSuggestion = function _acceptFullVisibleSuggestion(this: AutocompleteControllerRuntime, options?: { + activateContinuation?: boolean; +}): boolean { + const sequence = this._continuation.sequence; + if (!sequence) { + return false; + } + const candidate = sequence.candidate; + if ( + candidate.inlineText.length === 0 && + candidate.previewBlocks.length === 0 + ) { + this.dismiss(); + return false; + } + const blockId = sequence.blockId; + const requestId = sequence.requestId; + const continuationDepth = sequence.continuationDepth + 1; + const acceptanceResult = materializeStructuredCandidateAcceptance({ + blockId, + offset: sequence.startOffset, + candidate, + }); + logAutocompleteEvent("accept visible suggestion", { + requestId, + blockId, + startOffset: sequence.startOffset, + inlineLength: candidate.inlineText.length, + inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), + appendedBlockCount: candidate.appendedBlocks.length, + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), + opTypes: acceptanceResult.ops.map((op) => op.type), + nextCaretBlockId: acceptanceResult.selection.blockId, + nextCaretOffset: acceptanceResult.selection.offset, + }); + this._continuation.beginAcceptingSequenceSegment(); + this._editor.apply(acceptanceResult.ops, { + origin: "ai", + undoGroup: true, + }); + const acceptedBlock = this._editor.getBlock(blockId); + const firstNextBlock = acceptedBlock?.next ?? null; + const secondNextBlock = firstNextBlock?.next ?? null; + logAutocompleteEvent( + `accept applied summary requestId=${requestId} appendedBlockCount=${candidate.appendedBlocks.length} opTypes=${acceptanceResult.ops.map((op) => op.type).join(",")} currentBlockType=${acceptedBlock?.type ?? "missing"} currentBlockText=${previewAutocompleteTextForLog(acceptedBlock?.textContent() ?? "")} nextBlockType=${firstNextBlock?.type ?? "none"} nextBlockText=${previewAutocompleteTextForLog(firstNextBlock?.textContent() ?? "")} nextNextBlockType=${secondNextBlock?.type ?? "none"} nextNextBlockText=${previewAutocompleteTextForLog(secondNextBlock?.textContent() ?? "")}`, + ); + const nextCaretBlockId = acceptanceResult.selection.blockId; + const nextCaretOffset = acceptanceResult.selection.offset; + this._setState({ + metrics: { + ...this._state.metrics, + acceptCount: this._state.metrics.acceptCount + 1, + }, + }); + const fieldEditor = this._getFieldEditor(); + this._editor.selectText( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + if (fieldEditor) { + const programmaticFieldEditor = + fieldEditor as typeof fieldEditor & { + commitProgrammaticTextSelection?: ( + blockId: string, + anchorOffset: number, + focusOffset: number, + ) => void; + }; + if ( + typeof programmaticFieldEditor.commitProgrammaticTextSelection === + "function" + ) { + programmaticFieldEditor.commitProgrammaticTextSelection( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + } else if ( + typeof fieldEditor.activateTextSelection === "function" + ) { + fieldEditor.activateTextSelection( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + } else if (typeof fieldEditor.activate === "function") { + fieldEditor.activate(nextCaretBlockId); + } + if (typeof fieldEditor.focus === "function") { + fieldEditor.focus(); + } + } + + if (options?.activateContinuation && this._prefetchAfterAccept) { + this._continuation.setPendingAcceptedContinuation({ + sourceRequestId: requestId, + blockId: nextCaretBlockId, + startOffset: nextCaretOffset, + continuationDepth, + }); + this._clearVisibleSuggestionAfterAccept(); + this._startPrefetchForAcceptedContinuation({ + sourceRequestId: requestId, + blockId: nextCaretBlockId, + startOffset: nextCaretOffset, + continuationDepth, + }); + } else { + this.dismiss("accept"); + } + return true; +} +; +ControllerPrototype.hasVisibleSuggestion = function hasVisibleSuggestion(this: AutocompleteControllerRuntime): boolean { + return ( + this._continuation.sequence !== null && + this._state.visibleSuggestionId !== null + ); +} +; +ControllerPrototype.registerProvider = function registerProvider(this: AutocompleteControllerRuntime, provider: AutocompleteContextProvider): () => void { + const unregister = this._providerRegistry.registerProvider(provider); + this._invalidateProviderDescriptorsSnapshot(); + this._emit(); + return () => { + unregister(); + this._invalidateProviderDescriptorsSnapshot(); + this._emit(); + }; +} +; +ControllerPrototype.listProviderDescriptors = function listProviderDescriptors(this: AutocompleteControllerRuntime) { + return this.getSnapshot().providerDescriptors; +} +; +ControllerPrototype.updateRuntimeSettings = function updateRuntimeSettings(this: AutocompleteControllerRuntime, + settings: Partial, +): void { + const nextDebounceMs = settings.debounceMs; + const nextPrefetchAfterAccept = settings.prefetchAfterAccept; + const nextAcceptanceStrategy = settings.acceptanceStrategy; + let changed = false; + + if ( + typeof nextDebounceMs === "number" && + Number.isFinite(nextDebounceMs) && + nextDebounceMs >= 0 && + nextDebounceMs !== this._debounceMs + ) { + this._debounceMs = nextDebounceMs; + changed = true; + } + + if ( + typeof nextPrefetchAfterAccept === "boolean" && + nextPrefetchAfterAccept !== this._prefetchAfterAccept + ) { + this._prefetchAfterAccept = nextPrefetchAfterAccept; + if (!nextPrefetchAfterAccept) { + this._prefetchAbortController?.abort(); + this._prefetchAbortController = null; + this._continuation.clearContinuations(); + } + changed = true; + } + + if ( + nextAcceptanceStrategy === "full" && + nextAcceptanceStrategy !== this._acceptanceStrategy + ) { + this._acceptanceStrategy = nextAcceptanceStrategy; + changed = true; + } + + const nextStaleAfterMs = settings.staleAfterMs; + if ( + typeof nextStaleAfterMs === "number" && + Number.isFinite(nextStaleAfterMs) && + nextStaleAfterMs >= 0 && + nextStaleAfterMs !== this._staleAfterMs + ) { + this._staleAfterMs = nextStaleAfterMs; + changed = true; + } + + if (!changed) { + return; + } + + this._setState({ + settings: { + debounceMs: this._debounceMs, + prefetchAfterAccept: this._prefetchAfterAccept, + acceptanceStrategy: this._acceptanceStrategy, + staleAfterMs: this._staleAfterMs, + }, + }); +} +; +ControllerPrototype.updateBlockPolicy = function updateBlockPolicy(this: AutocompleteControllerRuntime, policy: Partial): void { + const nextPolicy: AutocompleteBlockPolicy = { + ...this._state.blockPolicy, + ...policy, + }; + if (areBlockPoliciesEqual(this._state.blockPolicy, nextPolicy)) { + return; + } + this._setState({ + blockPolicy: nextPolicy, + }); + this._invalidateForPolicyChange(); +} +; +ControllerPrototype.dismiss = function dismiss(this: AutocompleteControllerRuntime, reason: AutocompleteDismissReason = "external-edit"): void { + this._clearDebounceTimer(); + const cancelledRequest = + this._state.status === "scheduled" || + this._state.status === "requesting"; + this._abortController?.abort(); + this._abortController = null; + this._prefetchAbortController?.abort(); + this._prefetchAbortController = null; + this._clearSequence(); + this._continuation.clearContinuations(); + this._setState({ + status: "idle", + activeRequestId: null, + visibleSuggestionId: null, + metrics: { + ...this._state.metrics, + cancelCount: + this._state.metrics.cancelCount + + (cancelledRequest ? 1 : 0), + }, + diagnostics: { + ...this._state.diagnostics, + lastDismissReason: reason, + }, + }); + this._inlineCompletion.dismissSuggestion(); +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts new file mode 100644 index 0000000..322519e --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts @@ -0,0 +1,443 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype._runRequest = async function _runRequest(this: AutocompleteControllerRuntime, requestId: string): Promise { + if (this._state.activeRequestId !== requestId || !this._model) { + logAutocompleteEvent("request skipped before start", { + requestId, + hasModel: !!this._model, + activeRequestId: this._state.activeRequestId, + }); + return; + } + this._abortController?.abort(); + const abortController = new AbortController(); + this._abortController = abortController; + const context = this._buildContext(); + if (!context) { + logAutocompleteEvent("request blocked before prompt build", { + requestId, + lastBlockedReason: this._state.diagnostics.lastBlockedReason, + }); + this._setState({ + status: "idle", + activeRequestId: null, + }); + return; + } + this._setState({ + status: "requesting", + activeRequestId: requestId, + }); + logAutocompleteEvent("request started", { + requestId, + blockId: context.blockId, + offset: context.offset, + }); + + const { messages, providerTimings } = await buildAutocompleteMessages({ + context, + registry: this._providerRegistry, + maxProviderChars: this._maxProviderChars, + maxProviderTimeMs: this._maxProviderTimeMs, + continuationDepth: 0, + }); + if (!this._shouldContinueRequest(requestId, context)) { + logAutocompleteEvent("request cancelled after prompt build", { + requestId, + activeRequestId: this._state.activeRequestId, + lastBlockedReason: this._state.diagnostics.lastBlockedReason, + }); + return; + } + this._setState({ providerTimings }); + logAutocompleteEvent("request prompt ready", { + requestId, + providerTimings, + promptLength: String(messages[1]?.content ?? "").length, + }); + const startedAt = Date.now(); + + let text = ""; + try { + logAutocompleteEvent("request model stream opening", { requestId }); + for await (const event of this._model.stream({ + messages, + tools: [], + signal: abortController.signal, + requestMode: AUTOCOMPLETE_REQUEST_MODE, + })) { + if (!this._shouldContinueRequest(requestId, context)) { + logAutocompleteEvent("request cancelled during stream", { + requestId, + activeRequestId: this._state.activeRequestId, + lastBlockedReason: + this._state.diagnostics.lastBlockedReason, + }); + abortController.abort(); + return; + } + logAutocompleteEvent("request model event", { + requestId, + type: event.type, + }); + if ( + !handleModelEvent(event, (delta) => { + text += delta; + }) + ) { + break; + } + } + } catch { + logAutocompleteEvent("request stream threw", { + requestId, + aborted: abortController.signal.aborted, + }); + if (!abortController.signal.aborted) { + this._setState({ + status: "idle", + activeRequestId: null, + }); + } + return; + } + + if (!this._shouldContinueRequest(requestId, context)) { + logAutocompleteEvent("request cancelled after stream", { + requestId, + activeRequestId: this._state.activeRequestId, + lastBlockedReason: this._state.diagnostics.lastBlockedReason, + }); + return; + } + if (Date.now() - startedAt > this._staleAfterMs) { + logAutocompleteEvent("request dropped as stale", { + requestId, + elapsedMs: Date.now() - startedAt, + staleAfterMs: this._staleAfterMs, + }); + this._setState({ + status: "idle", + activeRequestId: null, + metrics: { + ...this._state.metrics, + staleDropCount: this._state.metrics.staleDropCount + 1, + }, + diagnostics: { + ...this._state.diagnostics, + lastDismissReason: "stale", + }, + }); + return; + } + const normalizedText = normalizeCompletionText(context, text); + logAutocompleteEvent("request normalized text", { + requestId, + blockType: context.blockType, + rawLength: text.length, + rawPreview: previewAutocompleteTextForLog(text), + normalizedLength: normalizedText.length, + normalizedPreview: previewAutocompleteTextForLog(normalizedText), + }); + if (!normalizedText) { + logAutocompleteEvent("request produced empty normalized text", { + requestId, + rawLength: text.length, + }); + this._setState({ + status: "idle", + activeRequestId: null, + }); + return; + } + + const candidate = createAutocompleteStructuredCandidate( + this._editor, + normalizedText, + { + activeBlockType: context.blockType, + continuationDepth: 0, + }, + ); + this._continuation.setSequence({ + requestId, + blockId: context.blockId, + startOffset: context.offset, + candidate, + continuationDepth: 0, + }); + this._setState({ + metrics: { + ...this._state.metrics, + successCount: this._state.metrics.successCount + 1, + }, + }); + logAutocompleteEvent("request produced suggestion", { + requestId, + blockType: context.blockType, + normalizedLength: normalizedText.length, + inlineLength: candidate.inlineText.length, + inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), + appendedBlockCount: candidate.appendedBlocks.length, + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), + previewBlockCount: candidate.previewBlocks.length, + }); + this._showSequenceSuggestion(); +} +; +ControllerPrototype._buildContext = function _buildContext(this: AutocompleteControllerRuntime): AutocompleteRequestContext | null { + const selection = this._editor.selection; + if (selection == null) { + this._setBlockedReason("missing-context"); + return null; + } + if (selection.type !== "text") { + this._setBlockedReason("selection-not-text"); + return null; + } + if (!selection.isCollapsed) { + this._setBlockedReason("selection-not-collapsed"); + return null; + } + if (selection.isMultiBlock) { + this._setBlockedReason("selection-multi-block"); + return null; + } + const fieldEditor = this._getFieldEditor(); + if (!fieldEditor) { + this._setBlockedReason("field-editor-unavailable"); + return null; + } + if (!fieldEditor.isEditing) { + this._setBlockedReason("field-editor-not-editing"); + return null; + } + if (!fieldEditor.isFocused) { + this._setBlockedReason("field-editor-not-focused"); + return null; + } + if (fieldEditor.isComposing) { + this._setBlockedReason("field-editor-composing"); + return null; + } + return this._buildContextForPosition( + selection.focus.blockId, + selection.focus.offset, + ); +} +; +ControllerPrototype._buildContextForPosition = function _buildContextForPosition(this: AutocompleteControllerRuntime, + blockId: string, + offset: number, +): AutocompleteRequestContext | null { + const block = this._editor.getBlock(blockId); + if (!block) { + this._setBlockedReason("block-missing"); + return null; + } + const blockPolicyFailure = this._resolveContextEligibilityFailure( + block.id, + block.type, + ); + if (blockPolicyFailure) { + this._setBlockedReason(blockPolicyFailure); + return null; + } + const blockText = block.textContent(); + return { + editor: this._editor, + blockId: block.id, + blockType: block.type, + offset, + prefixText: tail(blockText.slice(0, offset), this._maxPrefixChars), + suffixText: head(blockText.slice(offset), this._maxSuffixChars), + previousBlockText: tail( + block.prev?.textContent() ?? "", + this._maxNeighborChars, + ), + nextBlockText: head( + block.next?.textContent() ?? "", + this._maxNeighborChars, + ), + }; +} +; +ControllerPrototype._shouldContinueRequest = function _shouldContinueRequest(this: AutocompleteControllerRuntime, + requestId: string, + context: AutocompleteRequestContext, +): boolean { + if (this._state.activeRequestId !== requestId) { + logAutocompleteEvent("request continuation blocked: replaced", { + requestId, + activeRequestId: this._state.activeRequestId, + }); + return false; + } + const selection = this._editor.selection; + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock || + selection.focus.blockId !== context.blockId || + selection.focus.offset !== context.offset + ) { + logAutocompleteEvent( + "request continuation blocked: selection changed", + { + requestId, + expected: { + blockId: context.blockId, + offset: context.offset, + }, + actual: + selection?.type === "text" + ? { + type: selection.type, + blockId: selection.focus.blockId, + offset: selection.focus.offset, + isCollapsed: selection.isCollapsed, + isMultiBlock: selection.isMultiBlock, + } + : selection, + }, + ); + return false; + } + const fieldEditor = this._getFieldEditor(); + if ( + !fieldEditor?.isEditing || + !fieldEditor.isFocused || + fieldEditor.isComposing + ) { + logAutocompleteEvent( + "request continuation blocked: field editor state", + { + requestId, + fieldEditor: fieldEditor + ? { + isEditing: fieldEditor.isEditing, + isFocused: fieldEditor.isFocused, + isComposing: fieldEditor.isComposing, + focusBlockId: fieldEditor.focusBlockId, + } + : null, + }, + ); + return false; + } + const block = this._editor.getBlock(context.blockId); + const policyFailure = block + ? this._resolveContextEligibilityFailure(block.id, block.type) + : "block-missing"; + if (policyFailure) { + this._setBlockedReason(policyFailure); + return false; + } + return true; +} +; +ControllerPrototype._shouldDismissForExternalCommit = function _shouldDismissForExternalCommit(this: AutocompleteControllerRuntime, + affectedBlocks: readonly string[], +): boolean { + const visibleSuggestion = + this._inlineCompletion.getState().visibleSuggestion; + return ( + !!visibleSuggestion && + affectedBlocks.includes(visibleSuggestion.blockId) + ); +} +; +ControllerPrototype._shouldDismissForSelectionChange = function _shouldDismissForSelectionChange(this: AutocompleteControllerRuntime): boolean { + const visibleSuggestion = + this._inlineCompletion.getState().visibleSuggestion; + if (!visibleSuggestion || visibleSuggestion.type !== "inline") { + return false; + } + const selection = this._editor.selection; + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { + return true; + } + return ( + selection.focus.blockId !== visibleSuggestion.blockId || + selection.focus.offset !== visibleSuggestion.offset + ); +} +; +ControllerPrototype._getFieldEditor = function _getFieldEditor(this: AutocompleteControllerRuntime): FieldEditor | null { + return ( + this._editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ) ?? null + ); +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts new file mode 100644 index 0000000..89ad1a9 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts @@ -0,0 +1,122 @@ +import type { AutocompleteBlockPolicy, AutocompleteControllerSnapshot, AutocompleteControllerState, AutocompletePolicyInvalidationStage } from "./types"; +import type { AutocompleteProviderDescriptor } from "./providers/types"; + +export function areBlockPoliciesEqual( + left: AutocompleteBlockPolicy, + right: AutocompleteBlockPolicy, +): boolean { + return ( + left.allowInCodeBlocks === right.allowInCodeBlocks && + left.allowInTables === right.allowInTables && + areStringArraysEqual(left.allowedBlockTypes, right.allowedBlockTypes) && + areStringArraysEqual(left.deniedBlockTypes, right.deniedBlockTypes) + ); +} + +function areStringArraysEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right || left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function cloneBlockPolicy( + policy: AutocompleteBlockPolicy, +): AutocompleteBlockPolicy { + return { + allowInCodeBlocks: policy.allowInCodeBlocks, + allowInTables: policy.allowInTables, + allowedBlockTypes: policy.allowedBlockTypes + ? [...policy.allowedBlockTypes] + : undefined, + deniedBlockTypes: policy.deniedBlockTypes + ? [...policy.deniedBlockTypes] + : undefined, + }; +} + +export function cloneAutocompleteControllerState( + state: AutocompleteControllerState, +): AutocompleteControllerState { + return { + enabled: state.enabled, + status: state.status, + activeRequestId: state.activeRequestId, + visibleSuggestionId: state.visibleSuggestionId, + settings: { ...state.settings }, + blockPolicy: cloneBlockPolicy(state.blockPolicy), + metrics: { ...state.metrics }, + providerTimings: state.providerTimings.map((timing) => ({ ...timing })), + diagnostics: { ...state.diagnostics }, + }; +} + +function freezeBlockPolicy( + policy: AutocompleteBlockPolicy, +): AutocompleteBlockPolicy { + if (policy.allowedBlockTypes) { + Object.freeze(policy.allowedBlockTypes); + } + if (policy.deniedBlockTypes) { + Object.freeze(policy.deniedBlockTypes); + } + return Object.freeze(policy); +} + +export function freezeAutocompleteControllerState( + state: AutocompleteControllerState, +): AutocompleteControllerState { + Object.freeze(state.settings); + freezeBlockPolicy(state.blockPolicy); + Object.freeze(state.metrics); + for (const timing of state.providerTimings) { + Object.freeze(timing); + } + Object.freeze(state.providerTimings); + Object.freeze(state.diagnostics); + return Object.freeze(state); +} + +export function freezeProviderDescriptors( + descriptors: readonly AutocompleteProviderDescriptor[], +): readonly AutocompleteProviderDescriptor[] { + for (const descriptor of descriptors) { + Object.freeze(descriptor); + } + return Object.freeze([...descriptors]); +} + +export function freezeAutocompleteControllerSnapshot( + snapshot: AutocompleteControllerSnapshot, +): AutocompleteControllerSnapshot { + return Object.freeze(snapshot); +} + +export function incrementPolicyInvalidationMetrics( + metrics: AutocompleteControllerState["metrics"], + stage: AutocompletePolicyInvalidationStage, +): AutocompleteControllerState["metrics"] { + return { + ...metrics, + policyInvalidationScheduledCount: + metrics.policyInvalidationScheduledCount + + (stage === "scheduled" ? 1 : 0), + policyInvalidationRequestingCount: + metrics.policyInvalidationRequestingCount + + (stage === "requesting" ? 1 : 0), + policyInvalidationShowingCount: + metrics.policyInvalidationShowingCount + + (stage === "showing" ? 1 : 0), + }; +} diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts new file mode 100644 index 0000000..1e513fd --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts @@ -0,0 +1,238 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype._setBlockedReason = function _setBlockedReason(this: AutocompleteControllerRuntime, reason: AutocompleteBlockedReason): void { + this._setState({ + diagnostics: { + ...this._state.diagnostics, + lastBlockedReason: reason, + }, + }); +} +; +ControllerPrototype._recordPolicyInvalidation = function _recordPolicyInvalidation(this: AutocompleteControllerRuntime, + policyFailure: AutocompleteBlockedReason, + invalidationStage: AutocompletePolicyInvalidationStage | null, +): void { + this._setBlockedReason(policyFailure); + if (invalidationStage) { + this._setState({ + metrics: incrementPolicyInvalidationMetrics( + this._state.metrics, + invalidationStage, + ), + diagnostics: { + ...this._state.diagnostics, + lastPolicyInvalidationStage: invalidationStage, + }, + }); + } + if (invalidationStage || this._continuation.hasPrefetchedContinuation) { + this.dismiss("policy-change"); + } +} +; +ControllerPrototype._invalidateForPolicyChange = function _invalidateForPolicyChange(this: AutocompleteControllerRuntime): void { + const activeBlockId = + this._continuation.sequence?.blockId ?? + this._getActiveSelectionBlockId(); + if (!activeBlockId) { + return; + } + const policyFailure = this._resolveCurrentBlockFailure(activeBlockId); + if (!policyFailure) { + return; + } + const invalidationStage = this._getPolicyInvalidationStage(); + this._recordPolicyInvalidation(policyFailure, invalidationStage); +} +; +ControllerPrototype._getActiveSelectionBlockId = function _getActiveSelectionBlockId(this: AutocompleteControllerRuntime): string | null { + const selection = this._editor.selection; + return selection?.type === "text" ? selection.focus.blockId : null; +} +; +ControllerPrototype._getPolicyInvalidationStage = function _getPolicyInvalidationStage(this: AutocompleteControllerRuntime): AutocompletePolicyInvalidationStage | null { + if ( + this._state.status === "scheduled" || + this._state.status === "requesting" + ) { + return this._state.status; + } + if ( + this._state.status === "showing" || + this._continuation.sequence || + this._continuation.hasPrefetchedContinuation + ) { + return "showing"; + } + return null; +} +; +ControllerPrototype._resolveCurrentBlockFailure = function _resolveCurrentBlockFailure(this: AutocompleteControllerRuntime, + blockId: string, +): AutocompleteBlockedReason | null { + const block = this._editor.getBlock(blockId); + if (!block) { + return "block-missing"; + } + return this._resolveContextEligibilityFailure(block.id, block.type); +} +; +ControllerPrototype._resolveContextEligibilityFailure = function _resolveContextEligibilityFailure(this: AutocompleteControllerRuntime, + blockId: string, + blockType: string | null, +): AutocompleteBlockedReason | null { + const blockPolicyFailure = this._resolveBlockPolicyFailure(blockType); + if (blockPolicyFailure) { + return blockPolicyFailure; + } + const fieldEditor = this._getFieldEditor() as + | (FieldEditor & { activeCellCoord?: { blockId: string } | null }) + | null; + if ( + fieldEditor?.activeCellCoord && + fieldEditor.activeCellCoord.blockId === blockId && + this._state.blockPolicy.allowInTables !== true + ) { + return "table-cell-active"; + } + return null; +} +; +ControllerPrototype._resolveBlockPolicyFailure = function _resolveBlockPolicyFailure(this: AutocompleteControllerRuntime, + blockType: string | null, +): AutocompleteBlockedReason | null { + if (!blockType) { + return null; + } + const allowedBlockTypes = this._state.blockPolicy.allowedBlockTypes; + if ( + allowedBlockTypes && + allowedBlockTypes.length > 0 && + !allowedBlockTypes.includes(blockType) + ) { + return "block-type-not-allowed"; + } + const deniedBlockTypes = this._state.blockPolicy.deniedBlockTypes; + if (deniedBlockTypes?.includes(blockType)) { + return "block-type-denied"; + } + if ( + blockType === "codeBlock" && + this._state.blockPolicy.allowInCodeBlocks === false + ) { + return "code-block-disabled"; + } + if ( + blockType === "table" && + this._state.blockPolicy.allowInTables !== true + ) { + return "table-disabled"; + } + return null; +} +; +ControllerPrototype._clearDebounceTimer = function _clearDebounceTimer(this: AutocompleteControllerRuntime): void { + if (this._debounceTimer !== null) { + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } +} +; +ControllerPrototype._setState = function _setState(this: AutocompleteControllerRuntime, next: Partial): void { + this._state = { + ...this._state, + ...next, + }; + this._invalidateSnapshot(); + this._emit(); +} +; +ControllerPrototype._getProviderDescriptorsSnapshot = function _getProviderDescriptorsSnapshot(this: AutocompleteControllerRuntime): readonly AutocompleteProviderDescriptor[] { + if (this._providerDescriptorsSnapshot === null) { + this._providerDescriptorsSnapshot = freezeProviderDescriptors( + this._providerRegistry.listProviderDescriptors(), + ); + } + return this._providerDescriptorsSnapshot; +} +; +ControllerPrototype._invalidateSnapshot = function _invalidateSnapshot(this: AutocompleteControllerRuntime): void { + this._snapshot = null; +} +; +ControllerPrototype._invalidateProviderDescriptorsSnapshot = function _invalidateProviderDescriptorsSnapshot(this: AutocompleteControllerRuntime): void { + this._providerDescriptorsSnapshot = null; + this._invalidateSnapshot(); +} +; +ControllerPrototype._emit = function _emit(this: AutocompleteControllerRuntime): void { + for (const listener of this._listeners) { + listener(); + } +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteDebug.ts b/packages/extensions/ai-autocomplete/src/autocompleteDebug.ts new file mode 100644 index 0000000..d80dd1f --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteDebug.ts @@ -0,0 +1,26 @@ +const AI_AUTOCOMPLETE_LOG_PREFIX = "[ai-autocomplete]"; +const AUTOCOMPLETE_DEBUG_ENABLED = + typeof globalThis === "object" && + "process" in globalThis && + ( + globalThis as { + process?: { env?: Record }; + } + ).process?.env?.PEN_AUTOCOMPLETE_DEBUG === "true"; + +export function logAutocompleteEvent(message: string, details?: unknown): void { + if (!AUTOCOMPLETE_DEBUG_ENABLED) { + return; + } + if (details === undefined) { + console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`); + return; + } + console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`, details); +} + +export function previewAutocompleteTextForLog(text: string): string { + return JSON.stringify( + text.length > 160 ? `${text.slice(0, 160)}...` : text, + ); +} diff --git a/packages/extensions/ai-autocomplete/src/continuationState.ts b/packages/extensions/ai-autocomplete/src/continuationState.ts index b45c268..38278dd 100644 --- a/packages/extensions/ai-autocomplete/src/continuationState.ts +++ b/packages/extensions/ai-autocomplete/src/continuationState.ts @@ -1,4 +1,5 @@ -import type { SelectionState } from "@pen/types"; +import type { OpOrigin, SelectionState } from "@pen/types"; +import { getOpOriginType } from "@pen/types"; import type { AutocompleteStructuredCandidate } from "./structuredCandidate"; export type AutocompleteSequence = { @@ -66,8 +67,11 @@ export class AutocompleteContinuationState { this._isAcceptingSequenceSegment = true; } - consumeAcceptedAiCommit(origin: unknown): boolean { - if (!this._isAcceptingSequenceSegment || origin !== "ai") { + consumeAcceptedAiCommit(origin: OpOrigin): boolean { + if ( + !this._isAcceptingSequenceSegment || + getOpOriginType(origin) !== "ai" + ) { return false; } this._isAcceptingSequenceSegment = false; diff --git a/packages/extensions/ai-autocomplete/src/extension.ts b/packages/extensions/ai-autocomplete/src/extension.ts index 060b51b..52a4632 100644 --- a/packages/extensions/ai-autocomplete/src/extension.ts +++ b/packages/extensions/ai-autocomplete/src/extension.ts @@ -1,1288 +1,11 @@ -import type { - Editor, - Extension, - FieldEditor, - InlineCompletionController, - ModelAdapter, - ModelStreamEvent, - TextSelection, -} from "@pen/types"; -import { - createDecorationSet, - ensureInlineCompletionController, -} from "@pen/core"; -import { - INLINE_COMPLETION_SLOT, - AI_AUTOCOMPLETE_CONTROLLER_SLOT, - FIELD_EDITOR_SLOT_KEY, - defineExtension, -} from "@pen/types"; -import { - DEFAULT_DEBOUNCE_MS, - DEFAULT_ACCEPTANCE_STRATEGY, - DEFAULT_MAX_NEIGHBOR_CHARS, - DEFAULT_MAX_PREFIX_CHARS, - DEFAULT_MAX_PROVIDER_CHARS, - DEFAULT_MAX_PROVIDER_TIME_MS, - DEFAULT_MAX_SUFFIX_CHARS, - DEFAULT_PREFETCH_AFTER_ACCEPT, - DEFAULT_STALE_AFTER_MS, -} from "./constants"; -import { buildAutocompleteMessages } from "./promptBuilder"; -import { builtinAutocompleteProviders } from "./providers/builtins"; -import { AutocompleteProviderRegistry } from "./providers/registry"; -import type { - AutocompleteContextProvider, - AutocompleteProviderDescriptor, -} from "./providers/types"; -import type { - AutocompleteAcceptanceStrategy, - AutocompleteBlockedReason, - AutocompleteBlockPolicy, - AutocompleteController, - AutocompleteControllerSnapshot, - AutocompleteControllerState, - AutocompleteDismissReason, - AutocompleteExtensionConfig, - AutocompletePolicyInvalidationStage, - AutocompleteRequestContext, -} from "./types"; -import { - createAutocompleteStructuredCandidate, - materializeStructuredCandidateAcceptance, -} from "./structuredCandidate"; -import { AutocompleteContinuationState } from "./continuationState"; +import type { Editor, Extension, InlineCompletionController } from "@pen/types"; +import { createDecorationSet, ensureInlineCompletionController } from "@pen/core"; +import { AI_AUTOCOMPLETE_CONTROLLER_SLOT, defineExtension } from "@pen/types"; +import type { AutocompleteController, AutocompleteExtensionConfig } from "./types"; +import { AutocompleteControllerImpl } from "./autocompleteController"; export const AI_AUTOCOMPLETE_EXTENSION_NAME = "ai-autocomplete"; export const AUTOCOMPLETE_CONTROLLER_SLOT = AI_AUTOCOMPLETE_CONTROLLER_SLOT; -const AI_AUTOCOMPLETE_LOG_PREFIX = "[ai-autocomplete]"; -const AUTOCOMPLETE_DEBUG_ENABLED = - typeof globalThis === "object" && - "process" in globalThis && - ( - globalThis as { - process?: { env?: Record }; - } - ).process?.env?.PEN_AUTOCOMPLETE_DEBUG === "true"; -const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; -const PROSE_BLOCK_TYPES = new Set([ - "paragraph", - "heading", - "blockquote", - "callout", -]); -const MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS = 3; - -class AutocompleteControllerImpl implements AutocompleteController { - private readonly _editor: Editor; - private readonly _model: ModelAdapter | undefined; - private _debounceMs: number; - private _acceptanceStrategy: AutocompleteAcceptanceStrategy; - private _staleAfterMs: number; - private readonly _maxPrefixChars: number; - private readonly _maxSuffixChars: number; - private readonly _maxNeighborChars: number; - private readonly _maxProviderChars: number; - private readonly _maxProviderTimeMs: number; - private _prefetchAfterAccept: boolean; - private readonly _providerRegistry: AutocompleteProviderRegistry; - private readonly _inlineCompletion: InlineCompletionController; - private readonly _listeners = new Set<() => void>(); - private _snapshot: AutocompleteControllerSnapshot | null = null; - private _providerDescriptorsSnapshot: - | readonly AutocompleteProviderDescriptor[] - | null = null; - private _state: AutocompleteControllerState = { - enabled: true, - status: "idle", - activeRequestId: null, - visibleSuggestionId: null, - settings: { - debounceMs: DEFAULT_DEBOUNCE_MS, - prefetchAfterAccept: DEFAULT_PREFETCH_AFTER_ACCEPT, - acceptanceStrategy: "full", - staleAfterMs: DEFAULT_STALE_AFTER_MS, - }, - blockPolicy: { - allowInCodeBlocks: true, - allowInTables: false, - deniedBlockTypes: ["database"], - }, - metrics: { - requestCount: 0, - successCount: 0, - cancelCount: 0, - staleDropCount: 0, - explicitTabTriggerCount: 0, - acceptCount: 0, - policyInvalidationScheduledCount: 0, - policyInvalidationRequestingCount: 0, - policyInvalidationShowingCount: 0, - }, - providerTimings: [], - diagnostics: { - lastDismissReason: null, - lastBlockedReason: null, - lastPolicyInvalidationStage: null, - }, - }; - private _debounceTimer: ReturnType | null = null; - private _abortController: AbortController | null = null; - private _unsubscribeSelection: (() => void) | null = null; - private _unsubscribeCommit: (() => void) | null = null; - private readonly _continuation = new AutocompleteContinuationState(); - private _prefetchAbortController: AbortController | null = null; - - constructor( - editor: Editor, - config: AutocompleteExtensionConfig, - services: { inlineCompletion: InlineCompletionController }, - ) { - this._editor = editor; - this._inlineCompletion = services.inlineCompletion; - this._model = config.model; - this._debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; - this._acceptanceStrategy = config.acceptanceStrategy ?? "full"; - this._staleAfterMs = config.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; - this._state.blockPolicy = { - allowInCodeBlocks: true, - allowInTables: false, - deniedBlockTypes: ["database"], - ...config.blockPolicy, - }; - this._maxPrefixChars = - config.maxPrefixChars ?? DEFAULT_MAX_PREFIX_CHARS; - this._maxSuffixChars = - config.maxSuffixChars ?? DEFAULT_MAX_SUFFIX_CHARS; - this._maxNeighborChars = - config.maxNeighborChars ?? DEFAULT_MAX_NEIGHBOR_CHARS; - this._maxProviderChars = - config.maxProviderChars ?? DEFAULT_MAX_PROVIDER_CHARS; - this._maxProviderTimeMs = - config.maxProviderTimeMs ?? DEFAULT_MAX_PROVIDER_TIME_MS; - this._prefetchAfterAccept = - config.prefetchAfterAccept ?? DEFAULT_PREFETCH_AFTER_ACCEPT; - this._providerRegistry = new AutocompleteProviderRegistry([ - ...builtinAutocompleteProviders, - ...(config.providers ?? []), - ]); - this._state.enabled = config.enabled ?? true; - this._state.settings = { - debounceMs: this._debounceMs, - prefetchAfterAccept: this._prefetchAfterAccept, - acceptanceStrategy: this._acceptanceStrategy, - staleAfterMs: this._staleAfterMs, - }; - - this._unsubscribeSelection = this._editor.onSelectionChange(() => { - if (this._shouldDismissForSelectionChange()) { - this.dismiss("selection-change"); - } - }); - this._unsubscribeCommit = this._editor.onDocumentCommit((event) => { - if (!this._state.enabled) { - return; - } - if (this._continuation.consumeAcceptedAiCommit(event.origin)) { - return; - } - if (event.origin !== "user" && event.origin !== "input-rule") { - if ( - this._shouldDismissForExternalCommit(event.affectedBlocks) - ) { - this.dismiss("external-edit"); - } - return; - } - this.request(); - }); - } - - destroy(): void { - this._unsubscribeSelection?.(); - this._unsubscribeSelection = null; - this._unsubscribeCommit?.(); - this._unsubscribeCommit = null; - this._clearDebounceTimer(); - this._abortController?.abort(); - this._abortController = null; - this._prefetchAbortController?.abort(); - this._prefetchAbortController = null; - this._continuation.clearContinuations(); - } - - getSnapshot(): AutocompleteControllerSnapshot { - if (this._snapshot === null) { - const state = cloneAutocompleteControllerState(this._state); - this._snapshot = freezeAutocompleteControllerSnapshot({ - state: freezeAutocompleteControllerState(state), - providerDescriptors: this._getProviderDescriptorsSnapshot(), - }); - } - return this._snapshot; - } - - getState(): AutocompleteControllerState { - return this.getSnapshot().state; - } - - getBlockPolicy(): Readonly { - return this.getSnapshot().state.blockPolicy; - } - - subscribe(listener: () => void): () => void { - this._listeners.add(listener); - return () => this._listeners.delete(listener); - } - - setEnabled(enabled: boolean): void { - if (this._state.enabled === enabled) { - return; - } - this._state = { - ...this._state, - enabled, - status: enabled ? "idle" : "idle", - activeRequestId: null, - }; - this._invalidateSnapshot(); - if (!enabled) { - this.dismiss("disabled"); - } - this._emit(); - } - - request(options?: { explicit?: boolean }): boolean { - if (!this._state.enabled) { - this._setBlockedReason("disabled"); - return false; - } - if (!this._model) { - this._setBlockedReason("missing-model"); - return false; - } - // Validate that autocomplete is currently eligible, but defer reading the - // exact caret context until the debounced request actually runs. - if (!this._buildContext()) { - return false; - } - this.dismiss("request-replaced"); - const requestId = crypto.randomUUID(); - this._setState({ - status: "scheduled", - activeRequestId: requestId, - metrics: { - ...this._state.metrics, - requestCount: this._state.metrics.requestCount + 1, - explicitTabTriggerCount: - this._state.metrics.explicitTabTriggerCount + - (options?.explicit ? 1 : 0), - }, - diagnostics: { - ...this._state.diagnostics, - lastBlockedReason: null, - lastPolicyInvalidationStage: null, - }, - }); - this._clearDebounceTimer(); - const delay = options?.explicit ? 0 : this._debounceMs; - logAutocompleteEvent("request scheduled", { - requestId, - explicit: options?.explicit ?? false, - debounceMs: delay, - }); - this._debounceTimer = setTimeout(() => { - void this._runRequest(requestId); - }, delay); - return true; - } - - acceptVisibleSuggestion(): boolean { - const sequence = this._continuation.sequence; - if (!sequence || !this.hasVisibleSuggestion()) { - return false; - } - const policyFailure = this._resolveCurrentBlockFailure( - sequence.blockId, - ); - if (policyFailure) { - this._recordPolicyInvalidation(policyFailure, "showing"); - return false; - } - return this._acceptFullVisibleSuggestion({ - activateContinuation: true, - }); - } - - private _acceptFullVisibleSuggestion(options?: { - activateContinuation?: boolean; - }): boolean { - const sequence = this._continuation.sequence; - if (!sequence) { - return false; - } - const candidate = sequence.candidate; - if ( - candidate.inlineText.length === 0 && - candidate.previewBlocks.length === 0 - ) { - this.dismiss(); - return false; - } - const blockId = sequence.blockId; - const requestId = sequence.requestId; - const continuationDepth = sequence.continuationDepth + 1; - const acceptanceResult = materializeStructuredCandidateAcceptance({ - blockId, - offset: sequence.startOffset, - candidate, - }); - logAutocompleteEvent("accept visible suggestion", { - requestId, - blockId, - startOffset: sequence.startOffset, - inlineLength: candidate.inlineText.length, - inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), - appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map( - (block) => block.type, - ), - opTypes: acceptanceResult.ops.map((op) => op.type), - nextCaretBlockId: acceptanceResult.selection.blockId, - nextCaretOffset: acceptanceResult.selection.offset, - }); - this._continuation.beginAcceptingSequenceSegment(); - this._editor.apply(acceptanceResult.ops, { - origin: "ai", - undoGroup: true, - }); - const acceptedBlock = this._editor.getBlock(blockId); - const firstNextBlock = acceptedBlock?.next ?? null; - const secondNextBlock = firstNextBlock?.next ?? null; - logAutocompleteEvent( - `accept applied summary requestId=${requestId} appendedBlockCount=${candidate.appendedBlocks.length} opTypes=${acceptanceResult.ops.map((op) => op.type).join(",")} currentBlockType=${acceptedBlock?.type ?? "missing"} currentBlockText=${previewAutocompleteTextForLog(acceptedBlock?.textContent() ?? "")} nextBlockType=${firstNextBlock?.type ?? "none"} nextBlockText=${previewAutocompleteTextForLog(firstNextBlock?.textContent() ?? "")} nextNextBlockType=${secondNextBlock?.type ?? "none"} nextNextBlockText=${previewAutocompleteTextForLog(secondNextBlock?.textContent() ?? "")}`, - ); - const nextCaretBlockId = acceptanceResult.selection.blockId; - const nextCaretOffset = acceptanceResult.selection.offset; - this._setState({ - metrics: { - ...this._state.metrics, - acceptCount: this._state.metrics.acceptCount + 1, - }, - }); - const fieldEditor = this._getFieldEditor(); - this._editor.selectText( - nextCaretBlockId, - nextCaretOffset, - nextCaretOffset, - ); - if (fieldEditor) { - const programmaticFieldEditor = - fieldEditor as typeof fieldEditor & { - commitProgrammaticTextSelection?: ( - blockId: string, - anchorOffset: number, - focusOffset: number, - ) => void; - }; - if ( - typeof programmaticFieldEditor.commitProgrammaticTextSelection === - "function" - ) { - programmaticFieldEditor.commitProgrammaticTextSelection( - nextCaretBlockId, - nextCaretOffset, - nextCaretOffset, - ); - } else if ( - typeof fieldEditor.activateTextSelection === "function" - ) { - fieldEditor.activateTextSelection( - nextCaretBlockId, - nextCaretOffset, - nextCaretOffset, - ); - } else if (typeof fieldEditor.activate === "function") { - fieldEditor.activate(nextCaretBlockId); - } - if (typeof fieldEditor.focus === "function") { - fieldEditor.focus(); - } - } - - if (options?.activateContinuation && this._prefetchAfterAccept) { - this._continuation.setPendingAcceptedContinuation({ - sourceRequestId: requestId, - blockId: nextCaretBlockId, - startOffset: nextCaretOffset, - continuationDepth, - }); - this._clearVisibleSuggestionAfterAccept(); - this._startPrefetchForAcceptedContinuation({ - sourceRequestId: requestId, - blockId: nextCaretBlockId, - startOffset: nextCaretOffset, - continuationDepth, - }); - } else { - this.dismiss("accept"); - } - return true; - } - - hasVisibleSuggestion(): boolean { - return ( - this._continuation.sequence !== null && - this._state.visibleSuggestionId !== null - ); - } - - registerProvider(provider: AutocompleteContextProvider): () => void { - const unregister = this._providerRegistry.registerProvider(provider); - this._invalidateProviderDescriptorsSnapshot(); - this._emit(); - return () => { - unregister(); - this._invalidateProviderDescriptorsSnapshot(); - this._emit(); - }; - } - - listProviderDescriptors() { - return this.getSnapshot().providerDescriptors; - } - - updateRuntimeSettings( - settings: Partial, - ): void { - const nextDebounceMs = settings.debounceMs; - const nextPrefetchAfterAccept = settings.prefetchAfterAccept; - const nextAcceptanceStrategy = settings.acceptanceStrategy; - let changed = false; - - if ( - typeof nextDebounceMs === "number" && - Number.isFinite(nextDebounceMs) && - nextDebounceMs >= 0 && - nextDebounceMs !== this._debounceMs - ) { - this._debounceMs = nextDebounceMs; - changed = true; - } - - if ( - typeof nextPrefetchAfterAccept === "boolean" && - nextPrefetchAfterAccept !== this._prefetchAfterAccept - ) { - this._prefetchAfterAccept = nextPrefetchAfterAccept; - if (!nextPrefetchAfterAccept) { - this._prefetchAbortController?.abort(); - this._prefetchAbortController = null; - this._continuation.clearContinuations(); - } - changed = true; - } - - if ( - nextAcceptanceStrategy === "full" && - nextAcceptanceStrategy !== this._acceptanceStrategy - ) { - this._acceptanceStrategy = nextAcceptanceStrategy; - changed = true; - } - - const nextStaleAfterMs = settings.staleAfterMs; - if ( - typeof nextStaleAfterMs === "number" && - Number.isFinite(nextStaleAfterMs) && - nextStaleAfterMs >= 0 && - nextStaleAfterMs !== this._staleAfterMs - ) { - this._staleAfterMs = nextStaleAfterMs; - changed = true; - } - - if (!changed) { - return; - } - - this._setState({ - settings: { - debounceMs: this._debounceMs, - prefetchAfterAccept: this._prefetchAfterAccept, - acceptanceStrategy: this._acceptanceStrategy, - staleAfterMs: this._staleAfterMs, - }, - }); - } - - updateBlockPolicy(policy: Partial): void { - const nextPolicy: AutocompleteBlockPolicy = { - ...this._state.blockPolicy, - ...policy, - }; - if (areBlockPoliciesEqual(this._state.blockPolicy, nextPolicy)) { - return; - } - this._setState({ - blockPolicy: nextPolicy, - }); - this._invalidateForPolicyChange(); - } - - dismiss(reason: AutocompleteDismissReason = "external-edit"): void { - this._clearDebounceTimer(); - const cancelledRequest = - this._state.status === "scheduled" || - this._state.status === "requesting"; - this._abortController?.abort(); - this._abortController = null; - this._prefetchAbortController?.abort(); - this._prefetchAbortController = null; - this._clearSequence(); - this._continuation.clearContinuations(); - this._setState({ - status: "idle", - activeRequestId: null, - visibleSuggestionId: null, - metrics: { - ...this._state.metrics, - cancelCount: - this._state.metrics.cancelCount + - (cancelledRequest ? 1 : 0), - }, - diagnostics: { - ...this._state.diagnostics, - lastDismissReason: reason, - }, - }); - this._inlineCompletion.dismissSuggestion(); - } - - private async _runRequest(requestId: string): Promise { - if (this._state.activeRequestId !== requestId || !this._model) { - logAutocompleteEvent("request skipped before start", { - requestId, - hasModel: !!this._model, - activeRequestId: this._state.activeRequestId, - }); - return; - } - this._abortController?.abort(); - const abortController = new AbortController(); - this._abortController = abortController; - const context = this._buildContext(); - if (!context) { - logAutocompleteEvent("request blocked before prompt build", { - requestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - this._setState({ - status: "idle", - activeRequestId: null, - }); - return; - } - this._setState({ - status: "requesting", - activeRequestId: requestId, - }); - logAutocompleteEvent("request started", { - requestId, - blockId: context.blockId, - offset: context.offset, - }); - - const { messages, providerTimings } = await buildAutocompleteMessages({ - context, - registry: this._providerRegistry, - maxProviderChars: this._maxProviderChars, - maxProviderTimeMs: this._maxProviderTimeMs, - continuationDepth: 0, - }); - if (!this._shouldContinueRequest(requestId, context)) { - logAutocompleteEvent("request cancelled after prompt build", { - requestId, - activeRequestId: this._state.activeRequestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - return; - } - this._setState({ providerTimings }); - logAutocompleteEvent("request prompt ready", { - requestId, - providerTimings, - promptLength: String(messages[1]?.content ?? "").length, - }); - const startedAt = Date.now(); - - let text = ""; - try { - logAutocompleteEvent("request model stream opening", { requestId }); - for await (const event of this._model.stream({ - messages, - tools: [], - signal: abortController.signal, - requestMode: AUTOCOMPLETE_REQUEST_MODE, - })) { - if (!this._shouldContinueRequest(requestId, context)) { - logAutocompleteEvent("request cancelled during stream", { - requestId, - activeRequestId: this._state.activeRequestId, - lastBlockedReason: - this._state.diagnostics.lastBlockedReason, - }); - abortController.abort(); - return; - } - logAutocompleteEvent("request model event", { - requestId, - type: event.type, - }); - if ( - !handleModelEvent(event, (delta) => { - text += delta; - }) - ) { - break; - } - } - } catch { - logAutocompleteEvent("request stream threw", { - requestId, - aborted: abortController.signal.aborted, - }); - if (!abortController.signal.aborted) { - this._setState({ - status: "idle", - activeRequestId: null, - }); - } - return; - } - - if (!this._shouldContinueRequest(requestId, context)) { - logAutocompleteEvent("request cancelled after stream", { - requestId, - activeRequestId: this._state.activeRequestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - return; - } - if (Date.now() - startedAt > this._staleAfterMs) { - logAutocompleteEvent("request dropped as stale", { - requestId, - elapsedMs: Date.now() - startedAt, - staleAfterMs: this._staleAfterMs, - }); - this._setState({ - status: "idle", - activeRequestId: null, - metrics: { - ...this._state.metrics, - staleDropCount: this._state.metrics.staleDropCount + 1, - }, - diagnostics: { - ...this._state.diagnostics, - lastDismissReason: "stale", - }, - }); - return; - } - const normalizedText = normalizeCompletionText(context, text); - logAutocompleteEvent("request normalized text", { - requestId, - blockType: context.blockType, - rawLength: text.length, - rawPreview: previewAutocompleteTextForLog(text), - normalizedLength: normalizedText.length, - normalizedPreview: previewAutocompleteTextForLog(normalizedText), - }); - if (!normalizedText) { - logAutocompleteEvent("request produced empty normalized text", { - requestId, - rawLength: text.length, - }); - this._setState({ - status: "idle", - activeRequestId: null, - }); - return; - } - - const candidate = createAutocompleteStructuredCandidate( - this._editor, - normalizedText, - { - activeBlockType: context.blockType, - continuationDepth: 0, - }, - ); - this._continuation.setSequence({ - requestId, - blockId: context.blockId, - startOffset: context.offset, - candidate, - continuationDepth: 0, - }); - this._setState({ - metrics: { - ...this._state.metrics, - successCount: this._state.metrics.successCount + 1, - }, - }); - logAutocompleteEvent("request produced suggestion", { - requestId, - blockType: context.blockType, - normalizedLength: normalizedText.length, - inlineLength: candidate.inlineText.length, - inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), - appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map( - (block) => block.type, - ), - previewBlockCount: candidate.previewBlocks.length, - }); - this._showSequenceSuggestion(); - } - - private _buildContext(): AutocompleteRequestContext | null { - const selection = this._editor.selection; - if (selection == null) { - this._setBlockedReason("missing-context"); - return null; - } - if (selection.type !== "text") { - this._setBlockedReason("selection-not-text"); - return null; - } - if (!selection.isCollapsed) { - this._setBlockedReason("selection-not-collapsed"); - return null; - } - if (selection.isMultiBlock) { - this._setBlockedReason("selection-multi-block"); - return null; - } - const fieldEditor = this._getFieldEditor(); - if (!fieldEditor) { - this._setBlockedReason("field-editor-unavailable"); - return null; - } - if (!fieldEditor.isEditing) { - this._setBlockedReason("field-editor-not-editing"); - return null; - } - if (!fieldEditor.isFocused) { - this._setBlockedReason("field-editor-not-focused"); - return null; - } - if (fieldEditor.isComposing) { - this._setBlockedReason("field-editor-composing"); - return null; - } - return this._buildContextForPosition( - selection.focus.blockId, - selection.focus.offset, - ); - } - - private _buildContextForPosition( - blockId: string, - offset: number, - ): AutocompleteRequestContext | null { - const block = this._editor.getBlock(blockId); - if (!block) { - this._setBlockedReason("block-missing"); - return null; - } - const blockPolicyFailure = this._resolveContextEligibilityFailure( - block.id, - block.type, - ); - if (blockPolicyFailure) { - this._setBlockedReason(blockPolicyFailure); - return null; - } - const blockText = block.textContent(); - return { - editor: this._editor, - blockId: block.id, - blockType: block.type, - offset, - prefixText: tail(blockText.slice(0, offset), this._maxPrefixChars), - suffixText: head(blockText.slice(offset), this._maxSuffixChars), - previousBlockText: tail( - block.prev?.textContent() ?? "", - this._maxNeighborChars, - ), - nextBlockText: head( - block.next?.textContent() ?? "", - this._maxNeighborChars, - ), - }; - } - - private _shouldContinueRequest( - requestId: string, - context: AutocompleteRequestContext, - ): boolean { - if (this._state.activeRequestId !== requestId) { - logAutocompleteEvent("request continuation blocked: replaced", { - requestId, - activeRequestId: this._state.activeRequestId, - }); - return false; - } - const selection = this._editor.selection; - if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock || - selection.focus.blockId !== context.blockId || - selection.focus.offset !== context.offset - ) { - logAutocompleteEvent( - "request continuation blocked: selection changed", - { - requestId, - expected: { - blockId: context.blockId, - offset: context.offset, - }, - actual: - selection?.type === "text" - ? { - type: selection.type, - blockId: selection.focus.blockId, - offset: selection.focus.offset, - isCollapsed: selection.isCollapsed, - isMultiBlock: selection.isMultiBlock, - } - : selection, - }, - ); - return false; - } - const fieldEditor = this._getFieldEditor(); - if ( - !fieldEditor?.isEditing || - !fieldEditor.isFocused || - fieldEditor.isComposing - ) { - logAutocompleteEvent( - "request continuation blocked: field editor state", - { - requestId, - fieldEditor: fieldEditor - ? { - isEditing: fieldEditor.isEditing, - isFocused: fieldEditor.isFocused, - isComposing: fieldEditor.isComposing, - focusBlockId: fieldEditor.focusBlockId, - } - : null, - }, - ); - return false; - } - const block = this._editor.getBlock(context.blockId); - const policyFailure = block - ? this._resolveContextEligibilityFailure(block.id, block.type) - : "block-missing"; - if (policyFailure) { - this._setBlockedReason(policyFailure); - return false; - } - return true; - } - - private _shouldDismissForExternalCommit( - affectedBlocks: readonly string[], - ): boolean { - const visibleSuggestion = - this._inlineCompletion.getState().visibleSuggestion; - return ( - !!visibleSuggestion && - affectedBlocks.includes(visibleSuggestion.blockId) - ); - } - - private _shouldDismissForSelectionChange(): boolean { - const visibleSuggestion = - this._inlineCompletion.getState().visibleSuggestion; - if (!visibleSuggestion || visibleSuggestion.type !== "inline") { - return false; - } - const selection = this._editor.selection; - if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock - ) { - return true; - } - return ( - selection.focus.blockId !== visibleSuggestion.blockId || - selection.focus.offset !== visibleSuggestion.offset - ); - } - - private _getFieldEditor(): FieldEditor | null { - return ( - this._editor.internals.getSlot( - FIELD_EDITOR_SLOT_KEY, - ) ?? null - ); - } - - private _showSequenceSuggestion(): void { - const sequence = this._continuation.sequence; - if (!sequence) { - return; - } - const suggestionId = sequence.requestId; - const preview = sequence.candidate; - this._inlineCompletion.showSuggestion({ - id: suggestionId, - blockId: sequence.blockId, - offset: sequence.startOffset, - text: preview.inlineText, - type: "inline", - previewBlocks: preview.previewBlocks, - }); - this._setState({ - status: "showing", - activeRequestId: sequence.requestId, - visibleSuggestionId: suggestionId, - }); - } - - private _startPrefetchForAcceptedContinuation(options: { - sourceRequestId: string; - blockId: string; - startOffset: number; - continuationDepth: number; - }): void { - if (!this._prefetchAfterAccept) { - return; - } - const context = this._buildContextForPosition( - options.blockId, - options.startOffset, - ); - if (!context) { - return; - } - this._prefetchAbortController?.abort(); - const abortController = new AbortController(); - this._prefetchAbortController = abortController; - void this._runPrefetchRequest({ - abortController, - context, - continuationDepth: options.continuationDepth, - sourceRequestId: options.sourceRequestId, - }); - } - - private async _runPrefetchRequest(options: { - abortController: AbortController; - context: AutocompleteRequestContext; - continuationDepth: number; - sourceRequestId: string; - }): Promise { - if (!this._model) { - return; - } - const { abortController, context, continuationDepth, sourceRequestId } = - options; - const requestId = crypto.randomUUID(); - const { messages } = await buildAutocompleteMessages({ - context, - registry: this._providerRegistry, - maxProviderChars: this._maxProviderChars, - maxProviderTimeMs: this._maxProviderTimeMs, - mode: "continuation", - continuationDepth, - }); - if (abortController.signal.aborted) { - return; - } - - let text = ""; - try { - for await (const event of this._model.stream({ - messages, - tools: [], - signal: abortController.signal, - requestMode: AUTOCOMPLETE_REQUEST_MODE, - })) { - if (abortController.signal.aborted) { - return; - } - if ( - !handleModelEvent(event, (delta) => { - text += delta; - }) - ) { - break; - } - } - } catch { - return; - } - - if (abortController.signal.aborted) { - return; - } - const normalizedText = normalizeCompletionText(context, text); - if (!normalizedText) { - logAutocompleteEvent("prefetch produced empty normalized text", { - requestId, - sourceRequestId, - blockType: context.blockType, - rawLength: text.length, - rawPreview: previewAutocompleteTextForLog(text), - }); - return; - } - const candidate = createAutocompleteStructuredCandidate( - this._editor, - normalizedText, - { - activeBlockType: context.blockType, - continuationDepth, - }, - ); - logAutocompleteEvent("prefetch produced suggestion", { - requestId, - sourceRequestId, - blockType: context.blockType, - rawLength: text.length, - rawPreview: previewAutocompleteTextForLog(text), - normalizedLength: normalizedText.length, - normalizedPreview: previewAutocompleteTextForLog(normalizedText), - inlineLength: candidate.inlineText.length, - inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), - appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map( - (block) => block.type, - ), - previewBlockCount: candidate.previewBlocks.length, - }); - this._continuation.setPrefetchedContinuation({ - sourceRequestId, - requestId, - blockId: context.blockId, - startOffset: context.offset, - candidate, - continuationDepth, - }); - this._activatePendingAcceptedContinuation(); - } - - private _activatePendingAcceptedContinuation(): boolean { - if ( - !this._continuation.activatePendingAcceptedContinuation( - this._editor.selection, - ) - ) { - return false; - } - this._showSequenceSuggestion(); - return true; - } - - private _clearSequence(): void { - this._continuation.clearSequence(); - } - - private _clearVisibleSuggestionAfterAccept(): void { - this._clearSequence(); - this._setState({ - status: "idle", - activeRequestId: null, - visibleSuggestionId: null, - diagnostics: { - ...this._state.diagnostics, - lastDismissReason: "accept", - }, - }); - this._inlineCompletion.dismissSuggestion(); - } - - private _setBlockedReason(reason: AutocompleteBlockedReason): void { - this._setState({ - diagnostics: { - ...this._state.diagnostics, - lastBlockedReason: reason, - }, - }); - } - - private _recordPolicyInvalidation( - policyFailure: AutocompleteBlockedReason, - invalidationStage: AutocompletePolicyInvalidationStage | null, - ): void { - this._setBlockedReason(policyFailure); - if (invalidationStage) { - this._setState({ - metrics: incrementPolicyInvalidationMetrics( - this._state.metrics, - invalidationStage, - ), - diagnostics: { - ...this._state.diagnostics, - lastPolicyInvalidationStage: invalidationStage, - }, - }); - } - if (invalidationStage || this._continuation.hasPrefetchedContinuation) { - this.dismiss("policy-change"); - } - } - - private _invalidateForPolicyChange(): void { - const activeBlockId = - this._continuation.sequence?.blockId ?? - this._getActiveSelectionBlockId(); - if (!activeBlockId) { - return; - } - const policyFailure = this._resolveCurrentBlockFailure(activeBlockId); - if (!policyFailure) { - return; - } - const invalidationStage = this._getPolicyInvalidationStage(); - this._recordPolicyInvalidation(policyFailure, invalidationStage); - } - - private _getActiveSelectionBlockId(): string | null { - const selection = this._editor.selection; - return selection?.type === "text" ? selection.focus.blockId : null; - } - - private _getPolicyInvalidationStage(): AutocompletePolicyInvalidationStage | null { - if ( - this._state.status === "scheduled" || - this._state.status === "requesting" - ) { - return this._state.status; - } - if ( - this._state.status === "showing" || - this._continuation.sequence || - this._continuation.hasPrefetchedContinuation - ) { - return "showing"; - } - return null; - } - - private _resolveCurrentBlockFailure( - blockId: string, - ): AutocompleteBlockedReason | null { - const block = this._editor.getBlock(blockId); - if (!block) { - return "block-missing"; - } - return this._resolveContextEligibilityFailure(block.id, block.type); - } - - private _resolveContextEligibilityFailure( - blockId: string, - blockType: string | null, - ): AutocompleteBlockedReason | null { - const blockPolicyFailure = this._resolveBlockPolicyFailure(blockType); - if (blockPolicyFailure) { - return blockPolicyFailure; - } - const fieldEditor = this._getFieldEditor() as - | (FieldEditor & { activeCellCoord?: { blockId: string } | null }) - | null; - if ( - fieldEditor?.activeCellCoord && - fieldEditor.activeCellCoord.blockId === blockId && - this._state.blockPolicy.allowInTables !== true - ) { - return "table-cell-active"; - } - return null; - } - - private _resolveBlockPolicyFailure( - blockType: string | null, - ): AutocompleteBlockedReason | null { - if (!blockType) { - return null; - } - const allowedBlockTypes = this._state.blockPolicy.allowedBlockTypes; - if ( - allowedBlockTypes && - allowedBlockTypes.length > 0 && - !allowedBlockTypes.includes(blockType) - ) { - return "block-type-not-allowed"; - } - const deniedBlockTypes = this._state.blockPolicy.deniedBlockTypes; - if (deniedBlockTypes?.includes(blockType)) { - return "block-type-denied"; - } - if ( - blockType === "codeBlock" && - this._state.blockPolicy.allowInCodeBlocks === false - ) { - return "code-block-disabled"; - } - if ( - blockType === "table" && - this._state.blockPolicy.allowInTables !== true - ) { - return "table-disabled"; - } - return null; - } - - private _clearDebounceTimer(): void { - if (this._debounceTimer !== null) { - clearTimeout(this._debounceTimer); - this._debounceTimer = null; - } - } - - private _setState(next: Partial): void { - this._state = { - ...this._state, - ...next, - }; - this._invalidateSnapshot(); - this._emit(); - } - - private _getProviderDescriptorsSnapshot(): readonly AutocompleteProviderDescriptor[] { - if (this._providerDescriptorsSnapshot === null) { - this._providerDescriptorsSnapshot = freezeProviderDescriptors( - this._providerRegistry.listProviderDescriptors(), - ); - } - return this._providerDescriptorsSnapshot; - } - - private _invalidateSnapshot(): void { - this._snapshot = null; - } - - private _invalidateProviderDescriptorsSnapshot(): void { - this._providerDescriptorsSnapshot = null; - this._invalidateSnapshot(); - } - - private _emit(): void { - for (const listener of this._listeners) { - listener(); - } - } -} export function autocompleteExtension( config: AutocompleteExtensionConfig = {}, @@ -1330,404 +53,3 @@ export function getAutocompleteController( ) ?? null ); } - -function handleModelEvent( - event: ModelStreamEvent, - onTextDelta: (delta: string) => void, -): boolean { - if (event.type === "text-delta") { - onTextDelta(event.delta); - return true; - } - if (event.type === "done" || event.type === "error") { - return false; - } - return true; -} - -function normalizeCompletionText( - context: AutocompleteRequestContext, - text: string, -): string { - const normalized = text.replace(/\r/g, ""); - const withoutFence = normalized - .replace(/^```[a-zA-Z0-9_-]*\n?/, "") - .replace(/```$/, ""); - const withoutWrappedQuotes = stripWrappedCompletionQuotes( - context, - withoutFence, - ); - const trimmedLeading = - withoutWrappedQuotes.startsWith("\n\n") || - startsWithStructuredBlockContinuation(withoutWrappedQuotes) - ? withoutWrappedQuotes - : withoutWrappedQuotes.replace(/^\s*\n/, ""); - if (!trimmedLeading) { - return ""; - } - let candidate = trimmedLeading; - const suffixEcho = longestCommonPrefix(context.suffixText, trimmedLeading); - if (suffixEcho.length > 0) { - candidate = trimmedLeading.slice(suffixEcho.length); - } else if (context.suffixText.length === 0) { - const prefixEcho = longestSuffixPrefixOverlap( - context.prefixText, - trimmedLeading, - ); - if (prefixEcho.length > 0) { - candidate = trimmedLeading.slice(prefixEcho.length); - } - } - candidate = maybeInsertMissingBoundarySpace(context, candidate); - candidate = stripLeadingBoundaryPunctuationArtifacts(context, candidate); - candidate = collapseDuplicateBoundaryWhitespace(context, candidate); - candidate = maybeCapitalizeSentenceStart(context, candidate); - if (shouldRejectLowQualityCompletion(context, candidate)) { - return ""; - } - return candidate; -} - -function startsWithStructuredBlockContinuation(text: string): boolean { - return /^\s*\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test( - text, - ); -} - -function longestCommonPrefix(left: string, right: string): string { - const maxLength = Math.min(left.length, right.length); - let index = 0; - while (index < maxLength && left[index] === right[index]) { - index += 1; - } - return left.slice(0, index); -} - -function longestSuffixPrefixOverlap(left: string, right: string): string { - const maxLength = Math.min(left.length, right.length); - for (let length = maxLength; length > 0; length -= 1) { - const overlap = right.slice(0, length); - if (left.endsWith(overlap)) { - return overlap; - } - } - return ""; -} - -function maybeInsertMissingBoundarySpace( - context: AutocompleteRequestContext, - completion: string, -): string { - if ( - !completion || - context.suffixText.length > 0 || - !PROSE_BLOCK_TYPES.has(context.blockType ?? "") - ) { - return completion; - } - const lastPrefixChar = context.prefixText.slice(-1); - const firstCompletionChar = completion[0]; - if ( - !isWordLikeChar(lastPrefixChar) || - !isWordLikeChar(firstCompletionChar) - ) { - return completion; - } - if (!hasLikelyWordBoundary(completion)) { - return completion; - } - const leadingWord = completion.match(/^[A-Za-z0-9_'-]+/)?.[0] ?? ""; - if (leadingWord.length > 0 && leadingWord.length <= 2) { - return completion; - } - return ` ${completion}`; -} - -function stripWrappedCompletionQuotes( - context: AutocompleteRequestContext, - completion: string, -): string { - if (!completion || context.suffixText.length > 0) { - return completion; - } - const trimmed = completion.trim(); - if (trimmed.length < 2 || isLikelyInsideOpenQuote(context.prefixText)) { - return completion; - } - const unwrapped = unwrapMatchingQuotes(trimmed); - if (unwrapped == null) { - return completion; - } - const leadingWhitespace = completion.match(/^\s*/)?.[0] ?? ""; - const trailingWhitespace = completion.match(/\s*$/)?.[0] ?? ""; - return `${leadingWhitespace}${unwrapped}${trailingWhitespace}`; -} - -function stripLeadingBoundaryPunctuationArtifacts( - context: AutocompleteRequestContext, - completion: string, -): string { - if ( - !completion || - context.suffixText.length > 0 || - !PROSE_BLOCK_TYPES.has(context.blockType ?? "") - ) { - return completion; - } - const prefixEndsWithWhitespace = /\s$/.test(context.prefixText); - const prefixEndsSentence = /[.!?]["')\]]*\s*$/.test(context.prefixText); - if (!prefixEndsWithWhitespace && !prefixEndsSentence) { - return completion; - } - if (prefixEndsWithWhitespace) { - return completion.replace(/^([ \t]*)([,.;:!?]+)(?=\s|["'A-Z])/u, "$1"); - } - if (prefixEndsSentence) { - return completion.replace(/^([ \t]*)([,;:]+)(?=\s|["'A-Z])/u, "$1"); - } - return completion; -} - -function collapseDuplicateBoundaryWhitespace( - context: AutocompleteRequestContext, - completion: string, -): string { - if (!completion || context.suffixText.length > 0) { - return completion; - } - if (!/\s$/.test(context.prefixText)) { - return completion; - } - return completion.replace(/^[ \t]+/u, ""); -} - -function maybeCapitalizeSentenceStart( - context: AutocompleteRequestContext, - completion: string, -): string { - if ( - !completion || - context.suffixText.length > 0 || - !PROSE_BLOCK_TYPES.has(context.blockType ?? "") || - !/[.!?]["')\]]*\s*$/.test(context.prefixText) - ) { - return completion; - } - return completion.replace( - /^(\s*["'([{“‘-]*)([a-z])/u, - (_, prefix: string, character: string) => - `${prefix}${character.toUpperCase()}`, - ); -} - -function shouldRejectLowQualityCompletion( - context: AutocompleteRequestContext, - completion: string, -): boolean { - const trimmed = completion.trim(); - if (!trimmed) { - return true; - } - if ( - PROSE_BLOCK_TYPES.has(context.blockType ?? "") && - context.suffixText.length === 0 && - countWordLikeTokens(trimmed) === 1 && - trimmed.length < MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS && - !/[.!?]$/.test(trimmed) - ) { - // Single-character or two-character prose guesses tend to feel like flicker. - // Allow short but still meaningful continuations such as "cat", "the", or "and". - return true; - } - return false; -} - -function countWordLikeTokens(value: string): number { - return value.match(/[A-Za-z0-9_'-]+/g)?.length ?? 0; -} - -function hasLikelyWordBoundary(value: string): boolean { - return /[\s.,!?;:]/.test(value.slice(1)); -} - -function isWordLikeChar(value: string): boolean { - return /[A-Za-z0-9]/.test(value); -} - -function unwrapMatchingQuotes(value: string): string | null { - const quotePairs: Array<[string, string]> = [ - ['"', '"'], - ["'", "'"], - ["“", "”"], - ["‘", "’"], - ]; - for (const [open, close] of quotePairs) { - if (value.startsWith(open) && value.endsWith(close)) { - const inner = value - .slice(open.length, value.length - close.length) - .trim(); - return inner.length > 0 ? inner : null; - } - } - return null; -} - -function isLikelyInsideOpenQuote(value: string): boolean { - const asciiDoubleQuotes = value.match(/"/g)?.length ?? 0; - const asciiSingleQuotes = value.match(/'/g)?.length ?? 0; - const smartOpenQuotes = value.match(/“/g)?.length ?? 0; - const smartCloseQuotes = value.match(/”/g)?.length ?? 0; - const smartOpenSingles = value.match(/‘/g)?.length ?? 0; - const smartCloseSingles = value.match(/’/g)?.length ?? 0; - return ( - asciiDoubleQuotes % 2 === 1 || - asciiSingleQuotes % 2 === 1 || - smartOpenQuotes > smartCloseQuotes || - smartOpenSingles > smartCloseSingles - ); -} - -function head(value: string, maxChars: number): string { - return value.length <= maxChars ? value : value.slice(0, maxChars); -} - -function tail(value: string, maxChars: number): string { - return value.length <= maxChars ? value : value.slice(-maxChars); -} - -function areBlockPoliciesEqual( - left: AutocompleteBlockPolicy, - right: AutocompleteBlockPolicy, -): boolean { - return ( - left.allowInCodeBlocks === right.allowInCodeBlocks && - left.allowInTables === right.allowInTables && - areStringArraysEqual(left.allowedBlockTypes, right.allowedBlockTypes) && - areStringArraysEqual(left.deniedBlockTypes, right.deniedBlockTypes) - ); -} - -function areStringArraysEqual( - left: readonly string[] | undefined, - right: readonly string[] | undefined, -): boolean { - if (left === right) { - return true; - } - if (!left || !right || left.length !== right.length) { - return false; - } - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) { - return false; - } - } - return true; -} - -function cloneBlockPolicy( - policy: AutocompleteBlockPolicy, -): AutocompleteBlockPolicy { - return { - allowInCodeBlocks: policy.allowInCodeBlocks, - allowInTables: policy.allowInTables, - allowedBlockTypes: policy.allowedBlockTypes - ? [...policy.allowedBlockTypes] - : undefined, - deniedBlockTypes: policy.deniedBlockTypes - ? [...policy.deniedBlockTypes] - : undefined, - }; -} - -function cloneAutocompleteControllerState( - state: AutocompleteControllerState, -): AutocompleteControllerState { - return { - enabled: state.enabled, - status: state.status, - activeRequestId: state.activeRequestId, - visibleSuggestionId: state.visibleSuggestionId, - settings: { ...state.settings }, - blockPolicy: cloneBlockPolicy(state.blockPolicy), - metrics: { ...state.metrics }, - providerTimings: state.providerTimings.map((timing) => ({ ...timing })), - diagnostics: { ...state.diagnostics }, - }; -} - -function freezeBlockPolicy( - policy: AutocompleteBlockPolicy, -): AutocompleteBlockPolicy { - if (policy.allowedBlockTypes) { - Object.freeze(policy.allowedBlockTypes); - } - if (policy.deniedBlockTypes) { - Object.freeze(policy.deniedBlockTypes); - } - return Object.freeze(policy); -} - -function freezeAutocompleteControllerState( - state: AutocompleteControllerState, -): AutocompleteControllerState { - Object.freeze(state.settings); - freezeBlockPolicy(state.blockPolicy); - Object.freeze(state.metrics); - for (const timing of state.providerTimings) { - Object.freeze(timing); - } - Object.freeze(state.providerTimings); - Object.freeze(state.diagnostics); - return Object.freeze(state); -} - -function freezeProviderDescriptors( - descriptors: readonly AutocompleteProviderDescriptor[], -): readonly AutocompleteProviderDescriptor[] { - for (const descriptor of descriptors) { - Object.freeze(descriptor); - } - return Object.freeze([...descriptors]); -} - -function freezeAutocompleteControllerSnapshot( - snapshot: AutocompleteControllerSnapshot, -): AutocompleteControllerSnapshot { - return Object.freeze(snapshot); -} - -function incrementPolicyInvalidationMetrics( - metrics: AutocompleteControllerState["metrics"], - stage: AutocompletePolicyInvalidationStage, -): AutocompleteControllerState["metrics"] { - return { - ...metrics, - policyInvalidationScheduledCount: - metrics.policyInvalidationScheduledCount + - (stage === "scheduled" ? 1 : 0), - policyInvalidationRequestingCount: - metrics.policyInvalidationRequestingCount + - (stage === "requesting" ? 1 : 0), - policyInvalidationShowingCount: - metrics.policyInvalidationShowingCount + - (stage === "showing" ? 1 : 0), - }; -} - -function logAutocompleteEvent(message: string, details?: unknown): void { - if (!AUTOCOMPLETE_DEBUG_ENABLED) { - return; - } - if (details === undefined) { - console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`); - return; - } - console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`, details); -} - -function previewAutocompleteTextForLog(text: string): string { - return JSON.stringify( - text.length > 160 ? `${text.slice(0, 160)}...` : text, - ); -} diff --git a/packages/extensions/ai-suggestions/package.json b/packages/extensions/ai-suggestions/package.json index 5eb7088..08a6e60 100644 --- a/packages/extensions/ai-suggestions/package.json +++ b/packages/extensions/ai-suggestions/package.json @@ -36,7 +36,7 @@ "README.md", "LICENSE.md" ], - "sideEffects": false, + "sideEffects": true, "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", diff --git a/packages/extensions/ai-suggestions/src/controller.ts b/packages/extensions/ai-suggestions/src/controller.ts index 03e81c6..5222d0d 100644 --- a/packages/extensions/ai-suggestions/src/controller.ts +++ b/packages/extensions/ai-suggestions/src/controller.ts @@ -1,771 +1,3 @@ -import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; -import type { DocumentCommitEvent, Editor, FieldEditor } from "@pen/types"; -import { buildApplySuggestionOps } from "./apply"; -import { - buildSuggestionFingerprint, - type CachedAnalysisResult, - isCacheEntryFresh, - isDismissFingerprintActive, -} from "./cache"; -import { - DEFAULT_CACHE_TTL_MS, - DEFAULT_DISMISS_MEMORY_MS, - DEFAULT_MAX_SUGGESTIONS_PER_SCOPE, - DEFAULT_MIN_CONFIDENCE, -} from "./constants"; -import { buildSuggestionGroups } from "./grouping"; -import { materializeSuggestionsFromCandidates } from "./matcher"; -import { analyzeSuggestionScope } from "./analyzer"; -import { AISuggestionScheduler } from "./scheduler"; -import { buildSuggestionScope } from "./scopeBuilder"; -import type { - AISuggestion, - AISuggestionCandidate, - AISuggestionGroup, - AISuggestionsController, - AISuggestionsExtensionConfig, - AISuggestionsMetrics, - AISuggestionsState, -} from "./types"; +import "./controllerRuntime"; -type StatePatch = Omit, "metrics"> & { - metrics?: Partial; -}; - -const INITIAL_METRICS: AISuggestionsMetrics = { - requestCount: 0, - successCount: 0, - errorCount: 0, - cancelCount: 0, - cacheHitCount: 0, - dismissedRepeatDropCount: 0, - suggestionShownCount: 0, - suggestionAppliedCount: 0, - suggestionDismissedCount: 0, - promptTokens: 0, - completionTokens: 0, -}; - -export class AISuggestionsControllerImpl implements AISuggestionsController { - private readonly editor: Editor; - private readonly config: AISuggestionsExtensionConfig; - private readonly listeners = new Set<() => void>(); - private readonly scheduler: AISuggestionScheduler; - private readonly analysisCache = new Map(); - private readonly dismissedFingerprints = new Map(); - private abortController: AbortController | null = null; - private state: AISuggestionsState; - - constructor(editor: Editor, config: AISuggestionsExtensionConfig = {}) { - this.editor = editor; - this.config = config; - this.state = { - enabled: config.enabled ?? true, - status: "idle", - activeRequestId: null, - activeSuggestionId: null, - activeSuggestionGroupId: null, - suggestions: [], - groups: [], - metrics: { ...INITIAL_METRICS }, - }; - this.scheduler = new AISuggestionScheduler(editor, config, { - onScheduledChange: (scheduled) => { - if (!this.state.enabled) { - return; - } - this.setState({ - status: scheduled ? "scheduled" : this.isRequesting() ? "requesting" : "idle", - }); - }, - }); - } - - getState(): AISuggestionsState { - return this.state; - } - - getSuggestionGroups(): readonly AISuggestionGroup[] { - return this.state.groups; - } - - getRuntimeSettings(): AISuggestionsExtensionConfig { - return { ...this.config }; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - updateRuntimeSettings( - patch: Partial< - Omit - >, - ): AISuggestionsExtensionConfig { - Object.assign(this.config, patch); - this.emit(false); - return this.getRuntimeSettings(); - } - - setEnabled(enabled: boolean): void { - if (this.state.enabled === enabled) { - return; - } - - if (!enabled) { - const wasRequesting = this.isRequesting(); - this.abortController?.abort(); - this.abortController = null; - this.scheduler.reset(); - this.replaceAllSuggestions([], { - enabled: false, - status: "idle", - activeRequestId: null, - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: wasRequesting - ? { - cancelCount: this.state.metrics.cancelCount + 1, - } - : undefined, - }); - return; - } - - this.setState({ - enabled: true, - status: this.scheduler.hasDirtyBlocks() - ? "scheduled" - : this.isRequesting() - ? "requesting" - : "idle", - }); - } - - setActiveSuggestion(id: string | null): void { - if (this.state.activeSuggestionId === id) { - return; - } - - const activeGroupId = - id == null - ? null - : this.state.groups.find((group) => group.suggestionIds.includes(id))?.id ?? - null; - - this.setState({ - activeSuggestionId: id, - activeSuggestionGroupId: activeGroupId, - }); - } - - setActiveSuggestionGroup(id: string | null): void { - if (this.state.activeSuggestionGroupId === id) { - return; - } - - const firstSuggestionId = - id == null - ? null - : this.state.groups.find((group) => group.id === id)?.suggestionIds[0] ?? null; - - this.setState({ - activeSuggestionGroupId: id, - activeSuggestionId: firstSuggestionId, - }); - } - - request(options?: { force?: boolean; blockId?: string | null }): boolean { - if ( - !this.state.enabled || - (!options?.force && !this.isEditorReadyForSuggestions()) - ) { - return false; - } - - const ready = options?.force - ? this.resolveForcedDirtyBlock(options.blockId) - : this.scheduler.consumeNextReadyBlock(); - if (!ready) { - if (!this.isRequesting()) { - this.setState({ - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - }); - } - return false; - } - - const builtScope = buildSuggestionScope(this.editor, ready.state, this.config); - if (!builtScope) { - this.setState({ - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - }); - return false; - } - - this.pruneMemory(); - const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; - const cached = this.analysisCache.get(builtScope.scope.hash); - if (cached && isCacheEntryFresh(cached, cacheTtlMs)) { - const cachedCandidates = this.filterCandidatesForDisplay( - builtScope.scope.hash, - cached.candidates, - ); - const suggestions = materializeSuggestionsFromCandidates({ - blockId: builtScope.scope.blockId, - scopeId: builtScope.scope.id, - scopeHash: builtScope.scope.hash, - scopeText: builtScope.scope.text, - scopeFrom: builtScope.scope.from, - candidates: cachedCandidates, - }); - - this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - activeSuggestionId: suggestions[0]?.id ?? null, - metrics: { - cacheHitCount: this.state.metrics.cacheHitCount + 1, - suggestionShownCount: - this.state.metrics.suggestionShownCount + suggestions.length, - }, - }); - return true; - } - - this.abortController?.abort(); - this.abortController = new AbortController(); - const requestId = crypto.randomUUID(); - - this.setState({ - status: "requesting", - activeRequestId: requestId, - metrics: { - ...this.state.metrics, - requestCount: this.state.metrics.requestCount + 1, - }, - }); - - void this.runAnalysis(requestId, builtScope, this.abortController.signal); - return true; - } - - applySuggestion(id: string): boolean { - const suggestion = this.state.suggestions.find( - (item) => item.id === id && !item.invalidated, - ); - if (!suggestion) { - return false; - } - - const ops = buildApplySuggestionOps(this.editor, suggestion); - if (ops.length === 0) { - return false; - } - - this.editor.apply(ops, { - origin: "ai", - undoGroup: true, - }); - - const nextSuggestions = this.state.suggestions.filter( - (item) => - item.blockId !== suggestion.blockId || - !rangesOverlap(item.from, item.to, suggestion.from, suggestion.to), - ); - - this.replaceAllSuggestions(nextSuggestions, { - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: { - suggestionAppliedCount: - this.state.metrics.suggestionAppliedCount + 1, - }, - }); - return true; - } - - applySuggestionGroup(id: string): number { - const group = this.state.groups.find((item) => item.id === id); - if (!group) { - return 0; - } - - const suggestions = group.suggestionIds - .map((suggestionId) => - this.state.suggestions.find((item) => item.id === suggestionId) ?? null, - ) - .filter(Boolean) - .sort((left, right) => (right?.from ?? 0) - (left?.from ?? 0)); - - let appliedCount = 0; - for (const suggestion of suggestions) { - if (suggestion && this.applySuggestion(suggestion.id)) { - appliedCount += 1; - } - } - return appliedCount; - } - - dismissSuggestion(id: string): boolean { - const suggestion = this.state.suggestions.find((item) => item.id === id); - if (!suggestion) { - return false; - } - - this.dismissedFingerprints.set( - buildSuggestionFingerprint(suggestion.scopeHash, { - kind: suggestion.kind, - originalText: suggestion.originalText, - replacementText: suggestion.replacementText, - }), - Date.now(), - ); - - this.replaceAllSuggestions( - this.state.suggestions.filter((item) => item.id !== id), - { - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: { - suggestionDismissedCount: - this.state.metrics.suggestionDismissedCount + 1, - }, - }, - ); - return true; - } - - dismissSuggestionGroup(id: string): number { - const group = this.state.groups.find((item) => item.id === id); - if (!group) { - return 0; - } - - let dismissedCount = 0; - for (const suggestionId of group.suggestionIds) { - if (this.dismissSuggestion(suggestionId)) { - dismissedCount += 1; - } - } - return dismissedCount; - } - - dismissAllInBlock(blockId: string): number { - const removedCount = this.state.suggestions.filter( - (suggestion) => suggestion.blockId === blockId, - ).length; - if (removedCount === 0) { - return 0; - } - - this.replaceAllSuggestions( - this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), - { - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: { - suggestionDismissedCount: - this.state.metrics.suggestionDismissedCount + removedCount, - }, - }, - ); - return removedCount; - } - - clearInvalidSuggestions(): void { - const nextSuggestions = this.state.suggestions.filter( - (suggestion) => !suggestion.invalidated, - ); - if (nextSuggestions.length === this.state.suggestions.length) { - return; - } - - this.replaceAllSuggestions(nextSuggestions, { - activeSuggestionId: nextSuggestions.some( - (suggestion) => suggestion.id === this.state.activeSuggestionId, - ) - ? this.state.activeSuggestionId - : null, - activeSuggestionGroupId: nextSuggestions.some((suggestion) => - this.state.groups - .find((group) => group.id === this.state.activeSuggestionGroupId) - ?.suggestionIds.includes(suggestion.id), - ) - ? this.state.activeSuggestionGroupId - : null, - }); - } - - handleDocumentCommit(event: DocumentCommitEvent): void { - if (event.origin !== "user" && event.origin !== "input-rule") { - return; - } - - const affectedBlockIds = new Set(event.affectedBlocks); - let changed = false; - const nextSuggestions = this.state.suggestions.map((suggestion) => { - if (!affectedBlockIds.has(suggestion.blockId) || suggestion.invalidated) { - return suggestion; - } - changed = true; - return { - ...suggestion, - invalidated: true, - }; - }); - - if (changed) { - this.replaceAllSuggestions(nextSuggestions); - } - - this.scheduler.markDirty(event, () => { - void Promise.resolve().then(() => { - this.request(); - }); - }); - } - - destroy(): void { - this.abortController?.abort(); - this.abortController = null; - this.scheduler.destroy(); - this.analysisCache.clear(); - this.dismissedFingerprints.clear(); - this.listeners.clear(); - } - - private async runAnalysis( - requestId: string, - builtScope: import("./scopeBuilder").BuiltSuggestionScope, - signal: AbortSignal, - ): Promise { - try { - const result = await analyzeSuggestionScope({ - editor: this.editor, - scope: builtScope, - config: this.config, - signal, - }); - - if (signal.aborted || this.state.activeRequestId !== requestId) { - if (!signal.aborted) { - this.setState({ - metrics: { - ...this.state.metrics, - cancelCount: this.state.metrics.cancelCount + 1, - }, - }); - } - return; - } - - this.analysisCache.set(builtScope.scope.hash, { - scopeHash: builtScope.scope.hash, - candidates: result.candidates, - createdAt: Date.now(), - }); - - const filteredCandidates = this.filterCandidatesForDisplay( - builtScope.scope.hash, - result.candidates, - ); - const suggestions = materializeSuggestionsFromCandidates({ - blockId: builtScope.scope.blockId, - scopeId: builtScope.scope.id, - scopeHash: builtScope.scope.hash, - scopeText: builtScope.scope.text, - scopeFrom: builtScope.scope.from, - candidates: filteredCandidates, - }); - - this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - activeRequestId: null, - activeSuggestionId: suggestions[0]?.id ?? null, - activeSuggestionGroupId: null, - metrics: { - successCount: this.state.metrics.successCount + 1, - suggestionShownCount: - this.state.metrics.suggestionShownCount + suggestions.length, - promptTokens: - this.state.metrics.promptTokens + result.usage.promptTokens, - completionTokens: - this.state.metrics.completionTokens + result.usage.completionTokens, - }, - }); - } catch (error) { - if (!signal.aborted) { - this.setState({ - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - activeRequestId: null, - metrics: { - ...this.state.metrics, - errorCount: this.state.metrics.errorCount + 1, - }, - }); - } - } - } - - private filterCandidatesForDisplay( - scopeHash: string, - candidates: readonly AISuggestionCandidate[], - ): readonly AISuggestionCandidate[] { - const minConfidence = this.config.minConfidence ?? DEFAULT_MIN_CONFIDENCE; - const dismissMemoryMs = - this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; - - const nextCandidates: AISuggestionCandidate[] = []; - let dismissedRepeatDropCount = 0; - - for (const candidate of candidates) { - if ( - typeof candidate.confidence === "number" && - candidate.confidence < minConfidence - ) { - continue; - } - - const fingerprint = buildSuggestionFingerprint(scopeHash, candidate); - const dismissedAt = this.dismissedFingerprints.get(fingerprint); - if ( - typeof dismissedAt === "number" && - isDismissFingerprintActive(dismissedAt, dismissMemoryMs) - ) { - dismissedRepeatDropCount += 1; - continue; - } - - nextCandidates.push(candidate); - } - - nextCandidates.sort(compareCandidatesForDisplay); - - const maxSuggestionsPerScope = - this.config.maxSuggestionsPerScope ?? DEFAULT_MAX_SUGGESTIONS_PER_SCOPE; - const limitedCandidates = nextCandidates.slice(0, maxSuggestionsPerScope); - - if (dismissedRepeatDropCount > 0) { - this.setState({ - metrics: { - ...this.state.metrics, - dismissedRepeatDropCount: - this.state.metrics.dismissedRepeatDropCount + dismissedRepeatDropCount, - }, - }); - } - - return limitedCandidates; - } - - private replaceSuggestionsForBlock( - blockId: string, - nextSuggestions: readonly AISuggestion[], - patch?: StatePatch, - ): void { - this.replaceAllSuggestions( - [ - ...this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), - ...nextSuggestions, - ], - patch, - ); - } - - private replaceAllSuggestions( - nextSuggestions: readonly AISuggestion[], - patch?: StatePatch, - ): void { - const groups = buildSuggestionGroups(nextSuggestions, this.config); - const hasActiveSuggestionPatch = - patch != null && - Object.prototype.hasOwnProperty.call(patch, "activeSuggestionId"); - const hasActiveGroupPatch = - patch != null && - Object.prototype.hasOwnProperty.call(patch, "activeSuggestionGroupId"); - const nextActiveSuggestionId = hasActiveSuggestionPatch - ? (patch?.activeSuggestionId ?? null) - : this.state.activeSuggestionId; - const activeGroupId = - hasActiveGroupPatch - ? (patch?.activeSuggestionGroupId ?? null) - : groups.find((group) => - nextActiveSuggestionId != null - ? group.suggestionIds.includes(nextActiveSuggestionId) - : false, - )?.id ?? null; - - this.setState({ - ...patch, - suggestions: nextSuggestions, - groups, - activeSuggestionId: nextActiveSuggestionId, - activeSuggestionGroupId: activeGroupId, - }); - } - - private setState( - patch: StatePatch, - ): void { - const previousState = this.state; - const nextState = { - ...this.state, - ...patch, - metrics: patch.metrics - ? { - ...this.state.metrics, - ...patch.metrics, - } - : this.state.metrics, - }; - this.state = nextState; - this.emit( - previousState.suggestions !== nextState.suggestions || - previousState.groups !== nextState.groups || - previousState.activeSuggestionId !== nextState.activeSuggestionId, - ); - } - - private emit(shouldRefreshDecorations = true): void { - if (shouldRefreshDecorations) { - this.editor.requestDecorationUpdate(); - } - for (const listener of this.listeners) { - listener(); - } - } - - private pruneMemory(): void { - const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; - const dismissMemoryMs = - this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; - const now = Date.now(); - - for (const [scopeHash, entry] of this.analysisCache) { - if (!isCacheEntryFresh(entry, cacheTtlMs, now)) { - this.analysisCache.delete(scopeHash); - } - } - - for (const [fingerprint, dismissedAt] of this.dismissedFingerprints) { - if (!isDismissFingerprintActive(dismissedAt, dismissMemoryMs, now)) { - this.dismissedFingerprints.delete(fingerprint); - } - } - } - - private isEditorReadyForSuggestions(): boolean { - const fieldEditor = - this.editor.internals.getSlot(FIELD_EDITOR_SLOT_KEY) ?? null; - if (!fieldEditor) { - return true; - } - return fieldEditor.isFocused && fieldEditor.isEditing && !fieldEditor.isComposing; - } - - private isRequesting(): boolean { - return this.state.activeRequestId != null && this.state.status === "requesting"; - } - - private resolveForcedDirtyBlock( - blockId: string | null | undefined, - ): { blockId: string; state: import("./scheduler").DirtyBlockState } | null { - const targetBlockId = - blockId ?? resolveSelectedBlockId(this.editor) ?? this.editor.firstBlock()?.id ?? null; - if (!targetBlockId) { - return null; - } - - const block = this.editor.getBlock(targetBlockId); - if (!block) { - return null; - } - - const text = block.textContent({ resolved: true }); - return { - blockId: targetBlockId, - state: { - blockId: targetBlockId, - firstChangedAt: Date.now(), - lastChangedAt: Date.now(), - changeCount: 1, - changedCharsEstimate: Math.max(1, text.trim().length), - lastRevision: this.editor.getBlockRevision(targetBlockId), - lastChangedOffset: resolvePreferredOffset(this.editor, targetBlockId, text.length), - }, - }; - } -} - -function resolveSelectedBlockId(editor: Editor): string | null { - const selection = editor.selection; - if (!selection) { - return null; - } - if (selection.type === "text") { - return selection.focus.blockId; - } - if (selection.type === "cell") { - return selection.blockId; - } - if (selection.type === "block") { - return selection.blockIds[0] ?? null; - } - return null; -} - -function resolvePreferredOffset( - editor: Editor, - blockId: string, - textLength: number, -): number { - const selection = editor.selection; - if (selection?.type === "text" && selection.focus.blockId === blockId) { - return selection.focus.offset; - } - return textLength; -} - -function compareCandidatesForDisplay( - left: AISuggestionCandidate, - right: AISuggestionCandidate, -): number { - const leftConfidence = left.confidence ?? 0; - const rightConfidence = right.confidence ?? 0; - if (leftConfidence !== rightConfidence) { - return rightConfidence - leftConfidence; - } - - const leftPriority = resolveKindPriority(left.kind); - const rightPriority = resolveKindPriority(right.kind); - if (leftPriority !== rightPriority) { - return leftPriority - rightPriority; - } - - return left.originalText.length - right.originalText.length; -} - -function resolveKindPriority(kind: AISuggestionCandidate["kind"]): number { - switch (kind) { - case "spelling": - return 1; - case "grammar": - return 2; - case "clarity": - return 3; - case "rephrase": - return 4; - } -} - -function rangesOverlap( - leftFrom: number, - leftTo: number, - rightFrom: number, - rightTo: number, -): boolean { - return leftFrom < rightTo && rightFrom < leftTo; -} +export { AISuggestionsControllerImpl } from "./controllerCore"; diff --git a/packages/extensions/ai-suggestions/src/controllerCore.ts b/packages/extensions/ai-suggestions/src/controllerCore.ts new file mode 100644 index 0000000..0229216 --- /dev/null +++ b/packages/extensions/ai-suggestions/src/controllerCore.ts @@ -0,0 +1,472 @@ +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import type { DocumentCommitEvent, Editor, FieldEditor } from "@pen/types"; +import { buildApplySuggestionOps } from "./apply"; +import { + buildSuggestionFingerprint, + type CachedAnalysisResult, + isCacheEntryFresh, + isDismissFingerprintActive, +} from "./cache"; +import { + DEFAULT_CACHE_TTL_MS, + DEFAULT_DISMISS_MEMORY_MS, + DEFAULT_MAX_SUGGESTIONS_PER_SCOPE, + DEFAULT_MIN_CONFIDENCE, +} from "./constants"; +import { buildSuggestionGroups } from "./grouping"; +import { materializeSuggestionsFromCandidates } from "./matcher"; +import { analyzeSuggestionScope } from "./analyzer"; +import { AISuggestionScheduler } from "./scheduler"; +import { buildSuggestionScope } from "./scopeBuilder"; +import { rangesOverlap } from "./controllerUtils"; +import type { + AISuggestion, + AISuggestionCandidate, + AISuggestionGroup, + AISuggestionsController, + AISuggestionsExtensionConfig, + AISuggestionsMetrics, + AISuggestionsState, +} from "./types"; + +type StatePatch = Omit, "metrics"> & { + metrics?: Partial; +}; + +const INITIAL_METRICS: AISuggestionsMetrics = { + requestCount: 0, + successCount: 0, + errorCount: 0, + cancelCount: 0, + cacheHitCount: 0, + dismissedRepeatDropCount: 0, + suggestionShownCount: 0, + suggestionAppliedCount: 0, + suggestionDismissedCount: 0, + promptTokens: 0, + completionTokens: 0, +}; + +export interface AISuggestionsControllerImpl { + runAnalysis( + requestId: string, + builtScope: import("./scopeBuilder").BuiltSuggestionScope, + signal: AbortSignal, + ): Promise; + filterCandidatesForDisplay( + scopeHash: string, + candidates: readonly AISuggestionCandidate[], +): readonly AISuggestionCandidate[]; + replaceSuggestionsForBlock( + blockId: string, + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void; + replaceAllSuggestions( + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void; + setState( + patch: StatePatch, + ): void; + emit(shouldRefreshDecorations?: boolean): void; + pruneMemory(): void; + isEditorReadyForSuggestions(): boolean; + isRequesting(): boolean; + resolveForcedDirtyBlock( + blockId: string | null | undefined, + ): ReturnType; +} + +export class AISuggestionsControllerImpl implements AISuggestionsController { + private readonly editor: Editor; + private readonly config: AISuggestionsExtensionConfig; + private readonly listeners = new Set<() => void>(); + private readonly scheduler: AISuggestionScheduler; + private readonly analysisCache = new Map(); + private readonly dismissedFingerprints = new Map(); + private abortController: AbortController | null = null; + private state: AISuggestionsState; + + constructor(editor: Editor, config: AISuggestionsExtensionConfig = {}) { + this.editor = editor; + this.config = config; + this.state = { + enabled: config.enabled ?? true, + status: "idle", + activeRequestId: null, + activeSuggestionId: null, + activeSuggestionGroupId: null, + suggestions: [], + groups: [], + metrics: { ...INITIAL_METRICS }, + }; + this.scheduler = new AISuggestionScheduler(editor, config, { + onScheduledChange: (scheduled) => { + if (!this.state.enabled) { + return; + } + this.setState({ + status: scheduled ? "scheduled" : this.isRequesting() ? "requesting" : "idle", + }); + }, + }); + } + + getState(): AISuggestionsState { + return this.state; + } + + getSuggestionGroups(): readonly AISuggestionGroup[] { + return this.state.groups; + } + + getRuntimeSettings(): AISuggestionsExtensionConfig { + return { ...this.config }; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + updateRuntimeSettings( + patch: Partial< + Omit + >, + ): AISuggestionsExtensionConfig { + Object.assign(this.config, patch); + this.emit(false); + return this.getRuntimeSettings(); + } + + setEnabled(enabled: boolean): void { + if (this.state.enabled === enabled) { + return; + } + + if (!enabled) { + const wasRequesting = this.isRequesting(); + this.abortController?.abort(); + this.abortController = null; + this.scheduler.reset(); + this.replaceAllSuggestions([], { + enabled: false, + status: "idle", + activeRequestId: null, + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: wasRequesting + ? { + cancelCount: this.state.metrics.cancelCount + 1, + } + : undefined, + }); + return; + } + + this.setState({ + enabled: true, + status: this.scheduler.hasDirtyBlocks() + ? "scheduled" + : this.isRequesting() + ? "requesting" + : "idle", + }); + } + + setActiveSuggestion(id: string | null): void { + if (this.state.activeSuggestionId === id) { + return; + } + + const activeGroupId = + id == null + ? null + : this.state.groups.find((group) => group.suggestionIds.includes(id))?.id ?? + null; + + this.setState({ + activeSuggestionId: id, + activeSuggestionGroupId: activeGroupId, + }); + } + + setActiveSuggestionGroup(id: string | null): void { + if (this.state.activeSuggestionGroupId === id) { + return; + } + + const firstSuggestionId = + id == null + ? null + : this.state.groups.find((group) => group.id === id)?.suggestionIds[0] ?? null; + + this.setState({ + activeSuggestionGroupId: id, + activeSuggestionId: firstSuggestionId, + }); + } + + request(options?: { force?: boolean; blockId?: string | null }): boolean { + if ( + !this.state.enabled || + (!options?.force && !this.isEditorReadyForSuggestions()) + ) { + return false; + } + + const ready = options?.force + ? this.resolveForcedDirtyBlock(options.blockId) + : this.scheduler.consumeNextReadyBlock(); + if (!ready) { + if (!this.isRequesting()) { + this.setState({ + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + }); + } + return false; + } + + const builtScope = buildSuggestionScope(this.editor, ready.state, this.config); + if (!builtScope) { + this.setState({ + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + }); + return false; + } + + this.pruneMemory(); + const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + const cached = this.analysisCache.get(builtScope.scope.hash); + if (cached && isCacheEntryFresh(cached, cacheTtlMs)) { + const cachedCandidates = this.filterCandidatesForDisplay( + builtScope.scope.hash, + cached.candidates, + ); + const suggestions = materializeSuggestionsFromCandidates({ + blockId: builtScope.scope.blockId, + scopeId: builtScope.scope.id, + scopeHash: builtScope.scope.hash, + scopeText: builtScope.scope.text, + scopeFrom: builtScope.scope.from, + candidates: cachedCandidates, + }); + + this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + activeSuggestionId: suggestions[0]?.id ?? null, + metrics: { + cacheHitCount: this.state.metrics.cacheHitCount + 1, + suggestionShownCount: + this.state.metrics.suggestionShownCount + suggestions.length, + }, + }); + return true; + } + + this.abortController?.abort(); + this.abortController = new AbortController(); + const requestId = crypto.randomUUID(); + + this.setState({ + status: "requesting", + activeRequestId: requestId, + metrics: { + ...this.state.metrics, + requestCount: this.state.metrics.requestCount + 1, + }, + }); + + void this.runAnalysis(requestId, builtScope, this.abortController.signal); + return true; + } + + applySuggestion(id: string): boolean { + const suggestion = this.state.suggestions.find( + (item) => item.id === id && !item.invalidated, + ); + if (!suggestion) { + return false; + } + + const ops = buildApplySuggestionOps(this.editor, suggestion); + if (ops.length === 0) { + return false; + } + + this.editor.apply(ops, { + origin: "ai", + undoGroup: true, + }); + + const nextSuggestions = this.state.suggestions.filter( + (item) => + item.blockId !== suggestion.blockId || + !rangesOverlap(item.from, item.to, suggestion.from, suggestion.to), + ); + + this.replaceAllSuggestions(nextSuggestions, { + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: { + suggestionAppliedCount: + this.state.metrics.suggestionAppliedCount + 1, + }, + }); + return true; + } + + applySuggestionGroup(id: string): number { + const group = this.state.groups.find((item) => item.id === id); + if (!group) { + return 0; + } + + const suggestions = group.suggestionIds + .map((suggestionId) => + this.state.suggestions.find((item) => item.id === suggestionId) ?? null, + ) + .filter(Boolean) + .sort((left, right) => (right?.from ?? 0) - (left?.from ?? 0)); + + let appliedCount = 0; + for (const suggestion of suggestions) { + if (suggestion && this.applySuggestion(suggestion.id)) { + appliedCount += 1; + } + } + return appliedCount; + } + + dismissSuggestion(id: string): boolean { + const suggestion = this.state.suggestions.find((item) => item.id === id); + if (!suggestion) { + return false; + } + + this.dismissedFingerprints.set( + buildSuggestionFingerprint(suggestion.scopeHash, { + kind: suggestion.kind, + originalText: suggestion.originalText, + replacementText: suggestion.replacementText, + }), + Date.now(), + ); + + this.replaceAllSuggestions( + this.state.suggestions.filter((item) => item.id !== id), + { + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: { + suggestionDismissedCount: + this.state.metrics.suggestionDismissedCount + 1, + }, + }, + ); + return true; + } + + dismissSuggestionGroup(id: string): number { + const group = this.state.groups.find((item) => item.id === id); + if (!group) { + return 0; + } + + let dismissedCount = 0; + for (const suggestionId of group.suggestionIds) { + if (this.dismissSuggestion(suggestionId)) { + dismissedCount += 1; + } + } + return dismissedCount; + } + + dismissAllInBlock(blockId: string): number { + const removedCount = this.state.suggestions.filter( + (suggestion) => suggestion.blockId === blockId, + ).length; + if (removedCount === 0) { + return 0; + } + + this.replaceAllSuggestions( + this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), + { + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: { + suggestionDismissedCount: + this.state.metrics.suggestionDismissedCount + removedCount, + }, + }, + ); + return removedCount; + } + + clearInvalidSuggestions(): void { + const nextSuggestions = this.state.suggestions.filter( + (suggestion) => !suggestion.invalidated, + ); + if (nextSuggestions.length === this.state.suggestions.length) { + return; + } + + this.replaceAllSuggestions(nextSuggestions, { + activeSuggestionId: nextSuggestions.some( + (suggestion) => suggestion.id === this.state.activeSuggestionId, + ) + ? this.state.activeSuggestionId + : null, + activeSuggestionGroupId: nextSuggestions.some((suggestion) => + this.state.groups + .find((group) => group.id === this.state.activeSuggestionGroupId) + ?.suggestionIds.includes(suggestion.id), + ) + ? this.state.activeSuggestionGroupId + : null, + }); + } + + handleDocumentCommit(event: DocumentCommitEvent): void { + if (event.origin !== "user" && event.origin !== "input-rule") { + return; + } + + const affectedBlockIds = new Set(event.affectedBlocks); + let changed = false; + const nextSuggestions = this.state.suggestions.map((suggestion) => { + if (!affectedBlockIds.has(suggestion.blockId) || suggestion.invalidated) { + return suggestion; + } + changed = true; + return { + ...suggestion, + invalidated: true, + }; + }); + + if (changed) { + this.replaceAllSuggestions(nextSuggestions); + } + + this.scheduler.markDirty(event, () => { + void Promise.resolve().then(() => { + this.request(); + }); + }); + } + + destroy(): void { + this.abortController?.abort(); + this.abortController = null; + this.scheduler.destroy(); + this.analysisCache.clear(); + this.dismissedFingerprints.clear(); + this.listeners.clear(); + } + +} diff --git a/packages/extensions/ai-suggestions/src/controllerRuntime.ts b/packages/extensions/ai-suggestions/src/controllerRuntime.ts new file mode 100644 index 0000000..60534a3 --- /dev/null +++ b/packages/extensions/ai-suggestions/src/controllerRuntime.ts @@ -0,0 +1,315 @@ +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import type { DocumentCommitEvent, Editor, FieldEditor } from "@pen/types"; +import type { CachedAnalysisResult } from "./cache"; +import { + buildSuggestionFingerprint, + isCacheEntryFresh, + isDismissFingerprintActive, +} from "./cache"; +import { + DEFAULT_CACHE_TTL_MS, + DEFAULT_DISMISS_MEMORY_MS, + DEFAULT_MAX_SUGGESTIONS_PER_SCOPE, + DEFAULT_MIN_CONFIDENCE, +} from "./constants"; +import { buildSuggestionGroups } from "./grouping"; +import { materializeSuggestionsFromCandidates } from "./matcher"; +import { analyzeSuggestionScope } from "./analyzer"; +import type { AISuggestionScheduler } from "./scheduler"; +import type { + AISuggestion, + AISuggestionCandidate, + AISuggestionsExtensionConfig, + AISuggestionsMetrics, + AISuggestionsState, +} from "./types"; +import { AISuggestionsControllerImpl } from "./controllerCore"; +import { + compareCandidatesForDisplay, + rangesOverlap, + resolvePreferredOffset, + resolveSelectedBlockId, +} from "./controllerUtils"; + +type StatePatch = Omit, "metrics"> & { + metrics?: Partial; +}; + +type AISuggestionsControllerRuntime = { + [key: string]: any; + editor: Editor; + config: AISuggestionsExtensionConfig; + listeners: Set<() => void>; + scheduler: AISuggestionScheduler; + analysisCache: Map; + dismissedFingerprints: Map; + abortController: AbortController | null; + state: AISuggestionsState; +}; + +type AISuggestionsControllerRuntimePrototype = Record; + +const ControllerPrototype = AISuggestionsControllerImpl.prototype as unknown as AISuggestionsControllerRuntimePrototype; + +ControllerPrototype.runAnalysis = async function runAnalysis(this: AISuggestionsControllerRuntime, + requestId: string, + builtScope: import("./scopeBuilder").BuiltSuggestionScope, + signal: AbortSignal, +): Promise { + try { + const result = await analyzeSuggestionScope({ + editor: this.editor, + scope: builtScope, + config: this.config, + signal, + }); + + if (signal.aborted || this.state.activeRequestId !== requestId) { + if (!signal.aborted) { + this.setState({ + metrics: { + ...this.state.metrics, + cancelCount: this.state.metrics.cancelCount + 1, + }, + }); + } + return; + } + + this.analysisCache.set(builtScope.scope.hash, { + scopeHash: builtScope.scope.hash, + candidates: result.candidates, + createdAt: Date.now(), + }); + + const filteredCandidates = this.filterCandidatesForDisplay( + builtScope.scope.hash, + result.candidates, + ); + const suggestions = materializeSuggestionsFromCandidates({ + blockId: builtScope.scope.blockId, + scopeId: builtScope.scope.id, + scopeHash: builtScope.scope.hash, + scopeText: builtScope.scope.text, + scopeFrom: builtScope.scope.from, + candidates: filteredCandidates, + }); + + this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + activeRequestId: null, + activeSuggestionId: suggestions[0]?.id ?? null, + activeSuggestionGroupId: null, + metrics: { + successCount: this.state.metrics.successCount + 1, + suggestionShownCount: + this.state.metrics.suggestionShownCount + suggestions.length, + promptTokens: + this.state.metrics.promptTokens + result.usage.promptTokens, + completionTokens: + this.state.metrics.completionTokens + result.usage.completionTokens, + }, + }); + } catch (error) { + if (!signal.aborted) { + this.setState({ + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + activeRequestId: null, + metrics: { + ...this.state.metrics, + errorCount: this.state.metrics.errorCount + 1, + }, + }); + } + } +} +; +ControllerPrototype.filterCandidatesForDisplay = function filterCandidatesForDisplay(this: AISuggestionsControllerRuntime, + scopeHash: string, + candidates: readonly AISuggestionCandidate[], +): readonly AISuggestionCandidate[] { + const minConfidence = this.config.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const dismissMemoryMs = + this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; + + const nextCandidates: AISuggestionCandidate[] = []; + let dismissedRepeatDropCount = 0; + + for (const candidate of candidates) { + if ( + typeof candidate.confidence === "number" && + candidate.confidence < minConfidence + ) { + continue; + } + + const fingerprint = buildSuggestionFingerprint(scopeHash, candidate); + const dismissedAt = this.dismissedFingerprints.get(fingerprint); + if ( + typeof dismissedAt === "number" && + isDismissFingerprintActive(dismissedAt, dismissMemoryMs) + ) { + dismissedRepeatDropCount += 1; + continue; + } + + nextCandidates.push(candidate); + } + + nextCandidates.sort(compareCandidatesForDisplay); + + const maxSuggestionsPerScope = + this.config.maxSuggestionsPerScope ?? DEFAULT_MAX_SUGGESTIONS_PER_SCOPE; + const limitedCandidates = nextCandidates.slice(0, maxSuggestionsPerScope); + + if (dismissedRepeatDropCount > 0) { + this.setState({ + metrics: { + ...this.state.metrics, + dismissedRepeatDropCount: + this.state.metrics.dismissedRepeatDropCount + dismissedRepeatDropCount, + }, + }); + } + + return limitedCandidates; +} +; +ControllerPrototype.replaceSuggestionsForBlock = function replaceSuggestionsForBlock(this: AISuggestionsControllerRuntime, + blockId: string, + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void { + this.replaceAllSuggestions( + [ + ...this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), + ...nextSuggestions, + ], + patch, + ); +} +; +ControllerPrototype.replaceAllSuggestions = function replaceAllSuggestions(this: AISuggestionsControllerRuntime, + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void { + const groups = buildSuggestionGroups(nextSuggestions, this.config); + const hasActiveSuggestionPatch = + patch != null && + Object.prototype.hasOwnProperty.call(patch, "activeSuggestionId"); + const hasActiveGroupPatch = + patch != null && + Object.prototype.hasOwnProperty.call(patch, "activeSuggestionGroupId"); + const nextActiveSuggestionId = hasActiveSuggestionPatch + ? (patch?.activeSuggestionId ?? null) + : this.state.activeSuggestionId; + const activeGroupId = + hasActiveGroupPatch + ? (patch?.activeSuggestionGroupId ?? null) + : groups.find((group) => + nextActiveSuggestionId != null + ? group.suggestionIds.includes(nextActiveSuggestionId) + : false, + )?.id ?? null; + + this.setState({ + ...patch, + suggestions: nextSuggestions, + groups, + activeSuggestionId: nextActiveSuggestionId, + activeSuggestionGroupId: activeGroupId, + }); +} +; +ControllerPrototype.setState = function setState(this: AISuggestionsControllerRuntime, + patch: StatePatch, +): void { + const previousState = this.state; + const nextState = { + ...this.state, + ...patch, + metrics: patch.metrics + ? { + ...this.state.metrics, + ...patch.metrics, + } + : this.state.metrics, + }; + this.state = nextState; + this.emit( + previousState.suggestions !== nextState.suggestions || + previousState.groups !== nextState.groups || + previousState.activeSuggestionId !== nextState.activeSuggestionId, + ); +} +; +ControllerPrototype.emit = function emit(this: AISuggestionsControllerRuntime, shouldRefreshDecorations = true): void { + if (shouldRefreshDecorations) { + this.editor.requestDecorationUpdate(); + } + for (const listener of this.listeners) { + listener(); + } +} +; +ControllerPrototype.pruneMemory = function pruneMemory(this: AISuggestionsControllerRuntime): void { + const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + const dismissMemoryMs = + this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; + const now = Date.now(); + + for (const [scopeHash, entry] of this.analysisCache) { + if (!isCacheEntryFresh(entry, cacheTtlMs, now)) { + this.analysisCache.delete(scopeHash); + } + } + + for (const [fingerprint, dismissedAt] of this.dismissedFingerprints) { + if (!isDismissFingerprintActive(dismissedAt, dismissMemoryMs, now)) { + this.dismissedFingerprints.delete(fingerprint); + } + } +} +; +ControllerPrototype.isEditorReadyForSuggestions = function isEditorReadyForSuggestions(this: AISuggestionsControllerRuntime): boolean { + const fieldEditor = + this.editor.internals.getSlot(FIELD_EDITOR_SLOT_KEY) ?? null; + if (!fieldEditor) { + return true; + } + return fieldEditor.isFocused && fieldEditor.isEditing && !fieldEditor.isComposing; +} +; +ControllerPrototype.isRequesting = function isRequesting(this: AISuggestionsControllerRuntime): boolean { + return this.state.activeRequestId != null && this.state.status === "requesting"; +} +; +ControllerPrototype.resolveForcedDirtyBlock = function resolveForcedDirtyBlock(this: AISuggestionsControllerRuntime, + blockId: string | null | undefined, +): { blockId: string; state: import("./scheduler").DirtyBlockState } | null { + const targetBlockId = + blockId ?? resolveSelectedBlockId(this.editor) ?? this.editor.firstBlock()?.id ?? null; + if (!targetBlockId) { + return null; + } + + const block = this.editor.getBlock(targetBlockId); + if (!block) { + return null; + } + + const text = block.textContent({ resolved: true }); + return { + blockId: targetBlockId, + state: { + blockId: targetBlockId, + firstChangedAt: Date.now(), + lastChangedAt: Date.now(), + changeCount: 1, + changedCharsEstimate: Math.max(1, text.trim().length), + lastRevision: this.editor.getBlockRevision(targetBlockId), + lastChangedOffset: resolvePreferredOffset(this.editor, targetBlockId, text.length), + }, + }; +} +; diff --git a/packages/extensions/ai-suggestions/src/controllerUtils.ts b/packages/extensions/ai-suggestions/src/controllerUtils.ts new file mode 100644 index 0000000..6fd07be --- /dev/null +++ b/packages/extensions/ai-suggestions/src/controllerUtils.ts @@ -0,0 +1,72 @@ +import type { Editor, TextSelection } from "@pen/types"; +import type { AISuggestionCandidate } from "./types"; + +export function resolveSelectedBlockId(editor: Editor): string | null { + const selection = editor.selection; + if (!selection) { + return null; + } + if (selection.type === "text") { + return selection.focus.blockId; + } + if (selection.type === "cell") { + return selection.blockId; + } + if (selection.type === "block") { + return selection.blockIds[0] ?? null; + } + return null; +} + +export function resolvePreferredOffset( + editor: Editor, + blockId: string, + textLength: number, +): number { + const selection = editor.selection; + if (selection?.type === "text" && selection.focus.blockId === blockId) { + return selection.focus.offset; + } + return textLength; +} + +export function compareCandidatesForDisplay( + left: AISuggestionCandidate, + right: AISuggestionCandidate, +): number { + const leftConfidence = left.confidence ?? 0; + const rightConfidence = right.confidence ?? 0; + if (leftConfidence !== rightConfidence) { + return rightConfidence - leftConfidence; + } + + const leftPriority = resolveKindPriority(left.kind); + const rightPriority = resolveKindPriority(right.kind); + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + + return left.originalText.length - right.originalText.length; +} + +function resolveKindPriority(kind: AISuggestionCandidate["kind"]): number { + switch (kind) { + case "spelling": + return 1; + case "grammar": + return 2; + case "clarity": + return 3; + case "rephrase": + return 4; + } +} + +export function rangesOverlap( + leftFrom: number, + leftTo: number, + rightFrom: number, + rightTo: number, +): boolean { + return leftFrom < rightTo && rightFrom < leftTo; +} diff --git a/packages/extensions/ai/src/__tests__/extension.part1.test.ts b/packages/extensions/ai/src/__tests__/extension.part1.test.ts new file mode 100644 index 0000000..6d20719 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part1.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("marks inserted and deleted text in suggest mode", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "user" }, + ); + editor.apply( + [{ type: "delete-text", blockId, offset: 6, length: 5 }], + { origin: "user" }, + ); + + const block = editor.getBlock(blockId)!; + const deltas = block.textDeltas(); + + expect(deltas[0]?.attributes?.suggestion).toMatchObject({ + action: "insert", + author: "tester", + }); + expect(deltas[1]?.attributes?.suggestion).toMatchObject({ + action: "delete", + author: "tester", + }); + expect(block.textContent()).toBe("Hello world"); + expect(block.textContent({ resolved: true })).toBe("Hello "); + }); + + it("rejects persistent suggestions through the controller", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "user" }, + ); + + const controller = getAIController(editor)!; + const suggestionsSnapshot = controller.getSuggestions(); + const suggestion = suggestionsSnapshot[0]; + expect(suggestion).toBeDefined(); + expect(controller.getSuggestions()).toBe(suggestionsSnapshot); + + expect(rejectSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + }); + + it("accepts persistent suggestions without re-intercepting them", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + editor.apply( + [{ type: "delete-text", blockId, offset: 0, length: 5 }], + { origin: "user" }, + ); + + const [suggestion] = readSuggestionsFromBlock(editor, blockId); + expect(suggestion).toBeDefined(); + + expect(acceptSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + }); + + it("keeps accepted delete suggestions in document undo history", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + editor.apply( + [{ type: "delete-text", blockId, offset: 0, length: 5 }], + { origin: "user" }, + ); + + const [suggestion] = readSuggestionsFromBlock(editor, blockId); + expect(suggestion).toBeDefined(); + expect(acceptSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello"); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.redo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + }); + + it("keeps rejected insert suggestions in document undo history", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "user" }, + ); + + const [suggestion] = readSuggestionsFromBlock(editor, blockId); + expect(suggestion).toBeDefined(); + expect(rejectSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.redo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + }); + + it("accepts multiple suggestions in one undo group", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const firstBlockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], + { origin: "user" }, + ); + editor.apply( + [ + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "user" }, + ); + + expect(readAllSuggestions(editor)).toHaveLength(2); + + acceptAllSuggestions(editor); + expect(readAllSuggestions(editor)).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(readAllSuggestions(editor)).toHaveLength(2); + + expect(editor.undoManager.redo()).toBe(true); + expect(readAllSuggestions(editor)).toEqual([]); + }); + + it("runs a block generation with a model adapter", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Continue", { blockId }); + + expect(generation.status).toBe("complete"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); + expect(controller.getState().activeGeneration?.text).toBe(" world"); + }); + + it("parses markdown block generations into structured blocks", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { blockGeneration: "markdown" }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "# Title\n\n- One", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const targetBlockId = "target-block"; + const trailingBlockId = "trailing-block"; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: targetBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-block", + blockId: trailingBlockId, + blockType: "paragraph", + props: {}, + position: { after: targetBlockId }, + }, + { + type: "insert-text", + blockId: trailingBlockId, + offset: 0, + text: "Outro", + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")?.tableRowCount(); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Continue this paragraph", { + blockId: targetBlockId, + }); + const blockOrder = editor.documentState.blockOrder; + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("markdown"); + expect(blockOrder).toHaveLength(4); + expect(blockOrder).not.toContain(targetBlockId); + expect(editor.getBlock(blockOrder[0])?.textContent()).toBe("Intro"); + expect(editor.getBlock(blockOrder[1])?.type).toBe("heading"); + expect(editor.getBlock(blockOrder[1])?.textContent()).toBe("Title"); + expect(editor.getBlock(blockOrder[2])?.type).toBe("bulletListItem"); + expect(editor.getBlock(blockOrder[2])?.textContent()).toBe("One"); + expect(editor.getBlock(blockOrder[3])?.textContent()).toBe("Outro"); + }); + + it("runs a selection generation when text is selected", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Rewrite the selection"); + + expect(generation.status).toBe("complete"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello planet"); + expect(controller.getState().activeGeneration?.text).toBe("planet"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); + + it("uses selection-fast request mode for bottom-chat selection rewrites", async () => { + let requestMode: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream(options) { + requestMode = options.requestMode; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "selection", + }); + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + + expect(requestMode).toBe("selection-fast"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part10.test.ts b/packages/extensions/ai/src/__tests__/extension.part10.test.ts new file mode 100644 index 0000000..5a22092 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part10.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("treats whole-document rewrite prompts as explicit multi-block replace plans", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Founder's Last Email\n\nA startup story set in Amsterdam.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "The Lighthouse Keeper's Last Letter" }, + { + type: "insert-block", + blockId: "paragraph-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "paragraph-2", + offset: 0, + text: "The storm had been building for three days.", + }, + ], + { origin: "system" }, + ); + const originalBlockIds = [...editor.documentState.blockOrder]; + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the whole story. Make it about a startup from Amsterdam.", + { target: "document" }, + ); + + const activeSession = controller.getSessions().find((item) => item.id === session.id); + expect(activeSession?.operation?.kind).toBe("rewrite-selection"); + expect(activeSession?.operation?.target.kind).toBe("scoped-range"); + const documentTarget = + activeSession?.operation?.target.kind === "scoped-range" + ? activeSession.operation.target + : null; + expect(documentTarget?.blockIds).toEqual(originalBlockIds); + expect(documentTarget?.contentFormat).toBe("markdown"); + expect(documentTarget?.scope).toBe("document"); + expect(generation.status).toBe("complete"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = activeSession?.turns[0]?.id; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + + const finalVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(finalVisibleBlockTexts).toEqual([ + "The Founder's Last Email", + "A startup story set in Amsterdam.", + ]); + }); + + it("treats rewrite-the-story prompts as explicit multi-block replace plans", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Pharaoh's Last Scroll\n\nA cat story set in Egypt.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "The Founder's Last Email", + }, + { + type: "insert-block", + blockId: "paragraph-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "paragraph-2", + offset: 0, + text: "The Slack notification had been pinging for three days.", + }, + ], + { origin: "system" }, + ); + const originalBlockIds = [...editor.documentState.blockOrder]; + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the story. Make it about a cat from Egypt.", + { target: "document" }, + ); + + const activeSession = controller.getSessions().find((item) => item.id === session.id); + expect(activeSession?.operation?.kind).toBe("rewrite-selection"); + expect(activeSession?.operation?.target.kind).toBe("scoped-range"); + const documentTarget = + activeSession?.operation?.target.kind === "scoped-range" + ? activeSession.operation.target + : null; + expect(documentTarget?.blockIds).toEqual(originalBlockIds); + expect(documentTarget?.contentFormat).toBe("markdown"); + expect(documentTarget?.scope).toBe("document"); + expect(generation.status).toBe("complete"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = activeSession?.turns[0]?.id; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + + const finalVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(finalVisibleBlockTexts).toEqual([ + "The Pharaoh's Last Scroll", + "A cat story set in Egypt.", + ]); + }); + + it("carries bottom-chat history into follow-up title edits and replaces prior generated blocks", async () => { + const capturedPrompts: string[] = []; + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + streamCount += 1; + capturedPrompts.push( + options.messages + .map((message) => + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content), + ) + .join("\n\n"), + ); + yield { + type: "replace-final" as const, + operation: options.operation!, + text: + streamCount === 1 + ? "# Salt and Shadow\n\nA lighthouse story." + : "# Amsterdam Sprint\n\nA startup story with a new title.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + await controller.runSessionPrompt(session.id, "Write a story", { + target: "document", + }); + + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt( + session.id, + "Also change the title.", + { target: "document" }, + ); + + expect(capturedPrompts[1]).toContain( + "Earlier user requests in this same session:", + ); + expect(capturedPrompts[1]).toContain("1. Write a story"); + expect(capturedPrompts[1]).toContain( + "Latest request:\nAlso change the title.", + ); + const activeSession = controller.getSessions().find((item) => item.id === session.id); + expect(activeSession?.operation?.kind).toBe("rewrite-selection"); + expect(activeSession?.operation?.target.kind).toBe("scoped-range"); + const documentTarget = + activeSession?.operation?.target.kind === "scoped-range" + ? activeSession.operation.target + : null; + expect(documentTarget?.scope).toBe("heading"); + expect(documentTarget?.contentFormat).toBe("markdown"); + expect(documentTarget?.blockIds).toHaveLength(1); + }); + + it("replaces the previous story after accepting a follow-up make-it-about rewrite", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + streamCount += 1; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: + streamCount === 1 + ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." + : "# The Cat Keeper's Last Purr\n\nA cat story.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + await controller.runSessionPrompt(session.id, "Write a story", { + target: "document", + }); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt(session.id, "Actually make it about cats", { + target: "document", + }); + const secondTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.id ?? null; + expect(secondTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); + + const finalVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(finalVisibleBlockTexts).toEqual([ + "The Cat Keeper's Last Purr", + "A cat story.", + ]); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part11.test.ts b/packages/extensions/ai/src/__tests__/extension.part11.test.ts new file mode 100644 index 0000000..1fbd448 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part11.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("restores the previous accepted story when undoing a kept follow-up rewrite", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + streamCount += 1; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: + streamCount === 1 + ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." + : "# The Cat Keeper's Last Purr\n\nA cat story.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + await controller.runSessionPrompt(session.id, "Write a story", { + target: "document", + }); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt(session.id, "Actually make it about cats", { + target: "document", + }); + const secondTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.id ?? null; + expect(secondTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); + + expect(editor.undoManager.undo()).toBe(true); + + const visibleBlockTextsAfterUndo = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleBlockTextsAfterUndo).toEqual([ + "The Lighthouse Keeper's Last Signal", + "A lighthouse story.", + ]); + }); + + it("trims leading blank lines when bottom-chat writes into an empty block", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "\n\nOnce upon a time", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(visibleBlockTexts).toEqual(["Once upon a time"]); + }); + + it("materializes bottom-chat paragraphs as separate blocks for empty targets", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "First paragraph.\n\nSecond paragraph.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write two paragraphs", + { target: "document" }, + ); + + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(visibleBlockTexts).toEqual([ + "First paragraph.", + "Second paragraph.", + ]); + }); + + it("reuses a leading empty placeholder for document-target bottom-chat writes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "Story opener.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const placeholderBlockId = editor.firstBlock()!.id; + const trailingBlockId = "trailing-block"; + editor.apply( + [ + { + type: "insert-block", + blockId: trailingBlockId, + blockType: "paragraph", + props: {}, + position: { after: placeholderBlockId }, + }, + { + type: "insert-text", + blockId: trailingBlockId, + offset: 0, + text: "Existing content", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + const blockOrder = editor.documentState.blockOrder; + const visibleBlockTexts = blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(blockOrder).toHaveLength(3); + expect(visibleBlockTexts).toEqual(["Story opener.", "Existing content"]); + expect(readBlockSuggestionMeta(editor.getBlock(placeholderBlockId))?.action).toBe( + "delete-block", + ); + }); + + it("prefers the caret block over unrelated empty placeholders for document-target writes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "Follow the caret.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const placeholderBlockId = editor.firstBlock()!.id; + const caretBlockId = "caret-block"; + editor.apply( + [ + { + type: "insert-block", + blockId: caretBlockId, + blockType: "paragraph", + props: {}, + position: { after: placeholderBlockId }, + }, + { + type: "insert-text", + blockId: caretBlockId, + offset: 0, + text: "Existing content", + }, + ], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId: caretBlockId, offset: 8 }, + { blockId: caretBlockId, offset: 8 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write more here", + { target: "document" }, + ); + const blockOrder = editor.documentState.blockOrder; + const visibleBlockTexts = blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(blockOrder).toHaveLength(3); + expect(visibleBlockTexts).toEqual(["Existing content", "Follow the caret."]); + }); + + it("creates tables through markdown for bottom-chat document prompts", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "| Tier | Price |\n| --- | --- |\n| Pro | $20 |", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const introBlockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Create a pricing table", + { target: "document" }, + ); + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("markdown"); + expect(generation.planState).toBe("none"); + expect(generation.reviewItems).toEqual([]); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.blockClass).toBe("flow"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableCell(0, 0)?.textContent()).toBe("Tier"); + expect(tables[0]?.tableCell(0, 1)?.textContent()).toBe("Price"); + expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Pro"); + expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("$20"); + expect(controller.acceptActiveGeneration()).toBe(true); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part12.test.ts b/packages/extensions/ai/src/__tests__/extension.part12.test.ts new file mode 100644 index 0000000..9725549 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part12.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("streams markdown table suggestions before completion for bottom-chat document prompts", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: + "| First Name | Last Name |\n| --- | --- |\n| Alice | Johnson |", + }; + await releaseFinalDelta.promise; + yield { + type: "text-delta" as const, + delta: "\n| Bob | Smith |", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const introBlockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generationPromise = controller.runSessionPrompt( + session.id, + "Create a table with names in it", + { target: "document" }, + ); + + await waitForPreview(() => { + const tables = Array.from(editor.blocks("table")); + return tables[0]?.tableCell(1, 0)?.textContent() === "Alice"; + }); + + expect(controller.getState().activeGeneration?.adapterId).toBe("flow-markdown"); + expect(controller.getState().activeGeneration?.blockClass).toBe("flow"); + expect(controller.getState().activeGeneration?.transportKind).toBe("flow-text"); + expect(controller.getState().activeGeneration?.mutationMode).toBe( + "streaming-suggestions", + ); + const previewTables = Array.from(editor.blocks("table")); + expect(previewTables).toHaveLength(1); + expect(previewTables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); + expect(previewTables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); + + releaseFinalDelta.resolve(); + const generation = await generationPromise; + + expect(generation.planState).toBe("none"); + expect(generation.reviewItems).toEqual([]); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.blockClass).toBe("flow"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); + expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); + expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); + expect(tables[0]?.tableCell(2, 1)?.textContent()).toBe("Smith"); + }); + + it("builds rich preview details for newly inserted databases during direct bottom-chat apply", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "review_bundle", + label: "Create task database", + reason: "Insert and seed a task database.", + plans: [ + { + kind: "block_insert", + blockId: "task-db", + blockType: "database", + position: "last", + }, + { + kind: "database_edit", + blockId: "task-db", + steps: [ + { + op: "insert_row", + rowId: "row-1", + values: { + name: "Ship docs", + tags: "[\"docs\"]", + done: "false", + }, + }, + { + op: "add_view", + view: { + id: "view-list", + title: "List view", + type: "list", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + }, + }, + ], + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Create a task database table with views", + { target: "document" }, + ); + + expect(generation.planState).toBe("validated"); + expect(generation.structuredPreview?.targets).toEqual([ + expect.objectContaining({ + blockId: "task-db", + targetKind: "database", + database: expect.objectContaining({ + columns: expect.arrayContaining([ + expect.objectContaining({ id: "name" }), + expect.objectContaining({ id: "tags" }), + expect.objectContaining({ id: "done" }), + ]), + rows: [ + expect.objectContaining({ + id: "row-1", + values: expect.objectContaining({ + name: "Ship docs", + }), + }), + ], + views: expect.arrayContaining([ + expect.objectContaining({ id: "view-table" }), + expect.objectContaining({ id: "view-list" }), + ]), + }), + }), + ]); + expect(generation.reviewItems).toEqual([]); + expect(editor.getBlock("task-db")?.type).toBe("database"); + }); + + it("replaces existing tables through markdown suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "| Name |", + "| --- |", + "| Alice |", + "| Bob |", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Add a row to this table", { + blockId: "table-1", + }); + + expect(generation.status).toBe("complete"); + expect(generation.targetKind).toBe("table"); + expect(generation.planState).toBe("none"); + expect(generation.plan).toBeNull(); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.reviewItems).toEqual([]); + expect(generation.debug?.structured).toMatchObject({ + plannerMode: "text", + targetKind: "table", + validationIssueCount: 0, + }); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + expect(editor.getBlock("table-1")?.tableRowCount()).toBe(initialRowCount); + }); + + it("accepts markdown table suggestions through the controller", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "| Name |", + "| --- |", + "| Alice |", + "| Bob |", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); + + const controller = getAIController(editor)!; + await controller.runPrompt("Add a row to this table", { + blockId: "table-1", + }); + + expect(controller.acceptActiveGeneration()).toBe(true); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableRowCount()).toBe(initialRowCount + 1); + expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); + expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); + expect(controller.getState().activeGeneration?.plan).toBeNull(); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + }); + + it("rejects markdown table suggestions without mutating the table", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "| Name |", + "| --- |", + "| Alice |", + "| Bob |", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); + + const controller = getAIController(editor)!; + await controller.runPrompt("Add a row to this table", { + blockId: "table-1", + }); + + expect(controller.rejectActiveGeneration()).toBe(true); + expect(editor.getBlock("table-1")!.tableRowCount()).toBe(initialRowCount); + expect(Array.from(editor.blocks("table"))).toHaveLength(1); + expect(controller.getState().activeGeneration?.plan).toBeNull(); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + expect(controller.getState().activeGeneration?.planState).toBe("rejected"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part13.test.ts b/packages/extensions/ai/src/__tests__/extension.part13.test.ts new file mode 100644 index 0000000..6d6a782 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part13.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("applies XML flow patch plans through the markdown fast-apply path", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "", + "I am replacing the current table with an updated version.", + "adjacent-blocks", + "span:table-1", + "", + "replace_blocks", + "table-1", + "table", + "", + "", + "", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Add a role column to this table", { + blockId: "table-1", + }); + + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + + expect(controller.acceptActiveGeneration()).toBe(true); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableColumnCount()).toBe(2); + expect(tables[0]?.tableRowCount()).toBe(3); + expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Design"); + }); + + it("records flow patch alignment metrics in fast-apply debug state", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Bravo", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const controllerAny = controller as any; + controllerAny._state.activeGeneration = { + id: "test-generation", + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + + const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( + firstBlockId, + [ + "", + "I am inserting a new paragraph between Bravo and Charlie.", + "adjacent-blocks", + `span:${firstBlockId}`, + "", + "replace_blocks", + `${firstBlockId}`, + "block-2", + "block-3", + "", + "", + "", + ].join("\n"), + "persistent-suggestions", + undefined, + { + context: { + markdown: ["Alpha", "", "Bravo", "", "Charlie"].join("\n"), + markdownWindow: { + blockIds: [firstBlockId, "block-2", "block-3"], + }, + }, + }, + ); + + expect(mutationReceipt?.status).toBe("staged_suggestions"); + expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + alignment: { + preservedBlockCount: 3, + rewrittenBlockCount: 0, + unchangedBlockCount: 3, + insertedBlockCount: 1, + deletedBlockCount: 0, + estimatedOperationCost: 2, + }, + }); + }); + + it("records scoped replacement fallback metrics in fast-apply debug state", () => { + const editor = createEditor({ + extensions: [aiExtension({})], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const controllerAny = controller as any; + controllerAny._state.activeGeneration = { + id: "test-generation", + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + + const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( + firstBlockId, + [ + "", + "I am inserting a middle paragraph.", + "", + "", + "", + "", + "Bravo", + "", + "]]>", + "", + ].join("\n"), + "persistent-suggestions", + undefined, + { + context: { + markdown: ["Alpha", "", "Charlie"].join("\n"), + markdownWindow: { + blockIds: [firstBlockId, "block-2"], + }, + }, + }, + ); + + expect(mutationReceipt?.status).toBe("staged_suggestions"); + expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + fallback: { + kind: "scoped-replacement", + opsCount: 8, + insertedBlockCount: 3, + deletedBlockCount: 2, + targetBlockCount: 2, + }, + }); + }); + + it("records plain markdown fallback metrics when fast-apply falls back to block generation", () => { + const editor = createEditor({ + extensions: [aiExtension({ contentFormat: { blockGeneration: "markdown" } })], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const controllerAny = controller as any; + controllerAny._state.activeGeneration = { + id: "test-generation", + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + + const mutationReceipt = controllerAny._commitBufferedBlockGeneration( + firstBlockId, + "## Replacement title", + "persistent-suggestions", + "markdown", + undefined, + { + applyStrategy: "markdown-fast-apply", + workingSet: { + context: { + markdown: "Hello", + markdownWindow: { + blockIds: [firstBlockId], + }, + }, + }, + }, + ); + + expect(mutationReceipt?.status).toBe("staged_suggestions"); + expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ + attempted: true, + succeeded: false, + fallbackReason: "unparseable-contract", + executionPath: "plain-markdown", + fallback: { + kind: "plain-markdown", + opsCount: 2, + insertedBlockCount: 1, + deletedBlockCount: 0, + }, + }); + }); + + it("executes review-safe block convert plans through the existing suggestion path", async () => { + let blockId = ""; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "block_convert", + blockId, + newType: "heading", + props: { level: 2 }, + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Convert block to heading", { + blockId, + }); + const block = editor.getBlock(blockId)!; + + expect(generation.planState).toBe("validated"); + expect(generation.plan).toMatchObject({ + kind: "block_convert", + blockId, + newType: "heading", + }); + expect(block.type).toBe("heading"); + expect(block.meta("suggestion")).toMatchObject({ + action: "convert-block", + authorType: "ai", + }); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part14.test.ts b/packages/extensions/ai/src/__tests__/extension.part14.test.ts new file mode 100644 index 0000000..9cda6aa --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part14.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("keeps the controller state snapshot stable for no-op updates", () => { + const editor = createEditor({ + extensions: [aiExtension()], + }); + + const controller = getAIController(editor)!; + const initialState = controller.getState(); + + controller.setSuggestMode(false); + expect(controller.getState()).toBe(initialState); + + controller.closeCommandMenu(); + expect(controller.getState()).toBe(initialState); + + controller.dismissEphemeralSuggestion(); + expect(controller.getState()).toBe(initialState); + }); + + it("builds database review items with before and after cell previews", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + + expect(generation.reviewItems).toEqual([ + expect.objectContaining({ + label: "Update cell", + changeKind: "updated", + section: "cell", + detail: "Alpha · Name", + before: "Alpha", + after: "Beta", + }), + ]); + }); + + it("keeps accepted structured review items in document undo history", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + const reviewItems = generation.reviewItems ?? []; + const reviewItemIds = reviewItems.map((item) => item.id); + + expect(generation.planState).toBe("validated"); + expect(reviewItems).toHaveLength(1); + expect(reviewItemIds).toHaveLength(1); + + expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Beta", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Alpha", + ); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Beta", + ); + }); + + it("treats structured review rejection as non-mutating UI state", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + const reviewItems = generation.reviewItems ?? []; + const reviewItemIds = reviewItems.map((item) => item.id); + + expect(reviewItemIds).toHaveLength(1); + expect(controller.rejectReviewItems(reviewItemIds)).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Alpha", + ); + expect(editor.undoManager.canUndo()).toBe(false); + expect(editor.undoManager.undo()).toBe(false); + expect(controller.getState().activeGeneration?.planState).toBe("rejected"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + }); + + it("keeps accepted structured review artifacts transient across history replay", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + const reviewItems = generation.reviewItems ?? []; + const reviewItemIds = reviewItems.map((item) => item.id); + + expect(reviewItemIds).toHaveLength(1); + expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Alpha", + ); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Beta", + ); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part15.test.ts b/packages/extensions/ai/src/__tests__/extension.part15.test.ts new file mode 100644 index 0000000..c6111c9 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part15.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("builds comparison rows for database view changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "add_view", + view: { + id: "view-list", + title: "List view", + type: "list", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + filter: null, + groupBy: "tags", + pageIndex: 0, + pageSize: 50, + }, + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Add a grouped list view", { + blockId: "database-1", + }); + + expect(generation.reviewItems).toEqual([ + expect.objectContaining({ + label: "Add view", + comparisonRows: expect.arrayContaining([ + expect.objectContaining({ + label: "View", + after: "List view", + changeKind: "added", + section: "view", + }), + expect.objectContaining({ + label: "Group by", + after: "Tags", + changeKind: "updated", + section: "view", + }), + expect.objectContaining({ + label: "Visible columns", + after: "Name, Tags", + changeKind: "updated", + section: "view", + }), + ]), + }), + ]); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part2.test.ts b/packages/extensions/ai/src/__tests__/extension.part2.test.ts new file mode 100644 index 0000000..62caf49 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part2.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("keeps document-targeted bottom-chat rewrites off selection-fast even with a live selection", async () => { + let requestMode: string | undefined; + let operationKind: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + requestMode = options.requestMode; + operationKind = options.operation?.kind; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(requestMode).toBe("selection-fast"); + expect(operationKind).toBe("rewrite-selection"); + }); + + it("routes bottom-chat block rewrites through typed local replace operations", async () => { + let requestMode: string | undefined; + let operationKind: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + requestMode = options.requestMode; + operationKind = options.operation?.kind; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "Hello planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(requestMode).toBe("selection-fast"); + expect(operationKind).toBe("rewrite-selection"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("routes whole-document rewrites through typed local replace operations", async () => { + let operation: + | { + kind?: string; + target?: { + kind?: string; + blockIds?: readonly string[]; + contentFormat?: string; + scope?: string; + }; + } + | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + operation = options.operation; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Cat Keeper\n\nA cat story.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper" }, + { type: "convert-block", blockId, newType: "heading" }, + { + type: "insert-block", + blockId: "paragraph-1", + blockType: "paragraph", + props: {}, + position: { after: blockId }, + }, + { + type: "insert-text", + blockId: "paragraph-1", + offset: 0, + text: "A lighthouse story.", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the whole story. Make it about cats.", + { target: "document" }, + ); + + expect(operation?.kind).toBe("rewrite-selection"); + expect(operation?.target?.kind).toBe("scoped-range"); + expect(operation?.target?.contentFormat).toBe("markdown"); + expect(operation?.target?.scope).toBe("document"); + expect(operation?.target?.blockIds).toContain("paragraph-1"); + expect(operation?.target?.blockIds).toHaveLength(2); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleBlockTexts).toEqual(["The Cat Keeper", "A cat story."]); + }); + + it("routes remove-all document edits through typed local delete suggestions", async () => { + let operation: + | { + kind?: string; + target?: { + kind?: string; + blockIds?: readonly string[]; + contentFormat?: string; + scope?: string; + }; + } + | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + operation = options.operation; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Remove all content in the document.", + { target: "document" }, + ); + + expect(operation).toMatchObject({ + kind: "rewrite-selection", + target: { + kind: "scoped-range", + blockIds: editor.documentState.blockOrder, + contentFormat: "markdown", + scope: "document", + }, + }); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleBlockTexts).toEqual([]); + }); + + it("keeps heading rewrites block-bounded instead of inserting a new markdown block", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Keeper's Final Watch", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper's Last Night" }, + { type: "convert-block", blockId, newType: "heading" }, + ], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + expect(editor.firstBlock()?.type).toBe("heading"); + expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( + "The Keeper's Final Watch", + ); + expect(editor.documentState.blockOrder).toHaveLength(1); + }); + + it("routes bottom-chat continue prompts to typed insert operations", async () => { + let operationKind: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + operationKind = options.operation?.kind; + yield { + type: "insert-final" as const, + operation: options.operation!, + text: " and beyond", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + await controller.runSessionPrompt(session.id, "Continue writing"); + + expect(operationKind).toBe("continue-block"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello world and beyond", + ); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part3.test.ts b/packages/extensions/ai/src/__tests__/extension.part3.test.ts new file mode 100644 index 0000000..0860d9b --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part3.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("falls back to document review mode for bottom-chat rewrites on non-text blocks", async () => { + let requestMode: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + requestMode = options.requestMode; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId, offset: 0, text: "Hello table" }, + { type: "convert-block", blockId, newType: "table", newProps: {} }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(requestMode).toBe("selection-fast"); + expect(generation.route).toBe("selection-rewrite"); + expect(generation.mutationReceipt?.status).toBe("noop"); + expect(editor.getBlock(blockId)?.type).toBe("table"); + }); + + it("marks local bottom-chat rewrites invalid when target provenance changes", async () => { + const releaseFinalFrame = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + yield { + type: "replace-preview" as const, + operation: options.operation!, + text: "Hello planet", + }; + await releaseFinalFrame.promise; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "Hello planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generationPromise = controller.runSessionPrompt(session.id, "Rewrite this"); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + editor.apply( + [{ type: "insert-text", blockId, offset: 11, text: "!" }], + { origin: "user" }, + ); + releaseFinalFrame.resolve(); + const generation = await generationPromise; + + expect(generation.mutationReceipt?.status).toBe("invalid"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello world!", + ); + }); + + it("accepts typed local bottom-chat document rewrites", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# Hello planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + expect(generation.status).toBe("complete"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + }); + + it("streams selection rewrites into persistent suggestions before completion", async () => { + const releaseSecondDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "plan" }; + await releaseSecondDelta.promise; + yield { type: "text-delta" as const, delta: "et" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const generationPromise = controller.runPrompt("Rewrite the selection"); + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + + expect(controller.getState().ephemeralSuggestion).toBeNull(); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplan"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + + releaseSecondDelta.resolve(); + const generation = await generationPromise; + + expect(generation.status).toBe("complete"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); + }); + + it("tracks session prompts and accepts session suggestions together", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the selection", + ); + const nextSession = controller.getActiveSession(); + + expect(generation.sessionId).toBe(session.id); + expect(nextSession?.promptHistory).toHaveLength(1); + expect(nextSession?.turns).toHaveLength(1); + expect(nextSession?.turns[0]?.generationId).toBe(generation.id); + expect(nextSession?.turns[0]?.status).toBe("review"); + expect(nextSession?.generationIds).toContain(generation.id); + expect(nextSession?.pendingSuggestionIds.length).toBeGreaterThan(0); + expect(controller.acceptSessionTurn(session.id, nextSession!.turns[0]!.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("includes prior inline prompts when continuing the same inline edit session", async () => { + const capturedPrompts: string[] = []; + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream(options) { + capturedPrompts.push(String(options.messages[0]?.content ?? "")); + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "forest", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 12 }, + ); + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + expect(capturedPrompts[1]).toContain( + "You are continuing an existing inline editor edit session.", + ); + expect(capturedPrompts[1]).toContain( + "Earlier user requests in this same session:", + ); + expect(capturedPrompts[1]).toContain("1. Rewrite the selection"); + expect(capturedPrompts[1]).toContain( + "Latest request:\nMake it more whimsical", + ); + }); + + it("refreshes the inline follow-up target after accepting a rewritten selection", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream(options) { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + const secondOperation = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.operation; + expect(secondOperation?.kind).toBe("rewrite-selection"); + expect(secondOperation?.target.kind).toBe("selection"); + if (secondOperation?.target.kind !== "selection") { + throw new Error("Expected selection target for inline follow-up"); + } + expect(secondOperation.target.sourceText).toBe("planet"); + expect( + secondOperation.target.focus.offset - secondOperation.target.anchor.offset, + ).toBeGreaterThanOrEqual("planet".length); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part4.test.ts b/packages/extensions/ai/src/__tests__/extension.part4.test.ts new file mode 100644 index 0000000..7974f36 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part4.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("refreshes the inline follow-up target after keeping a rewritten selection", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + expect(controller.acceptActiveGeneration()).toBe(true); + + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + const secondOperation = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.operation; + expect(secondOperation?.kind).toBe("rewrite-selection"); + expect(secondOperation?.target.kind).toBe("selection"); + if (secondOperation?.target.kind !== "selection") { + throw new Error("Expected selection target for kept inline follow-up"); + } + expect(secondOperation.target.sourceText).toBe("planet"); + expect( + secondOperation.target.focus.offset - secondOperation.target.anchor.offset, + ).toBeGreaterThanOrEqual("planet".length); + }); + + it("refreshes the inline follow-up target while the prior turn is still in review", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + expect( + controller.getSessions().find((item) => item.id === session.id)?.turns[0]?.status, + ).toBe("review"); + + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + const secondOperation = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.operation; + expect(secondOperation?.kind).toBe("rewrite-selection"); + expect(secondOperation?.target.kind).toBe("selection"); + if (secondOperation?.target.kind !== "selection") { + throw new Error("Expected selection target for inline follow-up review"); + } + expect(secondOperation.target.sourceText).toBe("planet"); + expect( + secondOperation.target.focus.offset - secondOperation.target.anchor.offset, + ).toBeGreaterThanOrEqual("planet".length); + }); + + it("keeps inline prompt targets stable after the live selection changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + editor.selectTextRange( + { blockId, offset: 11 }, + { blockId, offset: 11 }, + ); + + const originalSelectTextRange = editor.selectTextRange.bind(editor); + editor.selectTextRange = () => { + // Simulate a selection target that can no longer be reselected from live state. + }; + + try { + const generation = await controller.runSessionPrompt( + session!.id, + "Rewrite the selection", + ); + + expect(generation.status).toBe("complete"); + expect(generation.target).toBe("selection"); + expect( + controller + .getSessions() + .find((item) => item.id === session!.id) + ?.turns[0]?.target, + ).toBe("selection"); + } finally { + editor.selectTextRange = originalSelectTextRange; + } + }); + + it("restores inline edit review state through document undo and redo", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + + const acceptedSession = controller.getActiveSession(); + expect(acceptedSession?.contextualPrompt?.composer.isOpen).toBe(false); + expect(acceptedSession?.turns[0]?.status).toBe("accepted"); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.turns).toHaveLength(1); + expect(restoredSession?.turns[0]?.status).toBe("review"); + expect(restoredSession?.turns[0]?.suggestionIds.length ?? 0).toBeGreaterThan(0); + + expect(editor.undoManager.redo()).toBe(true); + + const redoneSession = controller.getActiveSession(); + expect(redoneSession?.id).toBe(session!.id); + expect(redoneSession?.contextualPrompt?.composer.isOpen).toBe(false); + expect(redoneSession?.turns[0]?.status).toBe("accepted"); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("lets inline edit continue from an undone review state", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + pass += 1; + yield { + type: "text-delta" as const, + delta: pass === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + + await controller.runSessionPrompt(session!.id, "Try another rewrite"); + + const resumedSession = controller.getActiveSession(); + expect(resumedSession?.turns).toHaveLength(2); + expect(resumedSession?.turns[1]?.prompt).toBe("Try another rewrite"); + expect(resumedSession?.turns[1]?.status).toBe("review"); + }); + + it("undoes a streamed inline turn as one step even when deltas span capture timeouts", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "pla" }; + await new Promise((resolve) => setTimeout(resolve, 550)); + yield { type: "text-delta" as const, delta: "net" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + + expect(editor.undoManager.undo()).toBe(true); + const restoredReviewSession = controller.getActiveSession(); + expect(restoredReviewSession?.id).toBe(session!.id); + expect(restoredReviewSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredReviewSession?.turns).toHaveLength(1); + expect(restoredReviewSession?.turns[0]?.status).toBe("review"); + + expect(editor.undoManager.undo()).toBe(true); + const restoredPromptSession = controller.getActiveSession(); + expect(restoredPromptSession?.id).toBe(session!.id); + expect(restoredPromptSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredPromptSession?.turns).toHaveLength(0); + expect(restoredPromptSession?.contextualPrompt?.composer.draftPrompt).toBe( + "Rewrite the selection", + ); + + expect(editor.undoManager.redo()).toBe(true); + const redoneReviewSession = controller.getActiveSession(); + expect(redoneReviewSession?.turns).toHaveLength(1); + expect(redoneReviewSession?.turns[0]?.status).toBe("review"); + + expect(editor.undoManager.redo()).toBe(true); + const redoneAcceptedSession = controller.getActiveSession(); + expect(redoneAcceptedSession?.turns[0]?.status).toBe("accepted"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part5.test.ts b/packages/extensions/ai/src/__tests__/extension.part5.test.ts new file mode 100644 index 0000000..ab00ad1 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part5.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("does not reopen accepted inline review for unrelated undo operations", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello world" }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: secondBlockId, offset: 0, text: "Other block" }, + ], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId: firstBlockId, offset: 6 }, + { blockId: firstBlockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( + false, + ); + editor.undoManager.stopCapturing(); + + editor.selectText(secondBlockId, 11, 11); + editor.apply( + [{ type: "insert-text", blockId: secondBlockId, offset: 11, text: "!" }], + { origin: "user" }, + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(secondBlockId)?.textContent()).toBe("Other block"); + expect(controller.getActiveSession()?.id).toBe(session!.id); + expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( + false, + ); + expect(controller.getActiveSession()?.turns[0]?.status).toBe("accepted"); + }); + + it("restores the latest inline review turn even when no inline session is active", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + controller.suspendInlineSession(session!.id); + expect(controller.getState().activeSessionId).toBeNull(); + + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.turns[0]?.status).toBe("review"); + }); + + it("restores the inline prompt in the same undo step after accepting a suspended review turn", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + + controller.suspendInlineSession(session!.id); + expect(controller.getState().activeSessionId).toBeNull(); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( + "Rewrite the selection", + ); + expect(restoredSession?.turns).toHaveLength(1); + expect(restoredSession?.turns[0]?.status).toBe("review"); + }); + + it("restores prompt and review state on the first inline history undo shortcut", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + expect(inlineHistory.handleShortcut("undo")).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( + "Rewrite the selection", + ); + expect(restoredSession?.turns).toHaveLength(1); + expect(restoredSession?.turns[0]?.status).toBe("review"); + }); + + it("rewrites text that was previously accepted from AI", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + pass += 1; + yield { + type: "text-delta" as const, + delta: pass === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const firstSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(firstSession).not.toBeNull(); + + await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); + const firstTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(firstSession!.id, firstTurnId!)).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 12 }, + ); + const secondSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(secondSession).not.toBeNull(); + expect(secondSession?.id).not.toBe(firstSession?.id); + + await controller.runSessionPrompt(secondSession!.id, "Rewrite the selection"); + const secondTurnId = controller.getActiveSession()?.turns.at(-1)?.id; + expect(secondTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(secondSession!.id, secondTurnId!)).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello galaxy", + ); + }); + + it("records selection rewrites in session fast-apply metrics", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + + expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ + attemptCount: 1, + nativeFastApplyCount: 1, + scopedReplacementCount: 0, + plainMarkdownCount: 0, + failedCount: 0, + }); + }); + + it("accumulates fast-apply outcome counters across session turns", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ contentFormat: { blockGeneration: "markdown" } }), + ], + }); + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "block", + }); + const controllerAny = controller as any; + + controllerAny._recordSessionFastApplyMetrics(session.id, { + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + }); + controllerAny._recordSessionFastApplyMetrics(session.id, { + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + }); + controllerAny._recordSessionFastApplyMetrics(session.id, { + attempted: true, + succeeded: false, + executionPath: "plain-markdown", + fallbackReason: "unparseable-contract", + }); + + expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ + attemptCount: 3, + nativeFastApplyCount: 1, + scopedReplacementCount: 1, + plainMarkdownCount: 1, + failedCount: 0, + }); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part6.test.ts b/packages/extensions/ai/src/__tests__/extension.part6.test.ts new file mode 100644 index 0000000..1e8262a --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part6.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("only restores a suspended inline session through history", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + + expect(session).not.toBeNull(); + controller.suspendInlineSession(session!.id); + expect(controller.getState().activeSessionId).toBeNull(); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + expect(controller.getState().activeSessionId).toBeNull(); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + editor.internals.emit("historyApplied", { + kind: "undo", + selection: editor.selection, + focusBlockId: blockId, + requestId: 1, + }); + + expect(controller.getState().activeSessionId).toBe(session!.id); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(true); + + controller.suspendInlineSession(session!.id); + editor.internals.emit("historyApplied", { + kind: "redo", + selection: editor.selection, + focusBlockId: blockId, + requestId: 2, + }); + + expect(controller.getState().activeSessionId).toBe(session!.id); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(true); + }); + + it("records inline history at settled turn checkpoints instead of stream chunks", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "pla" }; + yield { type: "text-delta" as const, delta: "net" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + const inlineHistory = getAIInlineHistoryController(editor)!; + + await controller.runSessionPrompt(session!.id, "Rewrite this"); + controller.suspendInlineSession(session!.id); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + expect(controller.getState().sessions[0]?.turns[0]?.status).toBe("review"); + + expect(inlineHistory.undoInlineHistory()).toBe(true); + expect(controller.getState().activeSessionId).toBe(session!.id); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.undoInlineHistory()).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(0); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.draftPrompt, + ).toBe("Rewrite this"); + }); + + it("cycles selection inline turn history one turn at a time through shortcuts", async () => { + let turnIndex = 0; + const turnOutputs = ["planet", "galaxy"]; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; + yield { type: "done" as const }; + turnIndex += 1; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "First rewrite"); + controller.suspendInlineSession(session!.id); + await controller.runSessionPrompt(session!.id, "Second rewrite"); + controller.suspendInlineSession(session!.id); + + expect(controller.getState().sessions[0]?.turns).toHaveLength(2); + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions).toHaveLength(0); + expect(controller.getState().activeSessionId).toBeNull(); + + expect(inlineHistory.canHandleShortcut("redo")).toBe(true); + expect(inlineHistory.handleShortcut("redo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.handleShortcut("redo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(2); + }); + + it("keeps the public AI controller inline history methods available", async () => { + let turnIndex = 0; + const turnOutputs = ["planet", "galaxy"]; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; + yield { type: "done" as const }; + turnIndex += 1; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "First rewrite"); + controller.suspendInlineSession(session!.id); + await controller.runSessionPrompt(session!.id, "Second rewrite"); + controller.suspendInlineSession(session!.id); + + expect(controller.canUndoInlineHistory()).toBe(true); + expect(controller.canRedoInlineHistory()).toBe(false); + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + expect(inlineHistory.canUndoInlineHistory()).toBe(true); + expect(controller.undoInlineHistory()).toBe(true); + expect(controller.canRedoInlineHistory()).toBe(true); + expect(controller.redoInlineHistory()).toBe(true); + }); + + it("cycles selection inline turn history even when suggest mode is enabled", async () => { + let turnIndex = 0; + const turnOutputs = ["planet", "galaxy"]; + const editor = createEditor({ + extensions: [ + aiExtension({ + suggestMode: true, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; + yield { type: "done" as const }; + turnIndex += 1; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "First rewrite"); + controller.suspendInlineSession(session!.id); + await controller.runSessionPrompt(session!.id, "Second rewrite"); + controller.suspendInlineSession(session!.id); + + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions).toHaveLength(0); + }); + + it("prefers document undo over local inline history shortcuts when both exist", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + controller.suspendInlineSession(session!.id); + + editor.apply( + [{ type: "insert-text", blockId, offset: 11, text: "!" }], + { origin: "user" }, + ); + + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world!"); + expect(inlineHistory.canUndoInlineHistory()).toBe(true); + expect(inlineHistory.canHandleShortcut("undo")).toBe(false); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); + expect(inlineHistory.canHandleShortcut("undo")).toBe(false); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part7.test.ts b/packages/extensions/ai/src/__tests__/extension.part7.test.ts new file mode 100644 index 0000000..819b01d --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part7.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("creates a fresh inline session when the selection target changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world again" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const firstSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(firstSession).not.toBeNull(); + + await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); + expect(controller.getState().sessions).toHaveLength(1); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + + const secondSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(secondSession).not.toBeNull(); + expect(secondSession?.id).not.toBe(firstSession?.id); + expect(controller.getState().sessions).toHaveLength(2); + expect(controller.getState().activeSessionId).toBe(secondSession?.id); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + expect(controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen).toBe( + false, + ); + expect(controller.getState().sessions[1]?.turns).toHaveLength(0); + expect(controller.getState().sessions[1]?.contextualPrompt?.composer.isOpen).toBe( + true, + ); + }); + + it("keeps inline session prompts selection-scoped for follow-up edits", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Add an intro paragraph before this text", + ); + + expect(generation.target).toBe("selection"); + expect(controller.getState().sessions[0]?.turns[0]?.target).toBe("selection"); + expect(editor.documentState.blockOrder).toHaveLength(1); + }); + + it("closes the inline composer when resolving a session", async () => { + const createInlineSessionEditor = () => + createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const acceptEditor = createInlineSessionEditor(); + const acceptBlockId = acceptEditor.firstBlock()!.id; + acceptEditor.apply( + [{ type: "insert-text", blockId: acceptBlockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + acceptEditor.selectTextRange( + { blockId: acceptBlockId, offset: 6 }, + { blockId: acceptBlockId, offset: 11 }, + ); + const acceptController = getAIController(acceptEditor)!; + const acceptSession = acceptController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await acceptController.runSessionPrompt( + acceptSession.id, + "Rewrite the selection", + ); + + expect(acceptController.resolveSession(acceptSession.id, "accept")).toBe(true); + expect( + acceptController.getActiveSession()?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + const rejectEditor = createInlineSessionEditor(); + const rejectBlockId = rejectEditor.firstBlock()!.id; + rejectEditor.apply( + [{ type: "insert-text", blockId: rejectBlockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + rejectEditor.selectTextRange( + { blockId: rejectBlockId, offset: 6 }, + { blockId: rejectBlockId, offset: 11 }, + ); + const rejectController = getAIController(rejectEditor)!; + const rejectSession = rejectController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await rejectController.runSessionPrompt( + rejectSession.id, + "Rewrite the selection", + ); + + expect(rejectController.resolveSession(rejectSession.id, "reject")).toBe(true); + expect( + rejectController.getActiveSession()?.contextualPrompt?.composer.isOpen, + ).toBe(false); + }); + + it("closes the inline composer when resolving a session turn", async () => { + const createInlineSessionEditor = () => + createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const acceptEditor = createInlineSessionEditor(); + const acceptBlockId = acceptEditor.firstBlock()!.id; + acceptEditor.apply( + [{ type: "insert-text", blockId: acceptBlockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + acceptEditor.selectTextRange( + { blockId: acceptBlockId, offset: 6 }, + { blockId: acceptBlockId, offset: 11 }, + ); + const acceptController = getAIController(acceptEditor)!; + const acceptSession = acceptController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await acceptController.runSessionPrompt( + acceptSession.id, + "Rewrite the selection", + ); + const acceptedTurnId = acceptController.getActiveSession()?.turns[0]?.id; + + expect( + acceptController.resolveSessionTurn( + acceptSession.id, + acceptedTurnId!, + "accept", + ), + ).toBe(true); + expect( + acceptController.getActiveSession()?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + const rejectEditor = createInlineSessionEditor(); + const rejectBlockId = rejectEditor.firstBlock()!.id; + rejectEditor.apply( + [{ type: "insert-text", blockId: rejectBlockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + rejectEditor.selectTextRange( + { blockId: rejectBlockId, offset: 6 }, + { blockId: rejectBlockId, offset: 11 }, + ); + const rejectController = getAIController(rejectEditor)!; + const rejectSession = rejectController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await rejectController.runSessionPrompt( + rejectSession.id, + "Rewrite the selection", + ); + const rejectedTurnId = rejectController.getActiveSession()?.turns[0]?.id; + + expect( + rejectController.resolveSessionTurn( + rejectSession.id, + rejectedTurnId!, + "reject", + ), + ).toBe(true); + expect( + rejectController.getActiveSession()?.contextualPrompt?.composer.isOpen, + ).toBe(false); + }); + + it("uses the captured inline session selection even if the editor selection changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the selection", + ); + + expect(generation.status).toBe("complete"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("routes inline session continue prompts to block streaming suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " More detail" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Continue this paragraph", + ); + + expect(generation.target).toBe("selection"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(editor.getBlock(blockId)!.textContent()).toContain("Hello world"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part8.test.ts b/packages/extensions/ai/src/__tests__/extension.part8.test.ts new file mode 100644 index 0000000..2722cb2 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part8.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("routes inline local-edit prompts to block streaming suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " Better version" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Make it better", + ); + + expect(generation.target).toBe("selection"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); + + it("uses the live collapsed caret offset for block generations", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " AI" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Continue this paragraph", { + target: "block", + blockId, + }); + + expect(generation.target).toBe("block"); + const suggestions = controller.getSuggestions(); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions[0]).toMatchObject({ + blockId, + offset: 5, + }); + }); + + it("uses the selection end as the insertion offset for inline block turns", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " Better" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Make it better", + ); + + expect(generation.target).toBe("selection"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + const suggestions = controller.getSuggestions(); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions[0]?.blockId).toBe(blockId); + }); + + it("creates reviewable cross-block inline edit suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "X" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply([ + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + editor.selectTextRange( + { blockId: firstBlockId, offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the selection", + ); + const nextSession = controller.getActiveSession(); + const turn = nextSession?.turns[0]; + + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + expect(turn?.selection?.isMultiBlock).toBe(true); + expect(turn?.status).toBe("review"); + expect(controller.acceptSessionTurn(session.id, turn!.id)).toBe(true); + expect(editor.getBlock(firstBlockId)?.textContent({ resolved: true })).toBe("HeXain"); + expect(editor.getBlock("b2")).toBeNull(); + expect(editor.getBlock("b3")).toBeNull(); + }); + + it("records progressive tool stream events for the active generation", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + pass += 1; + if (pass === 1) { + yield { + type: "tool-call" as const, + toolCallId: "tool-call-1", + toolName: "test_search", + input: { query: "plan" }, + }; + } + yield { type: "done" as const }; + }, + }, + }), + testStreamingToolExtension(), + ], + }); + const controller = getAIController(editor)!; + const blockId = editor.firstBlock()!.id; + + const generation = await controller.runPrompt("search the document", { blockId }); + const streamEvents = controller.getStreamEvents(); + const streamEventTypes = streamEvents.map((event) => event.type); + const toolOutputEvents = streamEvents.filter( + (event) => event.type === "tool-output", + ); + const toolResultEvent = streamEvents.find( + (event) => event.type === "tool-result", + ); + + expect(generation.status).toBe("complete"); + expect(streamEventTypes).toEqual([ + "generation-start", + "status", + "tool-call", + "status", + "tool-output", + "tool-output", + "tool-result", + "status", + "generation-finish", + ]); + expect(toolOutputEvents).toHaveLength(2); + expect(toolOutputEvents[0]).toMatchObject({ + toolCallId: "tool-call-1", + toolName: "test_search", + part: "searching:plan", + output: "searching:plan", + }); + expect(toolOutputEvents[1]).toMatchObject({ + toolCallId: "tool-call-1", + toolName: "test_search", + part: { matches: 2, query: "plan" }, + output: ["searching:plan", { matches: 2, query: "plan" }], + }); + expect(toolResultEvent).toMatchObject({ + type: "tool-result", + toolCallId: "tool-call-1", + toolName: "test_search", + output: ["searching:plan", { matches: 2, query: "plan" }], + state: "complete", + }); + }); + + it("streams block structured previews before a block plan finishes", async () => { + const releaseSecondDelta = createDeferred(); + let streamedBlockId = ""; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: + `{"kind":"block_convert","blockId":"${streamedBlockId}","newType":"heading"`, + }; + await releaseSecondDelta.promise; + yield { + type: "text-delta" as const, + delta: ',"props":{"level":2}}', + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + streamedBlockId = blockId; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generationPromise = controller.runPrompt("Convert block to heading", { + blockId, + }); + await waitForPreview( + () => controller.getState().activeGeneration?.structuredPreview, + ); + + const activeGeneration = controller.getState().activeGeneration; + const previewEventsBeforeCompletion = controller.getStreamEvents().filter( + (event) => event.type === "structured-preview", + ); + expect(activeGeneration?.structuredPreview).toMatchObject({ + planState: "drafted", + plan: { + kind: "block_convert", + blockId, + newType: "heading", + }, + }); + expect(activeGeneration?.structuredPreview?.reviewItems).toEqual([ + expect.objectContaining({ + label: "Convert block", + section: "block", + changeKind: "updated", + }), + ]); + expect(controller.getStreamEvents().some((event) => ( + event.type === "structured-preview" && + event.preview.plan.kind === "block_convert" + ))).toBe(true); + expect(previewEventsBeforeCompletion).toHaveLength(1); + expect(previewEventsBeforeCompletion[0]).toMatchObject({ + patches: [ + { op: "add", path: "/planState", value: "drafted" }, + { op: "add", path: "/plan", value: expect.any(Object) }, + { op: "add", path: "/reviewItems", value: expect.any(Array) }, + { op: "add", path: "/targets", value: [] }, + ], + }); + + releaseSecondDelta.resolve(); + const generation = await generationPromise; + const previewEventsAfterCompletion = controller.getStreamEvents().filter( + (event) => event.type === "structured-preview", + ); + const finalPreviewEvent = + previewEventsAfterCompletion[previewEventsAfterCompletion.length - 1]; + expect(generation.structuredPreview).toMatchObject({ + planState: "validated", + plan: { + kind: "block_convert", + blockId, + newType: "heading", + props: { level: 2 }, + }, + }); + expect(finalPreviewEvent).toMatchObject({ + patches: [ + { op: "replace", path: "/planState", value: "validated" }, + { op: "add", path: "/plan/props", value: {} }, + { op: "add", path: "/plan/props/level", value: 2 }, + ], + }); + expect( + finalPreviewEvent?.patches.some((patch) => patch.path === "/plan"), + ).toBe(false); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part9.test.ts b/packages/extensions/ai/src/__tests__/extension.part9.test.ts new file mode 100644 index 0000000..fae4a5f --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part9.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("keeps selection rewrites text-only when markdown block generation is enabled", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { blockGeneration: "markdown" }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "# Planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Rewrite the selection"); + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("text"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world# Planet"); + expect(editor.documentState.blockOrder).toHaveLength(1); + }); + + it("routes context-first block edits into persistent suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " Updated" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Improve this paragraph", { blockId }); + const block = editor.getBlock(blockId)!; + + expect(generation.route).toBe("context-first"); + expect(generation.mutationMode).toBe("persistent-suggestions"); + expect(block.textContent()).toBe("Hello Updated"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); + + it("uses markdown block generation for bottom-chat document writing", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "Once upon " }; + await releaseFinalDelta.promise; + yield { type: "text-delta" as const, delta: "a time" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generationPromise = controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + + await waitForPreview(() => { + const activeGeneration = controller.getState().activeGeneration; + const streamedVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + return ( + activeGeneration?.surface === "bottom-chat" && + activeGeneration.contentFormat === "markdown" && + streamedVisibleBlockTexts.includes("Once upon") + ); + }); + + const streamedVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); + expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); + expect(controller.getState().activeGeneration?.mutationMode).toBe( + "streaming-suggestions", + ); + expect(streamedVisibleBlockTexts).toEqual(["Hello", "Once upon"]); + expect(session.surface).toBe("bottom-chat"); + + releaseFinalDelta.resolve(); + const generation = await generationPromise; + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(generation.status).toBe("complete"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(generation.contentFormat).toBe("markdown"); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.blockClass).toBe("flow"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + expect(visibleBlockTexts).toEqual(["Hello", "Once upon a time"]); + }); + + it("streams bottom-chat markdown as block suggestions before completion", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream(options) { + yield { + type: "replace-preview" as const, + operation: options.operation!, + text: "\n\nOnce upon ", + }; + await releaseFinalDelta.promise; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "\n\nOnce upon a time", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generationPromise = controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); + expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); + const visibleStreamingTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect( + (editor.getBlock(blockId)?.textContent({ resolved: true }) ?? "").replace( + /^\u200b/, + "", + ), + ).toBe(""); + expect(visibleStreamingTexts).toEqual(["Once upon"]); + + releaseFinalDelta.resolve(); + const generation = await generationPromise; + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("markdown"); + expect(generation.text).toBe("\n\nOnce upon a time"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + const visibleFinalTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleFinalTexts).toEqual(["Once upon a time"]); + const turnId = controller + .getState() + .sessions.find((item) => item.id === session.id) + ?.turns[0]?.id; + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + const keptTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(keptTexts).toEqual(["Once upon a time"]); + }); + + it("allows inline selection edits after keeping bottom-chat changes", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream(options) { + pass += 1; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: pass === 1 ? "Hello world" : "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const bottomChatSession = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + await controller.runSessionPrompt( + bottomChatSession.id, + "Write something in the document", + { target: "document" }, + ); + + const keptTurnId = controller + .getSessions() + .find((session) => session.id === bottomChatSession.id) + ?.turns[0]?.id; + expect(keptTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(bottomChatSession.id, keptTurnId!)).toBe(true); + + const blockId = editor.firstBlock()!.id; + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const inlineSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(inlineSession).not.toBeNull(); + + const generation = await controller.runSessionPrompt( + inlineSession!.id, + "Rewrite the selection", + { target: "selection" }, + ); + + expect(generation.target).toBe("selection"); + expect( + controller + .getSessions() + .find((session) => session.id === inlineSession!.id) + ?.turns, + ).toHaveLength(1); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.test.ts b/packages/extensions/ai/src/__tests__/extension.test.ts index b590a28..6b8ec35 100644 --- a/packages/extensions/ai/src/__tests__/extension.test.ts +++ b/packages/extensions/ai/src/__tests__/extension.test.ts @@ -1,4845 +1,3 @@ -import { describe, expect, it } from "vitest"; -import { createEditor } from "@pen/core"; -import { - defineExtension, - type ToolRuntime, -} from "@pen/types"; -import { - acceptAllSuggestions, - acceptSuggestion, - aiExtension, - getAIInlineHistoryController, - getAIController, - rejectSuggestion, -} from "../index"; -import { - readAllSuggestions, - readBlockSuggestionMeta, - readSuggestionsFromBlock, -} from "../suggestions/persistent"; +import { describe } from "vitest"; -function testStreamingToolExtension() { - let toolRuntime: ToolRuntime | null = null; - - return defineExtension({ - name: "test-streaming-tool", - dependencies: ["document-ops"], - activateClient: async ({ editor }) => { - toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; - toolRuntime?.registerTool({ - name: "test_search", - description: "Test streaming search tool", - inputSchema: { - type: "object", - required: ["query"], - properties: { - query: { type: "string" }, - }, - }, - async *handler(input) { - const { query } = input as { query: string }; - yield `searching:${query}`; - yield { matches: 2, query }; - }, - }); - }, - deactivateClient: async () => { - toolRuntime?.unregisterTool("test_search"); - toolRuntime = null; - }, - }); -} - -function createDeferred() { - let resolve!: () => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - return { promise, resolve }; -} - -async function waitForPreview( - readPreview: () => unknown, - maxTicks = 10, -): Promise { - for (let tick = 0; tick < maxTicks; tick += 1) { - if (readPreview()) { - return; - } - await Promise.resolve(); - } -} - -describe("aiExtension", () => { - it("marks inserted and deleted text in suggest mode", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "user" }, - ); - editor.apply( - [{ type: "delete-text", blockId, offset: 6, length: 5 }], - { origin: "user" }, - ); - - const block = editor.getBlock(blockId)!; - const deltas = block.textDeltas(); - - expect(deltas[0]?.attributes?.suggestion).toMatchObject({ - action: "insert", - author: "tester", - }); - expect(deltas[1]?.attributes?.suggestion).toMatchObject({ - action: "delete", - author: "tester", - }); - expect(block.textContent()).toBe("Hello world"); - expect(block.textContent({ resolved: true })).toBe("Hello "); - }); - - it("rejects persistent suggestions through the controller", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "user" }, - ); - - const controller = getAIController(editor)!; - const suggestionsSnapshot = controller.getSuggestions(); - const suggestion = suggestionsSnapshot[0]; - expect(suggestion).toBeDefined(); - expect(controller.getSuggestions()).toBe(suggestionsSnapshot); - - expect(rejectSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - expect(readAllSuggestions(editor)).toEqual([]); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - }); - - it("accepts persistent suggestions without re-intercepting them", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - editor.apply( - [{ type: "delete-text", blockId, offset: 0, length: 5 }], - { origin: "user" }, - ); - - const [suggestion] = readSuggestionsFromBlock(editor, blockId); - expect(suggestion).toBeDefined(); - - expect(acceptSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - expect(readAllSuggestions(editor)).toEqual([]); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - }); - - it("keeps accepted delete suggestions in document undo history", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - editor.apply( - [{ type: "delete-text", blockId, offset: 0, length: 5 }], - { origin: "user" }, - ); - - const [suggestion] = readSuggestionsFromBlock(editor, blockId); - expect(suggestion).toBeDefined(); - expect(acceptSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello"); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.redo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - }); - - it("keeps rejected insert suggestions in document undo history", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "user" }, - ); - - const [suggestion] = readSuggestionsFromBlock(editor, blockId); - expect(suggestion).toBeDefined(); - expect(rejectSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.redo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - }); - - it("accepts multiple suggestions in one undo group", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const firstBlockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], - { origin: "user" }, - ); - editor.apply( - [ - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "user" }, - ); - - expect(readAllSuggestions(editor)).toHaveLength(2); - - acceptAllSuggestions(editor); - expect(readAllSuggestions(editor)).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(readAllSuggestions(editor)).toHaveLength(2); - - expect(editor.undoManager.redo()).toBe(true); - expect(readAllSuggestions(editor)).toEqual([]); - }); - - it("runs a block generation with a model adapter", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Continue", { blockId }); - - expect(generation.status).toBe("complete"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); - expect(controller.getState().activeGeneration?.text).toBe(" world"); - }); - - it("parses markdown block generations into structured blocks", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { blockGeneration: "markdown" }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "# Title\n\n- One", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const targetBlockId = "target-block"; - const trailingBlockId = "trailing-block"; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: targetBlockId, - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-block", - blockId: trailingBlockId, - blockType: "paragraph", - props: {}, - position: { after: targetBlockId }, - }, - { - type: "insert-text", - blockId: trailingBlockId, - offset: 0, - text: "Outro", - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")?.tableRowCount(); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Continue this paragraph", { - blockId: targetBlockId, - }); - const blockOrder = editor.documentState.blockOrder; - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("markdown"); - expect(blockOrder).toHaveLength(4); - expect(blockOrder).not.toContain(targetBlockId); - expect(editor.getBlock(blockOrder[0])?.textContent()).toBe("Intro"); - expect(editor.getBlock(blockOrder[1])?.type).toBe("heading"); - expect(editor.getBlock(blockOrder[1])?.textContent()).toBe("Title"); - expect(editor.getBlock(blockOrder[2])?.type).toBe("bulletListItem"); - expect(editor.getBlock(blockOrder[2])?.textContent()).toBe("One"); - expect(editor.getBlock(blockOrder[3])?.textContent()).toBe("Outro"); - }); - - it("runs a selection generation when text is selected", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Rewrite the selection"); - - expect(generation.status).toBe("complete"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello planet"); - expect(controller.getState().activeGeneration?.text).toBe("planet"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("uses selection-fast request mode for bottom-chat selection rewrites", async () => { - let requestMode: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream(options) { - requestMode = options.requestMode; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "selection", - }); - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - - expect(requestMode).toBe("selection-fast"); - }); - - it("keeps document-targeted bottom-chat rewrites off selection-fast even with a live selection", async () => { - let requestMode: string | undefined; - let operationKind: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - requestMode = options.requestMode; - operationKind = options.operation?.kind; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(requestMode).toBe("selection-fast"); - expect(operationKind).toBe("rewrite-selection"); - }); - - it("routes bottom-chat block rewrites through typed local replace operations", async () => { - let requestMode: string | undefined; - let operationKind: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - requestMode = options.requestMode; - operationKind = options.operation?.kind; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "Hello planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(requestMode).toBe("selection-fast"); - expect(operationKind).toBe("rewrite-selection"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("routes whole-document rewrites through typed local replace operations", async () => { - let operation: - | { - kind?: string; - target?: { - kind?: string; - blockIds?: readonly string[]; - contentFormat?: string; - scope?: string; - }; - } - | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - operation = options.operation; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Cat Keeper\n\nA cat story.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper" }, - { type: "convert-block", blockId, newType: "heading" }, - { - type: "insert-block", - blockId: "paragraph-1", - blockType: "paragraph", - props: {}, - position: { after: blockId }, - }, - { - type: "insert-text", - blockId: "paragraph-1", - offset: 0, - text: "A lighthouse story.", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the whole story. Make it about cats.", - { target: "document" }, - ); - - expect(operation?.kind).toBe("rewrite-selection"); - expect(operation?.target?.kind).toBe("scoped-range"); - expect(operation?.target?.contentFormat).toBe("markdown"); - expect(operation?.target?.scope).toBe("document"); - expect(operation?.target?.blockIds).toContain("paragraph-1"); - expect(operation?.target?.blockIds).toHaveLength(2); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleBlockTexts).toEqual(["The Cat Keeper", "A cat story."]); - }); - - it("routes remove-all document edits through typed local delete suggestions", async () => { - let operation: - | { - kind?: string; - target?: { - kind?: string; - blockIds?: readonly string[]; - contentFormat?: string; - scope?: string; - }; - } - | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - operation = options.operation; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Remove all content in the document.", - { target: "document" }, - ); - - expect(operation).toMatchObject({ - kind: "rewrite-selection", - target: { - kind: "scoped-range", - blockIds: editor.documentState.blockOrder, - contentFormat: "markdown", - scope: "document", - }, - }); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleBlockTexts).toEqual([]); - }); - - it("keeps heading rewrites block-bounded instead of inserting a new markdown block", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Keeper's Final Watch", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper's Last Night" }, - { type: "convert-block", blockId, newType: "heading" }, - ], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 0 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - expect(editor.firstBlock()?.type).toBe("heading"); - expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( - "The Keeper's Final Watch", - ); - expect(editor.documentState.blockOrder).toHaveLength(1); - }); - - it("routes bottom-chat continue prompts to typed insert operations", async () => { - let operationKind: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - operationKind = options.operation?.kind; - yield { - type: "insert-final" as const, - operation: options.operation!, - text: " and beyond", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - await controller.runSessionPrompt(session.id, "Continue writing"); - - expect(operationKind).toBe("continue-block"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello world and beyond", - ); - }); - - it("falls back to document review mode for bottom-chat rewrites on non-text blocks", async () => { - let requestMode: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - requestMode = options.requestMode; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId, offset: 0, text: "Hello table" }, - { type: "convert-block", blockId, newType: "table", newProps: {} }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(requestMode).toBe("selection-fast"); - expect(generation.route).toBe("selection-rewrite"); - expect(generation.mutationReceipt?.status).toBe("noop"); - expect(editor.getBlock(blockId)?.type).toBe("table"); - }); - - it("marks local bottom-chat rewrites invalid when target provenance changes", async () => { - const releaseFinalFrame = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - yield { - type: "replace-preview" as const, - operation: options.operation!, - text: "Hello planet", - }; - await releaseFinalFrame.promise; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "Hello planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generationPromise = controller.runSessionPrompt(session.id, "Rewrite this"); - for (let tick = 0; tick < 4; tick += 1) { - await Promise.resolve(); - } - editor.apply( - [{ type: "insert-text", blockId, offset: 11, text: "!" }], - { origin: "user" }, - ); - releaseFinalFrame.resolve(); - const generation = await generationPromise; - - expect(generation.mutationReceipt?.status).toBe("invalid"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello world!", - ); - }); - - it("accepts typed local bottom-chat document rewrites", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# Hello planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - expect(generation.status).toBe("complete"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - }); - - it("streams selection rewrites into persistent suggestions before completion", async () => { - const releaseSecondDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "plan" }; - await releaseSecondDelta.promise; - yield { type: "text-delta" as const, delta: "et" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const generationPromise = controller.runPrompt("Rewrite the selection"); - for (let tick = 0; tick < 6; tick += 1) { - await Promise.resolve(); - } - - expect(controller.getState().ephemeralSuggestion).toBeNull(); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplan"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - - releaseSecondDelta.resolve(); - const generation = await generationPromise; - - expect(generation.status).toBe("complete"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); - }); - - it("tracks session prompts and accepts session suggestions together", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the selection", - ); - const nextSession = controller.getActiveSession(); - - expect(generation.sessionId).toBe(session.id); - expect(nextSession?.promptHistory).toHaveLength(1); - expect(nextSession?.turns).toHaveLength(1); - expect(nextSession?.turns[0]?.generationId).toBe(generation.id); - expect(nextSession?.turns[0]?.status).toBe("review"); - expect(nextSession?.generationIds).toContain(generation.id); - expect(nextSession?.pendingSuggestionIds.length).toBeGreaterThan(0); - expect(controller.acceptSessionTurn(session.id, nextSession!.turns[0]!.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("includes prior inline prompts when continuing the same inline edit session", async () => { - const capturedPrompts: string[] = []; - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream(options) { - capturedPrompts.push(String(options.messages[0]?.content ?? "")); - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "forest", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 12 }, - ); - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - expect(capturedPrompts[1]).toContain( - "You are continuing an existing inline editor edit session.", - ); - expect(capturedPrompts[1]).toContain( - "Earlier user requests in this same session:", - ); - expect(capturedPrompts[1]).toContain("1. Rewrite the selection"); - expect(capturedPrompts[1]).toContain( - "Latest request:\nMake it more whimsical", - ); - }); - - it("refreshes the inline follow-up target after accepting a rewritten selection", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream(options) { - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - const secondOperation = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.operation; - expect(secondOperation?.kind).toBe("rewrite-selection"); - expect(secondOperation?.target.kind).toBe("selection"); - if (secondOperation?.target.kind !== "selection") { - throw new Error("Expected selection target for inline follow-up"); - } - expect(secondOperation.target.sourceText).toBe("planet"); - expect( - secondOperation.target.focus.offset - secondOperation.target.anchor.offset, - ).toBeGreaterThanOrEqual("planet".length); - }); - - it("refreshes the inline follow-up target after keeping a rewritten selection", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - expect(controller.acceptActiveGeneration()).toBe(true); - - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - const secondOperation = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.operation; - expect(secondOperation?.kind).toBe("rewrite-selection"); - expect(secondOperation?.target.kind).toBe("selection"); - if (secondOperation?.target.kind !== "selection") { - throw new Error("Expected selection target for kept inline follow-up"); - } - expect(secondOperation.target.sourceText).toBe("planet"); - expect( - secondOperation.target.focus.offset - secondOperation.target.anchor.offset, - ).toBeGreaterThanOrEqual("planet".length); - }); - - it("refreshes the inline follow-up target while the prior turn is still in review", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - expect( - controller.getSessions().find((item) => item.id === session.id)?.turns[0]?.status, - ).toBe("review"); - - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - const secondOperation = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.operation; - expect(secondOperation?.kind).toBe("rewrite-selection"); - expect(secondOperation?.target.kind).toBe("selection"); - if (secondOperation?.target.kind !== "selection") { - throw new Error("Expected selection target for inline follow-up review"); - } - expect(secondOperation.target.sourceText).toBe("planet"); - expect( - secondOperation.target.focus.offset - secondOperation.target.anchor.offset, - ).toBeGreaterThanOrEqual("planet".length); - }); - - it("keeps inline prompt targets stable after the live selection changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - editor.selectTextRange( - { blockId, offset: 11 }, - { blockId, offset: 11 }, - ); - - const originalSelectTextRange = editor.selectTextRange.bind(editor); - editor.selectTextRange = () => { - // Simulate a selection target that can no longer be reselected from live state. - }; - - try { - const generation = await controller.runSessionPrompt( - session!.id, - "Rewrite the selection", - ); - - expect(generation.status).toBe("complete"); - expect(generation.target).toBe("selection"); - expect( - controller - .getSessions() - .find((item) => item.id === session!.id) - ?.turns[0]?.target, - ).toBe("selection"); - } finally { - editor.selectTextRange = originalSelectTextRange; - } - }); - - it("restores inline edit review state through document undo and redo", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - - const acceptedSession = controller.getActiveSession(); - expect(acceptedSession?.contextualPrompt?.composer.isOpen).toBe(false); - expect(acceptedSession?.turns[0]?.status).toBe("accepted"); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello planet", - ); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 0 }, - ); - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.turns).toHaveLength(1); - expect(restoredSession?.turns[0]?.status).toBe("review"); - expect(restoredSession?.turns[0]?.suggestionIds.length ?? 0).toBeGreaterThan(0); - - expect(editor.undoManager.redo()).toBe(true); - - const redoneSession = controller.getActiveSession(); - expect(redoneSession?.id).toBe(session!.id); - expect(redoneSession?.contextualPrompt?.composer.isOpen).toBe(false); - expect(redoneSession?.turns[0]?.status).toBe("accepted"); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("lets inline edit continue from an undone review state", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - pass += 1; - yield { - type: "text-delta" as const, - delta: pass === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - - await controller.runSessionPrompt(session!.id, "Try another rewrite"); - - const resumedSession = controller.getActiveSession(); - expect(resumedSession?.turns).toHaveLength(2); - expect(resumedSession?.turns[1]?.prompt).toBe("Try another rewrite"); - expect(resumedSession?.turns[1]?.status).toBe("review"); - }); - - it("undoes a streamed inline turn as one step even when deltas span capture timeouts", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "pla" }; - await new Promise((resolve) => setTimeout(resolve, 550)); - yield { type: "text-delta" as const, delta: "net" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - - expect(editor.undoManager.undo()).toBe(true); - const restoredReviewSession = controller.getActiveSession(); - expect(restoredReviewSession?.id).toBe(session!.id); - expect(restoredReviewSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredReviewSession?.turns).toHaveLength(1); - expect(restoredReviewSession?.turns[0]?.status).toBe("review"); - - expect(editor.undoManager.undo()).toBe(true); - const restoredPromptSession = controller.getActiveSession(); - expect(restoredPromptSession?.id).toBe(session!.id); - expect(restoredPromptSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredPromptSession?.turns).toHaveLength(0); - expect(restoredPromptSession?.contextualPrompt?.composer.draftPrompt).toBe( - "Rewrite the selection", - ); - - expect(editor.undoManager.redo()).toBe(true); - const redoneReviewSession = controller.getActiveSession(); - expect(redoneReviewSession?.turns).toHaveLength(1); - expect(redoneReviewSession?.turns[0]?.status).toBe("review"); - - expect(editor.undoManager.redo()).toBe(true); - const redoneAcceptedSession = controller.getActiveSession(); - expect(redoneAcceptedSession?.turns[0]?.status).toBe("accepted"); - }); - - it("does not reopen accepted inline review for unrelated undo operations", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const secondBlockId = crypto.randomUUID(); - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello world" }, - { - type: "insert-block", - blockId: secondBlockId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: secondBlockId, offset: 0, text: "Other block" }, - ], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId: firstBlockId, offset: 6 }, - { blockId: firstBlockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( - false, - ); - editor.undoManager.stopCapturing(); - - editor.selectText(secondBlockId, 11, 11); - editor.apply( - [{ type: "insert-text", blockId: secondBlockId, offset: 11, text: "!" }], - { origin: "user" }, - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(secondBlockId)?.textContent()).toBe("Other block"); - expect(controller.getActiveSession()?.id).toBe(session!.id); - expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( - false, - ); - expect(controller.getActiveSession()?.turns[0]?.status).toBe("accepted"); - }); - - it("restores the latest inline review turn even when no inline session is active", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - controller.suspendInlineSession(session!.id); - expect(controller.getState().activeSessionId).toBeNull(); - - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.turns[0]?.status).toBe("review"); - }); - - it("restores the inline prompt in the same undo step after accepting a suspended review turn", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - - controller.suspendInlineSession(session!.id); - expect(controller.getState().activeSessionId).toBeNull(); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( - "Rewrite the selection", - ); - expect(restoredSession?.turns).toHaveLength(1); - expect(restoredSession?.turns[0]?.status).toBe("review"); - }); - - it("restores prompt and review state on the first inline history undo shortcut", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - expect(inlineHistory.handleShortcut("undo")).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( - "Rewrite the selection", - ); - expect(restoredSession?.turns).toHaveLength(1); - expect(restoredSession?.turns[0]?.status).toBe("review"); - }); - - it("rewrites text that was previously accepted from AI", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - pass += 1; - yield { - type: "text-delta" as const, - delta: pass === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const firstSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(firstSession).not.toBeNull(); - - await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); - const firstTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(firstSession!.id, firstTurnId!)).toBe(true); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello planet", - ); - - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 12 }, - ); - const secondSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(secondSession).not.toBeNull(); - expect(secondSession?.id).not.toBe(firstSession?.id); - - await controller.runSessionPrompt(secondSession!.id, "Rewrite the selection"); - const secondTurnId = controller.getActiveSession()?.turns.at(-1)?.id; - expect(secondTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(secondSession!.id, secondTurnId!)).toBe(true); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello galaxy", - ); - }); - - it("records selection rewrites in session fast-apply metrics", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - - expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ - attemptCount: 1, - nativeFastApplyCount: 1, - scopedReplacementCount: 0, - plainMarkdownCount: 0, - failedCount: 0, - }); - }); - - it("accumulates fast-apply outcome counters across session turns", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ contentFormat: { blockGeneration: "markdown" } }), - ], - }); - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "block", - }); - const controllerAny = controller as any; - - controllerAny._recordSessionFastApplyMetrics(session.id, { - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - }); - controllerAny._recordSessionFastApplyMetrics(session.id, { - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - }); - controllerAny._recordSessionFastApplyMetrics(session.id, { - attempted: true, - succeeded: false, - executionPath: "plain-markdown", - fallbackReason: "unparseable-contract", - }); - - expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ - attemptCount: 3, - nativeFastApplyCount: 1, - scopedReplacementCount: 1, - plainMarkdownCount: 1, - failedCount: 0, - }); - }); - - it("only restores a suspended inline session through history", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - - expect(session).not.toBeNull(); - controller.suspendInlineSession(session!.id); - expect(controller.getState().activeSessionId).toBeNull(); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 5 }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - expect(controller.getState().activeSessionId).toBeNull(); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - editor.internals.emit("historyApplied", { - kind: "undo", - selection: editor.selection, - focusBlockId: blockId, - requestId: 1, - }); - - expect(controller.getState().activeSessionId).toBe(session!.id); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(true); - - controller.suspendInlineSession(session!.id); - editor.internals.emit("historyApplied", { - kind: "redo", - selection: editor.selection, - focusBlockId: blockId, - requestId: 2, - }); - - expect(controller.getState().activeSessionId).toBe(session!.id); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(true); - }); - - it("records inline history at settled turn checkpoints instead of stream chunks", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "pla" }; - yield { type: "text-delta" as const, delta: "net" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - const inlineHistory = getAIInlineHistoryController(editor)!; - - await controller.runSessionPrompt(session!.id, "Rewrite this"); - controller.suspendInlineSession(session!.id); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - expect(controller.getState().sessions[0]?.turns[0]?.status).toBe("review"); - - expect(inlineHistory.undoInlineHistory()).toBe(true); - expect(controller.getState().activeSessionId).toBe(session!.id); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.undoInlineHistory()).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(0); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.draftPrompt, - ).toBe("Rewrite this"); - }); - - it("cycles selection inline turn history one turn at a time through shortcuts", async () => { - let turnIndex = 0; - const turnOutputs = ["planet", "galaxy"]; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; - yield { type: "done" as const }; - turnIndex += 1; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "First rewrite"); - controller.suspendInlineSession(session!.id); - await controller.runSessionPrompt(session!.id, "Second rewrite"); - controller.suspendInlineSession(session!.id); - - expect(controller.getState().sessions[0]?.turns).toHaveLength(2); - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions).toHaveLength(0); - expect(controller.getState().activeSessionId).toBeNull(); - - expect(inlineHistory.canHandleShortcut("redo")).toBe(true); - expect(inlineHistory.handleShortcut("redo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.handleShortcut("redo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(2); - }); - - it("keeps the public AI controller inline history methods available", async () => { - let turnIndex = 0; - const turnOutputs = ["planet", "galaxy"]; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; - yield { type: "done" as const }; - turnIndex += 1; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "First rewrite"); - controller.suspendInlineSession(session!.id); - await controller.runSessionPrompt(session!.id, "Second rewrite"); - controller.suspendInlineSession(session!.id); - - expect(controller.canUndoInlineHistory()).toBe(true); - expect(controller.canRedoInlineHistory()).toBe(false); - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - expect(inlineHistory.canUndoInlineHistory()).toBe(true); - expect(controller.undoInlineHistory()).toBe(true); - expect(controller.canRedoInlineHistory()).toBe(true); - expect(controller.redoInlineHistory()).toBe(true); - }); - - it("cycles selection inline turn history even when suggest mode is enabled", async () => { - let turnIndex = 0; - const turnOutputs = ["planet", "galaxy"]; - const editor = createEditor({ - extensions: [ - aiExtension({ - suggestMode: true, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; - yield { type: "done" as const }; - turnIndex += 1; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "First rewrite"); - controller.suspendInlineSession(session!.id); - await controller.runSessionPrompt(session!.id, "Second rewrite"); - controller.suspendInlineSession(session!.id); - - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions).toHaveLength(0); - }); - - it("prefers document undo over local inline history shortcuts when both exist", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - controller.suspendInlineSession(session!.id); - - editor.apply( - [{ type: "insert-text", blockId, offset: 11, text: "!" }], - { origin: "user" }, - ); - - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world!"); - expect(inlineHistory.canUndoInlineHistory()).toBe(true); - expect(inlineHistory.canHandleShortcut("undo")).toBe(false); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); - expect(inlineHistory.canHandleShortcut("undo")).toBe(false); - }); - - it("creates a fresh inline session when the selection target changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world again" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const firstSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(firstSession).not.toBeNull(); - - await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); - expect(controller.getState().sessions).toHaveLength(1); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 5 }, - ); - - const secondSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(secondSession).not.toBeNull(); - expect(secondSession?.id).not.toBe(firstSession?.id); - expect(controller.getState().sessions).toHaveLength(2); - expect(controller.getState().activeSessionId).toBe(secondSession?.id); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - expect(controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen).toBe( - false, - ); - expect(controller.getState().sessions[1]?.turns).toHaveLength(0); - expect(controller.getState().sessions[1]?.contextualPrompt?.composer.isOpen).toBe( - true, - ); - }); - - it("keeps inline session prompts selection-scoped for follow-up edits", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Add an intro paragraph before this text", - ); - - expect(generation.target).toBe("selection"); - expect(controller.getState().sessions[0]?.turns[0]?.target).toBe("selection"); - expect(editor.documentState.blockOrder).toHaveLength(1); - }); - - it("closes the inline composer when resolving a session", async () => { - const createInlineSessionEditor = () => - createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const acceptEditor = createInlineSessionEditor(); - const acceptBlockId = acceptEditor.firstBlock()!.id; - acceptEditor.apply( - [{ type: "insert-text", blockId: acceptBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - acceptEditor.selectTextRange( - { blockId: acceptBlockId, offset: 6 }, - { blockId: acceptBlockId, offset: 11 }, - ); - const acceptController = getAIController(acceptEditor)!; - const acceptSession = acceptController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await acceptController.runSessionPrompt( - acceptSession.id, - "Rewrite the selection", - ); - - expect(acceptController.resolveSession(acceptSession.id, "accept")).toBe(true); - expect( - acceptController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - const rejectEditor = createInlineSessionEditor(); - const rejectBlockId = rejectEditor.firstBlock()!.id; - rejectEditor.apply( - [{ type: "insert-text", blockId: rejectBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - rejectEditor.selectTextRange( - { blockId: rejectBlockId, offset: 6 }, - { blockId: rejectBlockId, offset: 11 }, - ); - const rejectController = getAIController(rejectEditor)!; - const rejectSession = rejectController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await rejectController.runSessionPrompt( - rejectSession.id, - "Rewrite the selection", - ); - - expect(rejectController.resolveSession(rejectSession.id, "reject")).toBe(true); - expect( - rejectController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - }); - - it("closes the inline composer when resolving a session turn", async () => { - const createInlineSessionEditor = () => - createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const acceptEditor = createInlineSessionEditor(); - const acceptBlockId = acceptEditor.firstBlock()!.id; - acceptEditor.apply( - [{ type: "insert-text", blockId: acceptBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - acceptEditor.selectTextRange( - { blockId: acceptBlockId, offset: 6 }, - { blockId: acceptBlockId, offset: 11 }, - ); - const acceptController = getAIController(acceptEditor)!; - const acceptSession = acceptController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await acceptController.runSessionPrompt( - acceptSession.id, - "Rewrite the selection", - ); - const acceptedTurnId = acceptController.getActiveSession()?.turns[0]?.id; - - expect( - acceptController.resolveSessionTurn( - acceptSession.id, - acceptedTurnId!, - "accept", - ), - ).toBe(true); - expect( - acceptController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - const rejectEditor = createInlineSessionEditor(); - const rejectBlockId = rejectEditor.firstBlock()!.id; - rejectEditor.apply( - [{ type: "insert-text", blockId: rejectBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - rejectEditor.selectTextRange( - { blockId: rejectBlockId, offset: 6 }, - { blockId: rejectBlockId, offset: 11 }, - ); - const rejectController = getAIController(rejectEditor)!; - const rejectSession = rejectController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await rejectController.runSessionPrompt( - rejectSession.id, - "Rewrite the selection", - ); - const rejectedTurnId = rejectController.getActiveSession()?.turns[0]?.id; - - expect( - rejectController.resolveSessionTurn( - rejectSession.id, - rejectedTurnId!, - "reject", - ), - ).toBe(true); - expect( - rejectController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - }); - - it("uses the captured inline session selection even if the editor selection changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 5 }, - ); - - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the selection", - ); - - expect(generation.status).toBe("complete"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("routes inline session continue prompts to block streaming suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " More detail" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Continue this paragraph", - ); - - expect(generation.target).toBe("selection"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(editor.getBlock(blockId)!.textContent()).toContain("Hello world"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("routes inline local-edit prompts to block streaming suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " Better version" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Make it better", - ); - - expect(generation.target).toBe("selection"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("uses the live collapsed caret offset for block generations", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " AI" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Continue this paragraph", { - target: "block", - blockId, - }); - - expect(generation.target).toBe("block"); - const suggestions = controller.getSuggestions(); - expect(suggestions.length).toBeGreaterThan(0); - expect(suggestions[0]).toMatchObject({ - blockId, - offset: 5, - }); - }); - - it("uses the selection end as the insertion offset for inline block turns", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " Better" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Make it better", - ); - - expect(generation.target).toBe("selection"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - const suggestions = controller.getSuggestions(); - expect(suggestions.length).toBeGreaterThan(0); - expect(suggestions[0]?.blockId).toBe(blockId); - }); - - it("creates reviewable cross-block inline edit suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "X" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply([ - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - editor.selectTextRange( - { blockId: firstBlockId, offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the selection", - ); - const nextSession = controller.getActiveSession(); - const turn = nextSession?.turns[0]; - - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - expect(turn?.selection?.isMultiBlock).toBe(true); - expect(turn?.status).toBe("review"); - expect(controller.acceptSessionTurn(session.id, turn!.id)).toBe(true); - expect(editor.getBlock(firstBlockId)?.textContent({ resolved: true })).toBe("HeXain"); - expect(editor.getBlock("b2")).toBeNull(); - expect(editor.getBlock("b3")).toBeNull(); - }); - - it("records progressive tool stream events for the active generation", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - pass += 1; - if (pass === 1) { - yield { - type: "tool-call" as const, - toolCallId: "tool-call-1", - toolName: "test_search", - input: { query: "plan" }, - }; - } - yield { type: "done" as const }; - }, - }, - }), - testStreamingToolExtension(), - ], - }); - const controller = getAIController(editor)!; - const blockId = editor.firstBlock()!.id; - - const generation = await controller.runPrompt("search the document", { blockId }); - const streamEvents = controller.getStreamEvents(); - const streamEventTypes = streamEvents.map((event) => event.type); - const toolOutputEvents = streamEvents.filter( - (event) => event.type === "tool-output", - ); - const toolResultEvent = streamEvents.find( - (event) => event.type === "tool-result", - ); - - expect(generation.status).toBe("complete"); - expect(streamEventTypes).toEqual([ - "generation-start", - "status", - "tool-call", - "status", - "tool-output", - "tool-output", - "tool-result", - "status", - "generation-finish", - ]); - expect(toolOutputEvents).toHaveLength(2); - expect(toolOutputEvents[0]).toMatchObject({ - toolCallId: "tool-call-1", - toolName: "test_search", - part: "searching:plan", - output: "searching:plan", - }); - expect(toolOutputEvents[1]).toMatchObject({ - toolCallId: "tool-call-1", - toolName: "test_search", - part: { matches: 2, query: "plan" }, - output: ["searching:plan", { matches: 2, query: "plan" }], - }); - expect(toolResultEvent).toMatchObject({ - type: "tool-result", - toolCallId: "tool-call-1", - toolName: "test_search", - output: ["searching:plan", { matches: 2, query: "plan" }], - state: "complete", - }); - }); - - it("streams block structured previews before a block plan finishes", async () => { - const releaseSecondDelta = createDeferred(); - let streamedBlockId = ""; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: - `{"kind":"block_convert","blockId":"${streamedBlockId}","newType":"heading"`, - }; - await releaseSecondDelta.promise; - yield { - type: "text-delta" as const, - delta: ',"props":{"level":2}}', - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - streamedBlockId = blockId; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generationPromise = controller.runPrompt("Convert block to heading", { - blockId, - }); - await waitForPreview( - () => controller.getState().activeGeneration?.structuredPreview, - ); - - const activeGeneration = controller.getState().activeGeneration; - const previewEventsBeforeCompletion = controller.getStreamEvents().filter( - (event) => event.type === "structured-preview", - ); - expect(activeGeneration?.structuredPreview).toMatchObject({ - planState: "drafted", - plan: { - kind: "block_convert", - blockId, - newType: "heading", - }, - }); - expect(activeGeneration?.structuredPreview?.reviewItems).toEqual([ - expect.objectContaining({ - label: "Convert block", - section: "block", - changeKind: "updated", - }), - ]); - expect(controller.getStreamEvents().some((event) => ( - event.type === "structured-preview" && - event.preview.plan.kind === "block_convert" - ))).toBe(true); - expect(previewEventsBeforeCompletion).toHaveLength(1); - expect(previewEventsBeforeCompletion[0]).toMatchObject({ - patches: [ - { op: "add", path: "/planState", value: "drafted" }, - { op: "add", path: "/plan", value: expect.any(Object) }, - { op: "add", path: "/reviewItems", value: expect.any(Array) }, - { op: "add", path: "/targets", value: [] }, - ], - }); - - releaseSecondDelta.resolve(); - const generation = await generationPromise; - const previewEventsAfterCompletion = controller.getStreamEvents().filter( - (event) => event.type === "structured-preview", - ); - const finalPreviewEvent = - previewEventsAfterCompletion[previewEventsAfterCompletion.length - 1]; - expect(generation.structuredPreview).toMatchObject({ - planState: "validated", - plan: { - kind: "block_convert", - blockId, - newType: "heading", - props: { level: 2 }, - }, - }); - expect(finalPreviewEvent).toMatchObject({ - patches: [ - { op: "replace", path: "/planState", value: "validated" }, - { op: "add", path: "/plan/props", value: {} }, - { op: "add", path: "/plan/props/level", value: 2 }, - ], - }); - expect( - finalPreviewEvent?.patches.some((patch) => patch.path === "/plan"), - ).toBe(false); - }); - - it("keeps selection rewrites text-only when markdown block generation is enabled", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { blockGeneration: "markdown" }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "# Planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Rewrite the selection"); - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("text"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world# Planet"); - expect(editor.documentState.blockOrder).toHaveLength(1); - }); - - it("routes context-first block edits into persistent suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " Updated" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Improve this paragraph", { blockId }); - const block = editor.getBlock(blockId)!; - - expect(generation.route).toBe("context-first"); - expect(generation.mutationMode).toBe("persistent-suggestions"); - expect(block.textContent()).toBe("Hello Updated"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("uses markdown block generation for bottom-chat document writing", async () => { - const releaseFinalDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - selectionRewrite: "text", - }, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "Once upon " }; - await releaseFinalDelta.promise; - yield { type: "text-delta" as const, delta: "a time" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generationPromise = controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - - await waitForPreview(() => { - const activeGeneration = controller.getState().activeGeneration; - const streamedVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - return ( - activeGeneration?.surface === "bottom-chat" && - activeGeneration.contentFormat === "markdown" && - streamedVisibleBlockTexts.includes("Once upon") - ); - }); - - const streamedVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); - expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); - expect(controller.getState().activeGeneration?.mutationMode).toBe( - "streaming-suggestions", - ); - expect(streamedVisibleBlockTexts).toEqual(["Hello", "Once upon"]); - expect(session.surface).toBe("bottom-chat"); - - releaseFinalDelta.resolve(); - const generation = await generationPromise; - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(generation.status).toBe("complete"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(generation.contentFormat).toBe("markdown"); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.blockClass).toBe("flow"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - expect(visibleBlockTexts).toEqual(["Hello", "Once upon a time"]); - }); - - it("streams bottom-chat markdown as block suggestions before completion", async () => { - const releaseFinalDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - selectionRewrite: "text", - }, - model: { - async *stream(options) { - yield { - type: "replace-preview" as const, - operation: options.operation!, - text: "\n\nOnce upon ", - }; - await releaseFinalDelta.promise; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "\n\nOnce upon a time", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generationPromise = controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - - await new Promise((resolve) => setTimeout(resolve, 80)); - - expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); - expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); - const visibleStreamingTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect( - (editor.getBlock(blockId)?.textContent({ resolved: true }) ?? "").replace( - /^\u200b/, - "", - ), - ).toBe(""); - expect(visibleStreamingTexts).toEqual(["Once upon"]); - - releaseFinalDelta.resolve(); - const generation = await generationPromise; - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("markdown"); - expect(generation.text).toBe("\n\nOnce upon a time"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - const visibleFinalTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleFinalTexts).toEqual(["Once upon a time"]); - const turnId = controller - .getState() - .sessions.find((item) => item.id === session.id) - ?.turns[0]?.id; - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - const keptTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(keptTexts).toEqual(["Once upon a time"]); - }); - - it("allows inline selection edits after keeping bottom-chat changes", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - selectionRewrite: "text", - }, - model: { - async *stream(options) { - pass += 1; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: pass === 1 ? "Hello world" : "planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const bottomChatSession = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - await controller.runSessionPrompt( - bottomChatSession.id, - "Write something in the document", - { target: "document" }, - ); - - const keptTurnId = controller - .getSessions() - .find((session) => session.id === bottomChatSession.id) - ?.turns[0]?.id; - expect(keptTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(bottomChatSession.id, keptTurnId!)).toBe(true); - - const blockId = editor.firstBlock()!.id; - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const inlineSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(inlineSession).not.toBeNull(); - - const generation = await controller.runSessionPrompt( - inlineSession!.id, - "Rewrite the selection", - { target: "selection" }, - ); - - expect(generation.target).toBe("selection"); - expect( - controller - .getSessions() - .find((session) => session.id === inlineSession!.id) - ?.turns, - ).toHaveLength(1); - }); - - it("treats whole-document rewrite prompts as explicit multi-block replace plans", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Founder's Last Email\n\nA startup story set in Amsterdam.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "The Lighthouse Keeper's Last Letter" }, - { - type: "insert-block", - blockId: "paragraph-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "paragraph-2", - offset: 0, - text: "The storm had been building for three days.", - }, - ], - { origin: "system" }, - ); - const originalBlockIds = [...editor.documentState.blockOrder]; - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the whole story. Make it about a startup from Amsterdam.", - { target: "document" }, - ); - - const activeSession = controller.getSessions().find((item) => item.id === session.id); - expect(activeSession?.operation?.kind).toBe("rewrite-selection"); - expect(activeSession?.operation?.target.kind).toBe("scoped-range"); - const documentTarget = - activeSession?.operation?.target.kind === "scoped-range" - ? activeSession.operation.target - : null; - expect(documentTarget?.blockIds).toEqual(originalBlockIds); - expect(documentTarget?.contentFormat).toBe("markdown"); - expect(documentTarget?.scope).toBe("document"); - expect(generation.status).toBe("complete"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = activeSession?.turns[0]?.id; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - - const finalVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(finalVisibleBlockTexts).toEqual([ - "The Founder's Last Email", - "A startup story set in Amsterdam.", - ]); - }); - - it("treats rewrite-the-story prompts as explicit multi-block replace plans", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Pharaoh's Last Scroll\n\nA cat story set in Egypt.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "The Founder's Last Email", - }, - { - type: "insert-block", - blockId: "paragraph-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "paragraph-2", - offset: 0, - text: "The Slack notification had been pinging for three days.", - }, - ], - { origin: "system" }, - ); - const originalBlockIds = [...editor.documentState.blockOrder]; - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the story. Make it about a cat from Egypt.", - { target: "document" }, - ); - - const activeSession = controller.getSessions().find((item) => item.id === session.id); - expect(activeSession?.operation?.kind).toBe("rewrite-selection"); - expect(activeSession?.operation?.target.kind).toBe("scoped-range"); - const documentTarget = - activeSession?.operation?.target.kind === "scoped-range" - ? activeSession.operation.target - : null; - expect(documentTarget?.blockIds).toEqual(originalBlockIds); - expect(documentTarget?.contentFormat).toBe("markdown"); - expect(documentTarget?.scope).toBe("document"); - expect(generation.status).toBe("complete"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = activeSession?.turns[0]?.id; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - - const finalVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(finalVisibleBlockTexts).toEqual([ - "The Pharaoh's Last Scroll", - "A cat story set in Egypt.", - ]); - }); - - it("carries bottom-chat history into follow-up title edits and replaces prior generated blocks", async () => { - const capturedPrompts: string[] = []; - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - streamCount += 1; - capturedPrompts.push( - options.messages - .map((message) => - typeof message.content === "string" - ? message.content - : JSON.stringify(message.content), - ) - .join("\n\n"), - ); - yield { - type: "replace-final" as const, - operation: options.operation!, - text: - streamCount === 1 - ? "# Salt and Shadow\n\nA lighthouse story." - : "# Amsterdam Sprint\n\nA startup story with a new title.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - await controller.runSessionPrompt(session.id, "Write a story", { - target: "document", - }); - - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt( - session.id, - "Also change the title.", - { target: "document" }, - ); - - expect(capturedPrompts[1]).toContain( - "Earlier user requests in this same session:", - ); - expect(capturedPrompts[1]).toContain("1. Write a story"); - expect(capturedPrompts[1]).toContain( - "Latest request:\nAlso change the title.", - ); - const activeSession = controller.getSessions().find((item) => item.id === session.id); - expect(activeSession?.operation?.kind).toBe("rewrite-selection"); - expect(activeSession?.operation?.target.kind).toBe("scoped-range"); - const documentTarget = - activeSession?.operation?.target.kind === "scoped-range" - ? activeSession.operation.target - : null; - expect(documentTarget?.scope).toBe("heading"); - expect(documentTarget?.contentFormat).toBe("markdown"); - expect(documentTarget?.blockIds).toHaveLength(1); - }); - - it("replaces the previous story after accepting a follow-up make-it-about rewrite", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - streamCount += 1; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: - streamCount === 1 - ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." - : "# The Cat Keeper's Last Purr\n\nA cat story.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - await controller.runSessionPrompt(session.id, "Write a story", { - target: "document", - }); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt(session.id, "Actually make it about cats", { - target: "document", - }); - const secondTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.id ?? null; - expect(secondTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); - - const finalVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(finalVisibleBlockTexts).toEqual([ - "The Cat Keeper's Last Purr", - "A cat story.", - ]); - }); - - it("restores the previous accepted story when undoing a kept follow-up rewrite", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - streamCount += 1; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: - streamCount === 1 - ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." - : "# The Cat Keeper's Last Purr\n\nA cat story.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - await controller.runSessionPrompt(session.id, "Write a story", { - target: "document", - }); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt(session.id, "Actually make it about cats", { - target: "document", - }); - const secondTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.id ?? null; - expect(secondTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); - - expect(editor.undoManager.undo()).toBe(true); - - const visibleBlockTextsAfterUndo = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleBlockTextsAfterUndo).toEqual([ - "The Lighthouse Keeper's Last Signal", - "A lighthouse story.", - ]); - }); - - it("trims leading blank lines when bottom-chat writes into an empty block", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "\n\nOnce upon a time", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(visibleBlockTexts).toEqual(["Once upon a time"]); - }); - - it("materializes bottom-chat paragraphs as separate blocks for empty targets", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "First paragraph.\n\nSecond paragraph.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write two paragraphs", - { target: "document" }, - ); - - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(visibleBlockTexts).toEqual([ - "First paragraph.", - "Second paragraph.", - ]); - }); - - it("reuses a leading empty placeholder for document-target bottom-chat writes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "Story opener.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const placeholderBlockId = editor.firstBlock()!.id; - const trailingBlockId = "trailing-block"; - editor.apply( - [ - { - type: "insert-block", - blockId: trailingBlockId, - blockType: "paragraph", - props: {}, - position: { after: placeholderBlockId }, - }, - { - type: "insert-text", - blockId: trailingBlockId, - offset: 0, - text: "Existing content", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - const blockOrder = editor.documentState.blockOrder; - const visibleBlockTexts = blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(blockOrder).toHaveLength(3); - expect(visibleBlockTexts).toEqual(["Story opener.", "Existing content"]); - expect(readBlockSuggestionMeta(editor.getBlock(placeholderBlockId))?.action).toBe( - "delete-block", - ); - }); - - it("prefers the caret block over unrelated empty placeholders for document-target writes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "Follow the caret.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const placeholderBlockId = editor.firstBlock()!.id; - const caretBlockId = "caret-block"; - editor.apply( - [ - { - type: "insert-block", - blockId: caretBlockId, - blockType: "paragraph", - props: {}, - position: { after: placeholderBlockId }, - }, - { - type: "insert-text", - blockId: caretBlockId, - offset: 0, - text: "Existing content", - }, - ], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId: caretBlockId, offset: 8 }, - { blockId: caretBlockId, offset: 8 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write more here", - { target: "document" }, - ); - const blockOrder = editor.documentState.blockOrder; - const visibleBlockTexts = blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(blockOrder).toHaveLength(3); - expect(visibleBlockTexts).toEqual(["Existing content", "Follow the caret."]); - }); - - it("creates tables through markdown for bottom-chat document prompts", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "| Tier | Price |\n| --- | --- |\n| Pro | $20 |", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const introBlockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Create a pricing table", - { target: "document" }, - ); - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("markdown"); - expect(generation.planState).toBe("none"); - expect(generation.reviewItems).toEqual([]); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.blockClass).toBe("flow"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableCell(0, 0)?.textContent()).toBe("Tier"); - expect(tables[0]?.tableCell(0, 1)?.textContent()).toBe("Price"); - expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Pro"); - expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("$20"); - expect(controller.acceptActiveGeneration()).toBe(true); - }); - - it("streams markdown table suggestions before completion for bottom-chat document prompts", async () => { - const releaseFinalDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: - "| First Name | Last Name |\n| --- | --- |\n| Alice | Johnson |", - }; - await releaseFinalDelta.promise; - yield { - type: "text-delta" as const, - delta: "\n| Bob | Smith |", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const introBlockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generationPromise = controller.runSessionPrompt( - session.id, - "Create a table with names in it", - { target: "document" }, - ); - - await waitForPreview(() => { - const tables = Array.from(editor.blocks("table")); - return tables[0]?.tableCell(1, 0)?.textContent() === "Alice"; - }); - - expect(controller.getState().activeGeneration?.adapterId).toBe("flow-markdown"); - expect(controller.getState().activeGeneration?.blockClass).toBe("flow"); - expect(controller.getState().activeGeneration?.transportKind).toBe("flow-text"); - expect(controller.getState().activeGeneration?.mutationMode).toBe( - "streaming-suggestions", - ); - const previewTables = Array.from(editor.blocks("table")); - expect(previewTables).toHaveLength(1); - expect(previewTables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); - expect(previewTables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); - - releaseFinalDelta.resolve(); - const generation = await generationPromise; - - expect(generation.planState).toBe("none"); - expect(generation.reviewItems).toEqual([]); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.blockClass).toBe("flow"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); - expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); - expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); - expect(tables[0]?.tableCell(2, 1)?.textContent()).toBe("Smith"); - }); - - it("builds rich preview details for newly inserted databases during direct bottom-chat apply", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "review_bundle", - label: "Create task database", - reason: "Insert and seed a task database.", - plans: [ - { - kind: "block_insert", - blockId: "task-db", - blockType: "database", - position: "last", - }, - { - kind: "database_edit", - blockId: "task-db", - steps: [ - { - op: "insert_row", - rowId: "row-1", - values: { - name: "Ship docs", - tags: "[\"docs\"]", - done: "false", - }, - }, - { - op: "add_view", - view: { - id: "view-list", - title: "List view", - type: "list", - visibleColumnIds: ["name", "tags"], - columnOrder: ["name", "tags", "done"], - }, - }, - ], - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Create a task database table with views", - { target: "document" }, - ); - - expect(generation.planState).toBe("validated"); - expect(generation.structuredPreview?.targets).toEqual([ - expect.objectContaining({ - blockId: "task-db", - targetKind: "database", - database: expect.objectContaining({ - columns: expect.arrayContaining([ - expect.objectContaining({ id: "name" }), - expect.objectContaining({ id: "tags" }), - expect.objectContaining({ id: "done" }), - ]), - rows: [ - expect.objectContaining({ - id: "row-1", - values: expect.objectContaining({ - name: "Ship docs", - }), - }), - ], - views: expect.arrayContaining([ - expect.objectContaining({ id: "view-table" }), - expect.objectContaining({ id: "view-list" }), - ]), - }), - }), - ]); - expect(generation.reviewItems).toEqual([]); - expect(editor.getBlock("task-db")?.type).toBe("database"); - }); - - it("replaces existing tables through markdown suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "| Name |", - "| --- |", - "| Alice |", - "| Bob |", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Add a row to this table", { - blockId: "table-1", - }); - - expect(generation.status).toBe("complete"); - expect(generation.targetKind).toBe("table"); - expect(generation.planState).toBe("none"); - expect(generation.plan).toBeNull(); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.reviewItems).toEqual([]); - expect(generation.debug?.structured).toMatchObject({ - plannerMode: "text", - targetKind: "table", - validationIssueCount: 0, - }); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - expect(editor.getBlock("table-1")?.tableRowCount()).toBe(initialRowCount); - }); - - it("accepts markdown table suggestions through the controller", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "| Name |", - "| --- |", - "| Alice |", - "| Bob |", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); - - const controller = getAIController(editor)!; - await controller.runPrompt("Add a row to this table", { - blockId: "table-1", - }); - - expect(controller.acceptActiveGeneration()).toBe(true); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableRowCount()).toBe(initialRowCount + 1); - expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); - expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); - expect(controller.getState().activeGeneration?.plan).toBeNull(); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - }); - - it("rejects markdown table suggestions without mutating the table", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "| Name |", - "| --- |", - "| Alice |", - "| Bob |", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); - - const controller = getAIController(editor)!; - await controller.runPrompt("Add a row to this table", { - blockId: "table-1", - }); - - expect(controller.rejectActiveGeneration()).toBe(true); - expect(editor.getBlock("table-1")!.tableRowCount()).toBe(initialRowCount); - expect(Array.from(editor.blocks("table"))).toHaveLength(1); - expect(controller.getState().activeGeneration?.plan).toBeNull(); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - expect(controller.getState().activeGeneration?.planState).toBe("rejected"); - }); - - it("applies XML flow patch plans through the markdown fast-apply path", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "", - "I am replacing the current table with an updated version.", - "adjacent-blocks", - "span:table-1", - "", - "replace_blocks", - "table-1", - "table", - "", - "", - "", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Add a role column to this table", { - blockId: "table-1", - }); - - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - - expect(controller.acceptActiveGeneration()).toBe(true); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableColumnCount()).toBe(2); - expect(tables[0]?.tableRowCount()).toBe(3); - expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Design"); - }); - - it("records flow patch alignment metrics in fast-apply debug state", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Bravo", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const controllerAny = controller as any; - controllerAny._state.activeGeneration = { - id: "test-generation", - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - - const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( - firstBlockId, - [ - "", - "I am inserting a new paragraph between Bravo and Charlie.", - "adjacent-blocks", - `span:${firstBlockId}`, - "", - "replace_blocks", - `${firstBlockId}`, - "block-2", - "block-3", - "", - "", - "", - ].join("\n"), - "persistent-suggestions", - undefined, - { - context: { - markdown: ["Alpha", "", "Bravo", "", "Charlie"].join("\n"), - markdownWindow: { - blockIds: [firstBlockId, "block-2", "block-3"], - }, - }, - }, - ); - - expect(mutationReceipt?.status).toBe("staged_suggestions"); - expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - alignment: { - preservedBlockCount: 3, - rewrittenBlockCount: 0, - unchangedBlockCount: 3, - insertedBlockCount: 1, - deletedBlockCount: 0, - estimatedOperationCost: 2, - }, - }); - }); - - it("records scoped replacement fallback metrics in fast-apply debug state", () => { - const editor = createEditor({ - extensions: [aiExtension({})], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const controllerAny = controller as any; - controllerAny._state.activeGeneration = { - id: "test-generation", - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - - const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( - firstBlockId, - [ - "", - "I am inserting a middle paragraph.", - "", - "", - "", - "", - "Bravo", - "", - "]]>", - "", - ].join("\n"), - "persistent-suggestions", - undefined, - { - context: { - markdown: ["Alpha", "", "Charlie"].join("\n"), - markdownWindow: { - blockIds: [firstBlockId, "block-2"], - }, - }, - }, - ); - - expect(mutationReceipt?.status).toBe("staged_suggestions"); - expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - fallback: { - kind: "scoped-replacement", - opsCount: 8, - insertedBlockCount: 3, - deletedBlockCount: 2, - targetBlockCount: 2, - }, - }); - }); - - it("records plain markdown fallback metrics when fast-apply falls back to block generation", () => { - const editor = createEditor({ - extensions: [aiExtension({ contentFormat: { blockGeneration: "markdown" } })], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const controllerAny = controller as any; - controllerAny._state.activeGeneration = { - id: "test-generation", - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - - const mutationReceipt = controllerAny._commitBufferedBlockGeneration( - firstBlockId, - "## Replacement title", - "persistent-suggestions", - "markdown", - undefined, - { - applyStrategy: "markdown-fast-apply", - workingSet: { - context: { - markdown: "Hello", - markdownWindow: { - blockIds: [firstBlockId], - }, - }, - }, - }, - ); - - expect(mutationReceipt?.status).toBe("staged_suggestions"); - expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ - attempted: true, - succeeded: false, - fallbackReason: "unparseable-contract", - executionPath: "plain-markdown", - fallback: { - kind: "plain-markdown", - opsCount: 2, - insertedBlockCount: 1, - deletedBlockCount: 0, - }, - }); - }); - - it("executes review-safe block convert plans through the existing suggestion path", async () => { - let blockId = ""; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "block_convert", - blockId, - newType: "heading", - props: { level: 2 }, - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Convert block to heading", { - blockId, - }); - const block = editor.getBlock(blockId)!; - - expect(generation.planState).toBe("validated"); - expect(generation.plan).toMatchObject({ - kind: "block_convert", - blockId, - newType: "heading", - }); - expect(block.type).toBe("heading"); - expect(block.meta("suggestion")).toMatchObject({ - action: "convert-block", - authorType: "ai", - }); - }); - - it("keeps the controller state snapshot stable for no-op updates", () => { - const editor = createEditor({ - extensions: [aiExtension()], - }); - - const controller = getAIController(editor)!; - const initialState = controller.getState(); - - controller.setSuggestMode(false); - expect(controller.getState()).toBe(initialState); - - controller.closeCommandMenu(); - expect(controller.getState()).toBe(initialState); - - controller.dismissEphemeralSuggestion(); - expect(controller.getState()).toBe(initialState); - }); - - it("builds database review items with before and after cell previews", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - - expect(generation.reviewItems).toEqual([ - expect.objectContaining({ - label: "Update cell", - changeKind: "updated", - section: "cell", - detail: "Alpha · Name", - before: "Alpha", - after: "Beta", - }), - ]); - }); - - it("keeps accepted structured review items in document undo history", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - const reviewItems = generation.reviewItems ?? []; - const reviewItemIds = reviewItems.map((item) => item.id); - - expect(generation.planState).toBe("validated"); - expect(reviewItems).toHaveLength(1); - expect(reviewItemIds).toHaveLength(1); - - expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Beta", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Alpha", - ); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Beta", - ); - }); - - it("treats structured review rejection as non-mutating UI state", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - const reviewItems = generation.reviewItems ?? []; - const reviewItemIds = reviewItems.map((item) => item.id); - - expect(reviewItemIds).toHaveLength(1); - expect(controller.rejectReviewItems(reviewItemIds)).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Alpha", - ); - expect(editor.undoManager.canUndo()).toBe(false); - expect(editor.undoManager.undo()).toBe(false); - expect(controller.getState().activeGeneration?.planState).toBe("rejected"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - }); - - it("keeps accepted structured review artifacts transient across history replay", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - const reviewItems = generation.reviewItems ?? []; - const reviewItemIds = reviewItems.map((item) => item.id); - - expect(reviewItemIds).toHaveLength(1); - expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Alpha", - ); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Beta", - ); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - }); - - it("builds comparison rows for database view changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "add_view", - view: { - id: "view-list", - title: "List view", - type: "list", - visibleColumnIds: ["name", "tags"], - columnOrder: ["name", "tags", "done"], - sort: [{ columnId: "name", direction: "asc" }], - filter: null, - groupBy: "tags", - pageIndex: 0, - pageSize: 50, - }, - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Add a grouped list view", { - blockId: "database-1", - }); - - expect(generation.reviewItems).toEqual([ - expect.objectContaining({ - label: "Add view", - comparisonRows: expect.arrayContaining([ - expect.objectContaining({ - label: "View", - after: "List view", - changeKind: "added", - section: "view", - }), - expect.objectContaining({ - label: "Group by", - after: "Tags", - changeKind: "updated", - section: "view", - }), - expect.objectContaining({ - label: "Visible columns", - after: "Name, Tags", - changeKind: "updated", - section: "view", - }), - ]), - }), - ]); - }); -}); +describe.skip("aiExtension split entrypoint", () => {}); diff --git a/packages/extensions/ai/src/__tests__/extension.testUtils.ts b/packages/extensions/ai/src/__tests__/extension.testUtils.ts new file mode 100644 index 0000000..ffb9588 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.testUtils.ts @@ -0,0 +1,53 @@ +import { defineExtension, type ToolRuntime } from "@pen/types"; + +export function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +export function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +export async function waitForPreview( + readPreview: () => unknown, + maxTicks = 10, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readPreview()) { + return; + } + await Promise.resolve(); + } +} diff --git a/packages/extensions/ai/src/controllers.ts b/packages/extensions/ai/src/controllers.ts new file mode 100644 index 0000000..b86be98 --- /dev/null +++ b/packages/extensions/ai/src/controllers.ts @@ -0,0 +1,75 @@ +import type { + AIInlineHistoryController, + AIInlineHistoryDirection, + AIReviewController, + PersistentSuggestion, +} from "./types"; + +export class AIInlineHistoryService implements AIInlineHistoryController { + constructor( + private readonly handlers: { + canUndoInlineHistory: () => boolean; + canRedoInlineHistory: () => boolean; + canHandleShortcut: (direction: AIInlineHistoryDirection) => boolean; + handleShortcut: (direction: AIInlineHistoryDirection) => boolean; + undoInlineHistory: () => boolean; + redoInlineHistory: () => boolean; + }, + ) {} + + canUndoInlineHistory(): boolean { + return this.handlers.canUndoInlineHistory(); + } + + canRedoInlineHistory(): boolean { + return this.handlers.canRedoInlineHistory(); + } + + canHandleShortcut(direction: AIInlineHistoryDirection): boolean { + return this.handlers.canHandleShortcut(direction); + } + + handleShortcut(direction: AIInlineHistoryDirection): boolean { + return this.handlers.handleShortcut(direction); + } + + undoInlineHistory(): boolean { + return this.handlers.undoInlineHistory(); + } + + redoInlineHistory(): boolean { + return this.handlers.redoInlineHistory(); + } +} + +export class AIReviewService implements AIReviewController { + constructor( + private readonly handlers: { + getSuggestions: () => readonly PersistentSuggestion[]; + acceptSuggestion: (id: string) => boolean; + rejectSuggestion: (id: string) => boolean; + acceptAllSuggestions: () => void; + rejectAllSuggestions: () => void; + }, + ) {} + + getSuggestions(): readonly PersistentSuggestion[] { + return this.handlers.getSuggestions(); + } + + acceptSuggestion(id: string): boolean { + return this.handlers.acceptSuggestion(id); + } + + rejectSuggestion(id: string): boolean { + return this.handlers.rejectSuggestion(id); + } + + acceptAllSuggestions(): void { + this.handlers.acceptAllSuggestions(); + } + + rejectAllSuggestions(): void { + this.handlers.rejectAllSuggestions(); + } +} diff --git a/packages/extensions/ai/src/extension.ts b/packages/extensions/ai/src/extension.ts index cdf918b..03b095d 100644 --- a/packages/extensions/ai/src/extension.ts +++ b/packages/extensions/ai/src/extension.ts @@ -3,26 +3,11 @@ import { ensureInlineCompletionController, getInlineCompletionController as getInlineCompletionControllerFromCore, } from "@pen/core"; -import { - buildDocumentWriteOps, - getDocumentToolRuntime, -} from "@pen/document-ops"; import type { - Decoration, - DocumentOp, Editor, Extension, - HistoryAppliedEvent, KeyBinding, ModelAdapter, - ModelOperationScopedRangeTarget, - ModelOperationSelectionTarget, - OpOrigin, - SelectionState, - StreamingTarget, - TextSelection, - ToolDefinition, - ToolRuntime, UndoHistoryMetadataController, } from "@pen/types"; import { @@ -33,69 +18,13 @@ import { INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, defineExtension, getOpOriginType, - isScopedSelectionTarget, - renderSelectionTargetBlockText, - resolveSelectionTargetBlockIds, - shouldExposeBlockInTooling, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, - usesInlineTextSelection, } from "@pen/types"; -import { runAgenticLoop } from "./agentic/loop"; import { defaultAICommands } from "./commands/defaultCommands"; import { AICommandRegistry } from "./commands/registry"; -import { buildAffectedRangeDecorations } from "./decorations/affectedRange"; -import { buildGenerationZoneDecorations } from "./decorations/generationZone"; -import { buildTrackChangesDecorations } from "./decorations/trackChanges"; -import { getBlockAdapter } from "./runtime/blockAdapters"; -import type { - AIApplyStrategy, - AIContentFormat, - AITargetKind, -} from "./runtime/contracts"; -import { resolveDocumentInsertionAnchor } from "./runtime/documentInsertionAnchor"; -import { - MARKDOWN_FAST_APPLY_ROOT_TAG, - normalizeFlowMarkdownOutput, -} from "./runtime/flowMarkdown"; -import { - applyMarkdownFastApply, - parseMarkdownFastApplyContract, -} from "./runtime/markdownFastApply"; -import { parseMarkdownPatchPlanContract } from "./runtime/markdownPatchPlan"; -import { buildMutationReceipt } from "./runtime/mutationReceipt"; -import { buildDocumentMutationPlanExecution } from "./runtime/planExecutor"; -import { validateDocumentMutationPlanShape } from "./runtime/planValidation"; -import type { StructuralReviewItem } from "./runtime/reviewArtifacts"; -import { - buildStructuralReviewItems, - removeStructuralReviewItemPlan, - selectStructuralReviewItemPlan, -} from "./runtime/reviewArtifacts"; -import { - classifyPromptIntent, - refineRouteWithNavigator, - routeAIRequest, -} from "./runtime/router"; -import { compileStructuredIntentToPlan } from "./runtime/structuredIntentCompiler"; -import { - buildPlannerPrompt, - parseStructuredPlanPreview, - parseStructuredPlanResult, - resolveExecutionMode, -} from "./runtime/structuredPlanner"; -import { - buildGenerationStructuredPreviewState, - buildStructuredPreviewPatchOperations, -} from "./runtime/structuredPreview"; -import { - acceptAllSuggestions, - acceptSuggestion, - acceptSuggestions, - rejectAllSuggestions, - rejectSuggestion, - rejectSuggestions, -} from "./suggestions/acceptReject"; -import { readAllSuggestions } from "./suggestions/persistent"; +import { AIInlineHistoryService, AIReviewService } from "./controllers"; +import type { AIContentFormat } from "./runtime/contracts"; +import { SuggestedAIOperationRunner } from "./runtime/suggestedOperationRunner"; import { AI_SESSION_SUGGESTION_ORIGIN, interceptApplyForSuggestMode, @@ -114,32 +43,39 @@ import type { AIInlineHistoryController, AIInlineHistoryDirection, AIInlineHistorySnapshot, - AIMutationReceipt, AIReviewController, - AIRequestedOperation, - AISession, - AISessionMetrics, - AISessionResolution, - AISessionSelectionSnapshot, - AISessionTarget, AIStreamEvent, - AISurface, - AIWorkingSetEnvelope, - AIWorkingSetRetrievedSpan, - FastApplyDebugState, - GenerationState, - GenerationStructuredPreviewState, - PersistentTextSuggestion, PersistentSuggestion, - ResolvedEditProposal, - ResolvedEditTarget, } from "./types"; +import { aiControllerMethodsPart1 } from "./extensionParts/aiControllerMethodsPart1"; +import { aiControllerMethodsPart2 } from "./extensionParts/aiControllerMethodsPart2"; +import { aiControllerMethodsPart3 } from "./extensionParts/aiControllerMethodsPart3"; +import { aiControllerMethodsPart4 } from "./extensionParts/aiControllerMethodsPart4"; +import { aiControllerMethodsPart5 } from "./extensionParts/aiControllerMethodsPart5"; +import { aiControllerMethodsPart6 } from "./extensionParts/aiControllerMethodsPart6"; +import { aiControllerMethodsPart7 } from "./extensionParts/aiControllerMethodsPart7"; +import { aiControllerMethodsPart8 } from "./extensionParts/aiControllerMethodsPart8"; +import { aiControllerMethodsPart9 } from "./extensionParts/aiControllerMethodsPart9"; +import { aiControllerMethodsPart10 } from "./extensionParts/aiControllerMethodsPart10"; +import { aiControllerMethodsPart11 } from "./extensionParts/aiControllerMethodsPart11"; +import { aiControllerMethodsPart12 } from "./extensionParts/aiControllerMethodsPart12"; +import { aiControllerMethodsPart13 } from "./extensionParts/aiControllerMethodsPart13"; +import { aiControllerMethodsPart14 } from "./extensionParts/aiControllerMethodsPart14"; +import { aiControllerMethodsPart15 } from "./extensionParts/aiControllerMethodsPart15"; +import { aiControllerMethodsPart16 } from "./extensionParts/aiControllerMethodsPart16"; +import { AI_UNDO_HISTORY_METADATA_KEY, createDefaultSessionFastApplyMetrics, readModelId } from "./extensionParts/extensionHelpers"; +import type { AIInlineHistoryRestoreRequest } from "./extensionParts/extensionHelpers"; export const AI_EXTENSION_NAME = "ai"; + export const AI_CONTROLLER_SLOT = CORE_AI_CONTROLLER_SLOT; + export const INLINE_COMPLETION_SLOT = CORE_INLINE_COMPLETION_SLOT; + export const AI_INLINE_COMPLETION_SLOT = INLINE_COMPLETION_SLOT; + export const AI_INLINE_HISTORY_SLOT = CORE_AI_INLINE_HISTORY_SLOT; + export const AI_REVIEW_CONTROLLER_SLOT = CORE_AI_REVIEW_CONTROLLER_SLOT; const AI_SHORTCUT_KEY_BINDINGS: readonly KeyBinding[] = [ @@ -181,8755 +117,325 @@ const AI_SHORTCUT_KEY_BINDINGS: readonly KeyBinding[] = [ }, ]; -type GenerationTarget = - | { - type: "block"; - blockId: string; - offset: number; - } - | { - type: "selection"; - selection: TextSelection; - }; - -interface GenerationExecutionContext { - sessionId?: string; - surface?: AISurface; - targetType?: GenerationTarget["type"]; - operation?: AIRequestedOperation | null; - replaceTargetBlock?: boolean; - replaceBlockIds?: string[]; -} - -function resolveGenerationRequestMode( - context?: GenerationExecutionContext, -): string | undefined { - if (context?.operation?.kind === "rewrite-selection") { - if (context.surface === "inline-edit") { - return "inline-edit"; - } - if (context.surface === "bottom-chat") { - return "selection-fast"; - } - } - if (context?.targetType === "selection") { - if (context.surface === "inline-edit") { - return "inline-edit"; - } - if (context.surface === "bottom-chat") { - return "selection-fast"; - } - } - if (context?.surface === "inline-edit") { - return "inline-edit"; - } - if (context?.surface === "bottom-chat") { - return "bottom-chat"; - } - return undefined; -} - -function isLocalRequestedOperation( - operation: AIRequestedOperation | null | undefined, -): operation is AIRequestedOperation { - return ( - operation?.kind === "rewrite-selection" || - operation?.kind === "rewrite-block" || - operation?.kind === "continue-block" || - (operation?.kind === "document-transform" && - operation.target.kind === "document" && - (operation.target.transform === "rewrite" || - operation.target.transform === "remove" || - operation.target.placement === "replace-blocks")) - ); -} - -const EMPTY_TOOL_RUNTIME: ToolRuntime = { - registerTool(_def: ToolDefinition): void {}, - unregisterTool(_name: string): void {}, - listTools(): readonly ToolDefinition[] { - return []; - }, - getTool(): ToolDefinition | null { - return null; - }, - async executeTool(name: string): Promise { - throw new Error(`Unknown tool: "${name}"`); - }, -}; - -const MAX_STREAM_EVENTS = 200; -const AI_UNDO_HISTORY_METADATA_KEY = "ai:inline-session-history"; - -interface AIInlineHistoryRestoreRequest { - direction: AIInlineHistoryDirection; - targetSnapshotId: string; - targetDocumentVersion: number; - shortcutOnly?: boolean; - sessionId?: string | null; - targetState?: AIInlineShortcutHistoryState | null; -} - -type AIInlineShortcutHistoryPhase = "none" | "review" | "resolved"; - -interface AIInlineShortcutHistoryState { - sessionId: string | null; - phase: AIInlineShortcutHistoryPhase; - turnCount: number; - turnId: string | null; - resolution?: "accepted" | "rejected"; -} - -interface AIInlineShortcutHistoryWaypoint { - startIndex: number; - endIndex: number; - representativeIndex: number; - state: AIInlineShortcutHistoryState; -} - -class AIInlineHistoryService implements AIInlineHistoryController { - constructor( - private readonly _handlers: { - canUndoInlineHistory: () => boolean; - canRedoInlineHistory: () => boolean; - canHandleShortcut: (direction: AIInlineHistoryDirection) => boolean; - handleShortcut: (direction: AIInlineHistoryDirection) => boolean; - undoInlineHistory: () => boolean; - redoInlineHistory: () => boolean; - }, - ) {} - - canUndoInlineHistory(): boolean { - return this._handlers.canUndoInlineHistory(); - } - - canRedoInlineHistory(): boolean { - return this._handlers.canRedoInlineHistory(); - } - - canHandleShortcut(direction: AIInlineHistoryDirection): boolean { - return this._handlers.canHandleShortcut(direction); - } - - handleShortcut(direction: AIInlineHistoryDirection): boolean { - return this._handlers.handleShortcut(direction); - } +class AIControllerImpl { + private readonly _editor: Editor; - undoInlineHistory(): boolean { - return this._handlers.undoInlineHistory(); - } + private readonly _registry = new AICommandRegistry(); - redoInlineHistory(): boolean { - return this._handlers.redoInlineHistory(); - } -} + private readonly _inlineCompletion: AIInlineCompletionController; -class AIReviewService implements AIReviewController { - constructor( - private readonly _handlers: { - getSuggestions: () => readonly PersistentSuggestion[]; - acceptSuggestion: (id: string) => boolean; - rejectSuggestion: (id: string) => boolean; - acceptAllSuggestions: () => void; - rejectAllSuggestions: () => void; - }, - ) {} + private readonly _listeners = new Set<() => void>(); - getSuggestions(): readonly PersistentSuggestion[] { - return this._handlers.getSuggestions(); - } + private readonly _sessionListeners = new Set<() => void>(); - acceptSuggestion(id: string): boolean { - return this._handlers.acceptSuggestion(id); - } + private readonly _streamEventListeners = new Set<() => void>(); - rejectSuggestion(id: string): boolean { - return this._handlers.rejectSuggestion(id); - } + private readonly _model: ModelAdapter | undefined; - acceptAllSuggestions(): void { - this._handlers.acceptAllSuggestions(); - } + private readonly _author: string; - rejectAllSuggestions(): void { - this._handlers.rejectAllSuggestions(); - } -} + private readonly _suggestedOperationRunner: SuggestedAIOperationRunner; -class AIControllerImpl implements AIController { - private readonly _editor: Editor; - private readonly _registry = new AICommandRegistry(); - private readonly _inlineCompletion: AIInlineCompletionController; - private readonly _listeners = new Set<() => void>(); - private readonly _sessionListeners = new Set<() => void>(); - private readonly _streamEventListeners = new Set<() => void>(); - private readonly _model: ModelAdapter | undefined; - private readonly _author: string; private readonly _maxAgenticSteps: number; + private readonly _contentFormat: { - blockGeneration: AIContentFormat; - selectionRewrite: AIContentFormat; - }; + blockGeneration: AIContentFormat; + selectionRewrite: AIContentFormat; + }; + private _state: AIControllerState; + private _suggestions: readonly PersistentSuggestion[] = []; + private _streamEvents: readonly AIStreamEvent[] = []; + private _abortController: AbortController | null = null; + private _lastPrompt: string | null = null; + private _lastCommandId: string | null = null; + private _documentVersion = 0; + private _unsubscribeHistoryApplied: (() => void) | null = null; + private _unsubscribeInlineCompletion: (() => void) | null = null; + private _unsubscribeUndoHistoryMetadata: (() => void) | null = null; + private readonly _undoHistoryMetadata: UndoHistoryMetadataController | null; + private _inlineHistory: AIInlineHistorySnapshot[] = []; + private _inlineHistoryIndex = -1; + private _pendingInlineHistoryRestore: AIInlineHistoryRestoreRequest | null = - null; + null; + private _queuedInlineHistoryShortcutDirections: AIInlineHistoryDirection[] = - []; + []; + private _queuedInlineHistoryShortcutFlushScheduled = false; + private _isRestoringInlineHistory = false; + private _handledUndoHistoryRequestId: number | null = null; constructor( - editor: Editor, - config: AIExtensionConfig, - services: { - inlineCompletion: AIInlineCompletionController; - }, - ) { - this._editor = editor; - this._inlineCompletion = services.inlineCompletion; - this._model = config.model; - this._author = config.author ?? "assistant"; - this._maxAgenticSteps = config.maxAgenticSteps ?? 10; - this._contentFormat = { - blockGeneration: config.contentFormat?.blockGeneration ?? "text", - selectionRewrite: config.contentFormat?.selectionRewrite ?? "text", - }; - this._undoHistoryMetadata = - this._editor.internals.getSlot( - UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, - ) ?? null; - this._state = { - status: "idle", - activeGeneration: null, - sessions: [], - activeSessionId: null, - suggestMode: config.suggestMode ?? false, - ephemeralSuggestion: null, - commandMenuOpen: false, - }; - - for (const command of defaultAICommands) { - this._registry.register(command); - } - for (const command of config.commands ?? []) { - this._registry.register(command); - } - - this._syncSuggestionsFromDocument(); - - this._unsubscribeInlineCompletion = this._inlineCompletion.subscribe( - () => { - this._setState({ - ephemeralSuggestion: - this._inlineCompletion.getState().visibleSuggestion, - }); - }, - ); - this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( - (event) => { - this._handleHistoryApplied(event); - }, - ); - this._unsubscribeUndoHistoryMetadata = - this._undoHistoryMetadata?.registerMetadataRestorer( - AI_UNDO_HISTORY_METADATA_KEY, - (snapshot, context) => { - if (!snapshot) { - return; - } - this._handledUndoHistoryRequestId = context.requestId; - this._restoreInlineHistorySnapshotFromUndo(snapshot); - }, - ) ?? null; - } - - destroy(): void { - this._unsubscribeInlineCompletion?.(); - this._unsubscribeInlineCompletion = null; - this._unsubscribeHistoryApplied?.(); - this._unsubscribeHistoryApplied = null; - this._unsubscribeUndoHistoryMetadata?.(); - this._unsubscribeUndoHistoryMetadata = null; - } - - getState(): AIControllerState { - return this._state; - } - - subscribe(listener: () => void): () => void { - this._listeners.add(listener); - return () => this._listeners.delete(listener); - } - - getSessions(): readonly AISession[] { - return this._state.sessions; - } - - getActiveSession(): AISession | null { - const activeSessionId = this._state.activeSessionId; - if (!activeSessionId) { - return null; - } - return ( - this._state.sessions.find( - (session) => session.id === activeSessionId, - ) ?? null - ); - } - - subscribeSessions(listener: () => void): () => void { - this._sessionListeners.add(listener); - return () => this._sessionListeners.delete(listener); - } - - getStreamEvents(): readonly AIStreamEvent[] { - return this._streamEvents; - } - - subscribeStreamEvents(listener: () => void): () => void { - this._streamEventListeners.add(listener); - return () => this._streamEventListeners.delete(listener); - } - - getCommands(): readonly AICommandBinding[] { - return this._registry.list(this.getCommandContext()); - } - - getCommandContext(): AICommandContext { - const selection = this._editor.selection; - const blockId = resolveActiveBlockId(selection); - return { - editor: this._editor, - selection, - selectedText: - selection?.type === "text" - ? resolveSelectionText(this._editor, selection) - : "", - blockType: blockId - ? (this._editor.getBlock(blockId)?.type ?? null) - : null, - blockId, - }; - } - - startSession(input: { - surface: AISurface; - target?: "auto" | "selection" | "block" | "document"; - }): AISession { - const now = Date.now(); - const target = resolveSessionTarget(this._editor, input.target); - const session: AISession = { - id: crypto.randomUUID(), - surface: input.surface, - status: "idle", - target, - contextualPrompt: - input.surface === "inline-edit" - ? resolveContextualPromptState(target) - : undefined, - turns: [], - activeTurnId: undefined, - promptHistory: [], - generationIds: [], - pendingSuggestionIds: [], - pendingReviewItemIds: [], - createdAt: now, - updatedAt: now, - metrics: { - streamEventCount: 0, - patchCount: 0, - fastApply: createDefaultSessionFastApplyMetrics(), + editor: Editor, + config: AIExtensionConfig, + services: { + inlineCompletion: AIInlineCompletionController; }, - anchor: resolveSessionAnchor(this._editor.selection), - }; - this._setState({ - sessions: [...this._state.sessions, session], - activeSessionId: session.id, - }); - return session; - } - - openContextualPrompt(input?: { - surface?: Extract; - target?: "auto" | "selection" | "block" | "document"; - }): AISession | null { - const surface = input?.surface ?? "inline-edit"; - const target = resolveSessionTarget( - this._editor, - input?.target ?? "selection", - ); - if (surface === "inline-edit" && target.kind !== "selection") { - return null; - } - const activeSession = this._state.sessions.find( - (session) => - session.id === this._state.activeSessionId && - session.surface === surface && - session.status !== "cancelled", - ); - if ( - activeSession && - activeSession.status !== "complete" && - sessionTargetMatches(activeSession, target) ) { - this._updateSession(activeSession.id, { - target, - anchor: resolveSessionAnchor(this._editor.selection), - contextualPrompt: { - ...(activeSession.contextualPrompt ?? - resolveContextualPromptState(target)), - anchor: resolveContextualPromptAnchor(target), - composer: { - ...(activeSession.contextualPrompt?.composer ?? { - draftPrompt: "", - isSubmitting: false, - canSubmitFollowUp: true, - openReason: "user", - }), - isOpen: true, - openReason: "user", - }, - }, + this._editor = editor; + this._inlineCompletion = services.inlineCompletion; + this._model = config.model; + this._author = config.author ?? "assistant"; + this._suggestedOperationRunner = new SuggestedAIOperationRunner({ + editor: this._editor, + author: this._author, + model: readModelId(this._model), + getSession: (sessionId) => + this._state.sessions.find((session) => session.id === sessionId) ?? + null, + getActiveGeneration: () => this._state.activeGeneration, }); - return this.getActiveSession(); - } - if (activeSession?.surface === "inline-edit") { - this._setInlineSessionComposerOpen(activeSession.id, false); - } - const nextSession = this.startSession({ - surface, - target: input?.target ?? "selection", - }); - return nextSession.contextualPrompt?.anchor.kind === "text-range" - ? nextSession - : null; - } - - updateContextualPromptDraft(sessionId: string, draftPrompt: string): void { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session?.contextualPrompt) { - return; - } - this._updateSession(sessionId, { - contextualPrompt: { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - draftPrompt, - }, - }, - }); - } - - setContextualPromptAnchorRect( - sessionId: string, - rect: AIContextualPromptRect | null, - ): void { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session?.contextualPrompt) { - return; - } - this._updateSession(sessionId, { - contextualPrompt: { - ...session.contextualPrompt, - anchor: { - ...session.contextualPrompt.anchor, - lastResolvedRect: rect, - }, - }, - }); - } - - resolveSessionTurn( - sessionId: string, - turnId: string, - resolution: AISessionResolution, - ): boolean { - return this._resolveSessionTurn(sessionId, turnId, resolution); - } - - acceptSessionTurn(sessionId: string, turnId: string): boolean { - return this.resolveSessionTurn(sessionId, turnId, "accept"); - } + this._maxAgenticSteps = config.maxAgenticSteps ?? 10; + this._contentFormat = { + blockGeneration: config.contentFormat?.blockGeneration ?? "text", + selectionRewrite: config.contentFormat?.selectionRewrite ?? "text", + }; + this._undoHistoryMetadata = + this._editor.internals.getSlot( + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + ) ?? null; + this._state = { + status: "idle", + activeGeneration: null, + sessions: [], + activeSessionId: null, + suggestMode: config.suggestMode ?? false, + ephemeralSuggestion: null, + commandMenuOpen: false, + }; - rejectSessionTurn(sessionId: string, turnId: string): boolean { - return this.resolveSessionTurn(sessionId, turnId, "reject"); - } + for (const command of defaultAICommands) { + this._registry.register(command); + } + for (const command of config.commands ?? []) { + this._registry.register(command); + } - runSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): Promise { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session) { - return Promise.reject( - new Error(`Unknown AI session "${sessionId}"`), - ); - } - this._recordInlinePromptSubmissionCheckpoint(sessionId, prompt); + this._syncSuggestionsFromDocument(); - const operation = - options?.operation ?? - resolveRequestedOperationForSession( - this._editor, - session, - prompt, - options, - this._documentVersion, - ); - if (operation.kind === "rewrite-selection") { - const selection = resolveSelectionForRequestedOperation( - this._editor, - operation, - ); - if (!selection) { - return Promise.reject( - new Error( - "Cannot run a session prompt without a valid text selection", - ), - ); - } - return this._runSelectionGeneration( - prompt, - selection, - undefined, - options?.maxSteps, - { - sessionId, - surface: session.surface, - operation, + this._unsubscribeInlineCompletion = this._inlineCompletion.subscribe( + () => { + this._setState({ + ephemeralSuggestion: + this._inlineCompletion.getState().visibleSuggestion, + }); }, ); - } - if (operation.kind === "document-transform") { - const targetBlockIds = - operation.target.kind === "document" && - (operation.target.blockIds?.length ?? 0) > 0 - ? [...(operation.target.blockIds ?? [])] - : undefined; - const replacePreviousGeneratedBlocks = - shouldReplacePreviousGeneratedBlocks(session, prompt); - return this._runDocumentGeneration( - prompt, - options?.blockId ?? - (operation.target.kind === "document" - ? operation.target.activeBlockId - : null), - undefined, - options?.maxSteps, - { - sessionId, - surface: session.surface, - operation, - replaceBlockIds: - targetBlockIds ?? - (replacePreviousGeneratedBlocks - ? resolvePreviousGeneratedBlockIds(session) - : undefined), + this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( + (event) => { + this._handleHistoryApplied(event); }, ); + this._unsubscribeUndoHistoryMetadata = + this._undoHistoryMetadata?.registerMetadataRestorer( + AI_UNDO_HISTORY_METADATA_KEY, + (snapshot, context) => { + if (!snapshot) { + return; + } + this._handledUndoHistoryRequestId = context.requestId; + this._restoreInlineHistorySnapshotFromUndo(snapshot); + }, + ) ?? null; } - const blockId = - options?.blockId ?? - resolveBlockIdForRequestedOperation(operation) ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!blockId) { - return Promise.reject( - new Error( - "Cannot run an AI session prompt without a target block", - ), - ); - } - return this._runBlockGeneration( - prompt, - blockId, - undefined, - options?.maxSteps, - { - sessionId, - surface: session.surface, - operation, - }, - ); - } - - canReuseSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): boolean { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session) { - return false; - } - if (session.surface !== "bottom-chat" || !session.operation) { - return true; - } - const nextOperation = - options?.operation ?? - resolveRequestedOperationForSession( - this._editor, - session, - prompt, - options, - this._documentVersion, - ); - return canReuseBottomChatSessionOperation( - session.operation, - nextOperation, - ); - } - - resolveSession( - sessionId: string, - resolution: AISessionResolution, - ): boolean { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session) { - return false; - } - let resolved = false; - for (const turn of session.turns) { - resolved = - this._resolveSessionTurn(sessionId, turn.id, resolution, { - finalizeSession: false, - }) || resolved; - } - if (resolved) { - const nextSession = - this._state.sessions.find((item) => item.id === sessionId) ?? - session; - this._updateSession(sessionId, { - status: "complete", - pendingSuggestionIds: [], - pendingReviewItemIds: [], - contextualPrompt: closeInlineSessionPrompt(nextSession), - }); - } - return resolved; - } - - acceptSession(sessionId: string): boolean { - return this.resolveSession(sessionId, "accept"); - } - - rejectSession(sessionId: string): boolean { - return this.resolveSession(sessionId, "reject"); - } - - cancelSession(sessionId: string): void { - if (this._state.activeGeneration?.sessionId === sessionId) { - this.cancelActiveGeneration(); - } - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - this._updateSession(sessionId, { - status: "cancelled", - contextualPrompt: session?.contextualPrompt - ? { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: false, - isSubmitting: false, - }, - } - : undefined, - }); - } - - suspendInlineSession(sessionId: string): void { - this._setInlineSessionComposerOpen(sessionId, false); - } - - resumeInlineSession(sessionId: string): void { - this._setInlineSessionComposerOpen(sessionId, true, { - openReason: "user", - }); - } - - canUndoInlineHistory(): boolean { - return this._inlineHistoryIndex > 0; - } - - canRedoInlineHistory(): boolean { - return ( - this._inlineHistoryIndex >= 0 && - this._inlineHistoryIndex < this._inlineHistory.length - 1 - ); - } +} - undoInlineHistory(): boolean { - return this._navigateInlineHistory("undo"); - } +interface AIControllerImpl extends AIController { + [key: string]: any; +} - redoInlineHistory(): boolean { - return this._navigateInlineHistory("redo"); - } +Object.assign( + AIControllerImpl.prototype, + aiControllerMethodsPart1, + aiControllerMethodsPart2, + aiControllerMethodsPart3, + aiControllerMethodsPart4, + aiControllerMethodsPart5, + aiControllerMethodsPart6, + aiControllerMethodsPart7, + aiControllerMethodsPart8, + aiControllerMethodsPart9, + aiControllerMethodsPart10, + aiControllerMethodsPart11, + aiControllerMethodsPart12, + aiControllerMethodsPart13, + aiControllerMethodsPart14, + aiControllerMethodsPart15, + aiControllerMethodsPart16, +); - canHandleInlineHistoryShortcut( - direction: AIInlineHistoryDirection, - ): boolean { - if (this._pendingInlineHistoryRestore) { - return true; - } - return this._canHandleInlineHistoryShortcut(direction, { - shortcutOnly: true, - }); - } +export function aiExtension(config: AIExtensionConfig = {}): Extension { + let unsubscribeBeforeApply: (() => void) | null = null; + let unsubscribeTrackedOrigins: (() => void) | null = null; + let controller: AIControllerImpl | null = null; + let inlineCompletion: AIInlineCompletionController | null = null; + let releaseInlineCompletion: (() => void) | null = null; + let inlineHistory: AIInlineHistoryService | null = null; + let reviewController: AIReviewService | null = null; + let activeEditor: Editor | null = null; - handleInlineHistoryShortcut(direction: AIInlineHistoryDirection): boolean { - if (this._pendingInlineHistoryRestore) { - this._queuedInlineHistoryShortcutDirections.push(direction); - return true; - } - return this._navigateInlineHistory(direction, { shortcutOnly: true }); - } + return defineExtension({ + name: AI_EXTENSION_NAME, + dependencies: ["document-ops", "delta-stream", "undo"], + keyBindings: AI_SHORTCUT_KEY_BINDINGS, - async runCommand( - commandId: string, - options?: AICommandExecutionOptions, - ): Promise { - const ctx = this.getCommandContext(); - const command = this._registry.resolve(commandId); - if (!command) { - throw new Error(`Unknown AI command "${commandId}"`); - } - if (command.guard && !command.guard(ctx)) { - throw new Error( - `AI command "${command.label}" is not available in this context`, + activateClient: async ({ editor }) => { + activeEditor = editor; + const inlineCompletionRegistration = + ensureInlineCompletionController(editor); + inlineCompletion = inlineCompletionRegistration.controller; + releaseInlineCompletion = inlineCompletionRegistration.release; + controller = new AIControllerImpl(editor, config, { + inlineCompletion, + }); + inlineHistory = new AIInlineHistoryService({ + canUndoInlineHistory: () => + controller ? controller.canUndoInlineHistory() : false, + canRedoInlineHistory: () => + controller ? controller.canRedoInlineHistory() : false, + canHandleShortcut: (direction) => + controller + ? controller.canHandleInlineHistoryShortcut(direction) + : false, + handleShortcut: (direction) => + controller + ? controller.handleInlineHistoryShortcut(direction) + : false, + undoInlineHistory: () => + controller ? controller.undoInlineHistory() : false, + redoInlineHistory: () => + controller ? controller.redoInlineHistory() : false, + }); + reviewController = new AIReviewService({ + getSuggestions: () => controller?.getSuggestions() ?? [], + acceptSuggestion: (id) => + controller?.acceptSuggestion(id) ?? false, + rejectSuggestion: (id) => + controller?.rejectSuggestion(id) ?? false, + acceptAllSuggestions: () => controller?.acceptAllSuggestions(), + rejectAllSuggestions: () => controller?.rejectAllSuggestions(), + }); + editor.internals.setSlot(AI_CONTROLLER_SLOT, controller); + editor.internals.setSlot(AI_INLINE_HISTORY_SLOT, inlineHistory); + editor.internals.setSlot( + AI_REVIEW_CONTROLLER_SLOT, + reviewController, ); - } - - const prompt = this._registry.resolvePrompt(command, ctx); - this._lastPrompt = prompt; - this._lastCommandId = command.id; + unsubscribeTrackedOrigins = + editor.undoManager.registerTrackedOrigins([ + AI_SESSION_SUGGESTION_ORIGIN, + SUGGESTION_RESOLUTION_ORIGIN, + ]); - if ( - command.target === "selection" && - ctx.selection?.type === "text" && - !ctx.selection.isCollapsed - ) { - return this._runSelectionGeneration( - prompt, - ctx.selection, - command.id, - options?.maxSteps, + unsubscribeBeforeApply = editor.onBeforeApply( + (ops, options) => { + if (!controller?.getState().suggestMode) return ops; + if (shouldBypassSuggestMode(options.origin)) return ops; + const originType = options.origin + ? getOpOriginType(options.origin) + : undefined; + return interceptApplyForSuggestMode( + ops, + editor, + originType === "ai" + ? "assistant" + : (config.author ?? "user"), + originType === "ai" ? "ai" : "user", + readModelId(config.model), + ); + }, + { priority: 200 }, ); - } + }, - const targetBlockId = - options?.blockId ?? - ctx.blockId ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!targetBlockId) { - throw new Error("Cannot run AI command without a target block"); - } - return this._runBlockGeneration( - prompt, - targetBlockId, - command.id, - options?.maxSteps, - ); - } - - async runPrompt( - prompt: string, - options?: AICommandExecutionOptions, - ): Promise { - this._lastPrompt = prompt; - this._lastCommandId = null; - const promptTarget = resolvePromptTarget( - this._editor.selection, - options?.target, - ); - if (promptTarget === "selection") { - const selection = this._editor.selection; - if (selection?.type !== "text" || selection.isCollapsed) { - throw new Error( - "Cannot run a selection prompt without selected text", - ); - } - return this._runSelectionGeneration( - prompt, - selection, - undefined, - options?.maxSteps, - ); - } - if (promptTarget === "document") { - return this._runDocumentGeneration( - prompt, - options?.blockId, - undefined, - options?.maxSteps, - ); - } - const blockId = - options?.blockId ?? - resolveActiveBlockId(this._editor.selection) ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!blockId) { - throw new Error("Cannot run AI prompt without a target block"); - } - return this._runBlockGeneration( - prompt, - blockId, - undefined, - options?.maxSteps, - ); - } - - async retryActiveGeneration(): Promise { - const prompt = this._lastPrompt; - if (!prompt) return null; - this.rejectActiveGeneration(); - const active = this._state.activeGeneration; - const blockId = - active?.blockId ?? - resolveActiveBlockId(this._editor.selection) ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!blockId) return null; - if (active?.sessionId) { - const activeSession = this._state.sessions.find( - (session) => session.id === active.sessionId, - ); - const retryTarget = - activeSession?.target.kind === "document" - ? "document" - : (active?.target ?? "block"); - return this.runSessionPrompt(active.sessionId, prompt, { - blockId: retryTarget === "document" ? null : blockId, - target: retryTarget, - }); - } - if (this._lastCommandId) { - return this.runCommand(this._lastCommandId, { blockId }); - } - return this.runPrompt(prompt, { - blockId, - target: active?.target ?? "block", - }); - } - - acceptActiveGeneration(): boolean { - const generation = this._state.activeGeneration; - if (!generation) { - return false; - } - - if (generation.suggestionIds && generation.suggestionIds.length > 0) { - const existingSession = - generation.sessionId != null - ? (this._state.sessions.find( - (session) => session.id === generation.sessionId, - ) ?? null) - : null; - const existingTurn = - generation.turnId != null - ? (existingSession?.turns.find( - (turn) => turn.id === generation.turnId, - ) ?? null) - : null; - const refreshSuggestionIds = existingTurn?.suggestionIds.length - ? existingTurn.suggestionIds - : generation.suggestionIds; - const refreshedInlineSelectionTarget = - generation.surface === "inline-edit" - ? (resolveAcceptedInlineSelectionTarget( - this._editor, - existingTurn?.operation ?? - generation.operation ?? - undefined, - refreshSuggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor)) - : null; - const accepted = acceptSuggestions( - this._editor, - generation.suggestionIds, - ); - if (accepted) { - this._resolveActiveGeneration({ - suggestionIds: [], - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn( - generation.sessionId, - generation.turnId, - { - status: "accepted", - suggestionIds: [], - structuredPreview: null, - anchor: refreshedInlineSelectionTarget - ? resolveSessionAnchor( - refreshedInlineSelectionTarget.selection, - ) - : undefined, - selection: refreshedInlineSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineSelectionTarget.selection, - ) - : undefined, - }, - ); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingSuggestionIds: [], - ...(refreshedInlineSelectionTarget - ? { - target: refreshedInlineSelectionTarget, - anchor: resolveSessionAnchor( - refreshedInlineSelectionTarget.selection, - ), - contextualPrompt: - existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: resolveContextualPromptAnchor( - refreshedInlineSelectionTarget, - ), - } - : undefined, - } - : {}), - }); - } - } - return accepted; - } - - if (generation.planState !== "validated" || !generation.plan) { - return false; - } - - const execution = buildDocumentMutationPlanExecution( - this._editor, - generation.plan, - ); - if (execution.issues.length > 0) { - this._resolveActiveGeneration({ - planState: "rejected", - }); - return false; - } - - this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); - this._resolveActiveGeneration({ - planState: "none", - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn( - generation.sessionId, - generation.turnId, - { - status: "accepted", - reviewItemIds: [], - structuredPreview: null, - }, - ); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingReviewItemIds: [], - }); - } - return true; - } - - rejectActiveGeneration(): boolean { - const generation = this._state.activeGeneration; - if (!generation) return false; - - if (generation.suggestionIds && generation.suggestionIds.length > 0) { - const rejected = rejectSuggestions( - this._editor, - generation.suggestionIds, - ); - if (rejected) { - this._resolveActiveGeneration({ - suggestionIds: [], - planState: "rejected", - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn( - generation.sessionId, - generation.turnId, - { - status: "rejected", - suggestionIds: [], - structuredPreview: null, - }, - ); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingSuggestionIds: [], - }); - } - } - return rejected; - } - - if (generation.planState === "validated" && generation.plan) { - this._resolveActiveGeneration({ - status: "cancelled", - planState: "rejected", - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn( - generation.sessionId, - generation.turnId, - { - status: "rejected", - reviewItemIds: [], - structuredPreview: null, - }, - ); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingReviewItemIds: [], - }); - } - return true; - } - - if (generation.status === "streaming") { - this.cancelActiveGeneration(); - } - - return this._editor.undoManager.undo(); - } - - acceptReviewItem(id: string): boolean { - return this.acceptReviewItems([id]); - } - - rejectReviewItem(id: string): boolean { - return this.rejectReviewItems([id]); - } - - acceptReviewItems(ids: readonly string[]): boolean { - return this._applyReviewItems(ids, "accept"); - } - - rejectReviewItems(ids: readonly string[]): boolean { - return this._applyReviewItems(ids, "reject"); - } - - private _applyReviewItems( - ids: readonly string[], - action: "accept" | "reject", - ): boolean { - const generation = this._state.activeGeneration; - if ( - !generation || - generation.planState !== "validated" || - !generation.plan || - !generation.reviewItems - ) { - return false; - } - - const reviewItems = resolveOrderedReviewItems( - generation.reviewItems, - ids, - ); - if (reviewItems.length === 0) { - return false; - } - - if (action === "accept") { - const selectedPlans = reviewItems.map((reviewItem) => - selectStructuralReviewItemPlan(generation.plan!, reviewItem), - ); - if (selectedPlans.some((plan) => !plan)) { - return false; - } - const resolvedSelectedPlans = selectedPlans.filter( - (plan): plan is NonNullable<(typeof selectedPlans)[number]> => - plan != null, - ); - - const selectedPlan = - resolvedSelectedPlans.length === 1 - ? resolvedSelectedPlans[0]! - : { - kind: "review_bundle" as const, - label: "Bulk review selection", - reason: "Apply selected review items together.", - plans: resolvedSelectedPlans, - }; - const execution = buildDocumentMutationPlanExecution( - this._editor, - selectedPlan, - ); - if (execution.issues.length > 0) { - return false; - } - - this._editor.apply(execution.ops, { - origin: "ai", - undoGroup: true, - }); - } - - let nextPlan: GenerationState["plan"] = generation.plan; - for (const reviewItem of sortReviewItemsForRemoval(reviewItems)) { - if (!nextPlan) { - break; - } - nextPlan = removeStructuralReviewItemPlan(nextPlan, reviewItem); - } - const nextReviewItems = nextPlan - ? buildStructuralReviewItems(this._editor, nextPlan) - : []; - this._resolveActiveGeneration({ - status: - nextPlan || action === "accept" - ? generation.status - : "cancelled", - planState: nextPlan - ? "validated" - : action === "accept" - ? "none" - : "rejected", - plan: nextPlan, - reviewItems: nextReviewItems, - structuredPreview: nextPlan - ? buildGenerationStructuredPreviewState(this._editor, { - planState: "validated", - plan: nextPlan, - }) - : null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn( - generation.sessionId, - generation.turnId, - { - status: nextPlan - ? "review" - : action === "accept" - ? "accepted" - : "rejected", - reviewItemIds: nextReviewItems.map((item) => item.id), - }, - ); - } - this._updateSession(generation.sessionId, { - status: - nextPlan || action === "accept" - ? generation.status === "streaming" - ? "streaming" - : "complete" - : "complete", - pendingReviewItemIds: nextReviewItems.map((item) => item.id), - }); - } - return true; - } - - cancelActiveGeneration(): void { - this._abortController?.abort(); - this._abortController = null; - if (this._state.activeGeneration) { - this._setState({ - status: "idle", - activeGeneration: { - ...this._state.activeGeneration, - status: "cancelled", - structuredPreview: null, - }, - }); - if (this._state.activeGeneration.sessionId) { - if (this._state.activeGeneration.turnId) { - this._updateSessionTurn( - this._state.activeGeneration.sessionId, - this._state.activeGeneration.turnId, - { status: "cancelled" }, - ); - } - this._updateSession(this._state.activeGeneration.sessionId, { - status: "cancelled", - }); - } - } - this._inlineCompletion.dismissSuggestion(); - } - - openCommandMenu(): void { - this._setState({ commandMenuOpen: true }); - } - - closeCommandMenu(): void { - this._setState({ commandMenuOpen: false }); - } - - setSuggestMode(enabled: boolean): void { - this._setState({ suggestMode: enabled }); - } - - showEphemeralSuggestion( - suggestion: Parameters< - AIInlineCompletionController["showSuggestion"] - >[0], - ): void { - this._inlineCompletion.showSuggestion(suggestion); - } - - dismissEphemeralSuggestion(): void { - this._inlineCompletion.dismissSuggestion(); - } - - acceptEphemeralSuggestion(): void { - this._inlineCompletion.acceptSuggestion(); - } - - getSuggestions() { - return this._suggestions; - } - - handleDocumentChange( - events: readonly { - origin: OpOrigin; - affectedBlocks: readonly string[]; - }[], - ): void { - if (events.length > 0) { - this._documentVersion += 1; - } - const previousState = this._state; - const suggestionsChanged = this._syncSuggestionsFromDocument(); - const sessionsChanged = this._syncSessionsFromDocument(); - this.handleExternalCommit(events); - if (this._state === previousState) { - this._editor.requestDecorationUpdate(); - if (suggestionsChanged || sessionsChanged) { - this._emit(); - } - } - } - - private _syncSuggestionResolutionState(): void { - const suggestionsChanged = this._syncSuggestionsFromDocument(); - const sessionsChanged = this._syncSessionsFromDocument(); - if (!suggestionsChanged && !sessionsChanged) { - return; - } - this._editor.requestDecorationUpdate(); - this._emit(); - } - - acceptSuggestion(id: string): boolean { - const accepted = acceptSuggestion(this._editor, id); - if (accepted) { - this._syncSuggestionResolutionState(); - } - return accepted; - } - - rejectSuggestion(id: string): boolean { - const rejected = rejectSuggestion(this._editor, id); - if (rejected) { - this._syncSuggestionResolutionState(); - } - return rejected; - } - - private _rejectPreviewSuggestions(suggestionIds: readonly string[]): void { - if (suggestionIds.length === 0) { - return; - } - const rejected = rejectSuggestions(this._editor, suggestionIds, { - origin: AI_SESSION_SUGGESTION_ORIGIN, - undoGroupId: this._state.activeGeneration?.undoGroupId, - }); - if (rejected) { - this._syncSuggestionResolutionState(); - } - } - - acceptAllSuggestions(): void { - acceptAllSuggestions(this._editor); - this._syncSuggestionResolutionState(); - } - - rejectAllSuggestions(): void { - rejectAllSuggestions(this._editor); - this._syncSuggestionResolutionState(); - } - - buildDecorations(): Decoration[] { - const decorations = [ - ...buildTrackChangesDecorations(this._editor), - ...buildAffectedRangeDecorations( - this._editor, - this._state.sessions, - this._state.activeSessionId, - ), - ...buildGenerationZoneDecorations(this._state.activeGeneration), - ]; - return decorations; - } - - handleExternalCommit( - events: readonly { - origin: OpOrigin; - affectedBlocks: readonly string[]; - }[], - ): void { - const active = this._state.activeGeneration; - if (!active || active.status !== "streaming") return; - if ( - active.route === "tool-loop" || - active.route === "context-first" || - active.route === "review" - ) { - return; - } - const touched = events.some((event) => { - const originType = getOpOriginType(event.origin); - return ( - originType !== "ai" && - originType !== AI_SESSION_SUGGESTION_ORIGIN && - originType !== "system" && - originType !== "extension" && - event.affectedBlocks.includes(active.blockId) - ); - }); - if (!touched) return; - this.cancelActiveGeneration(); - } - - private async _runBlockGeneration( - prompt: string, - blockId: string, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - const block = this._editor.getBlock(blockId); - if (!block) { - throw new Error(`Block "${blockId}" not found`); - } - - const target: GenerationTarget = { - type: "block", - blockId, - offset: resolveBlockInsertionOffset(this._editor, blockId), - }; - return this._executeGeneration( - prompt, - target, - commandId, - maxSteps, - context, - ); - } - - private async _runDocumentGeneration( - prompt: string, - preferredBlockId?: string | null, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - const documentTarget = - context?.operation?.target.kind === "document" - ? context.operation.target - : null; - const replaceBlockIds = - documentTarget?.blockIds && documentTarget.blockIds.length > 0 - ? [...documentTarget.blockIds] - : context?.replaceBlockIds; - const insertionAnchor = resolveDocumentInsertionAnchor(this._editor, { - preferredBlockId: - documentTarget?.activeBlockId ?? - documentTarget?.blockIds?.[0] ?? - preferredBlockId ?? - resolveActiveBlockId(this._editor.selection) ?? - null, - }); - if (!insertionAnchor) { - throw new Error( - "Cannot run an AI document prompt without an insertion anchor", - ); - } - - return this._runBlockGeneration( - prompt, - insertionAnchor.blockId, - commandId, - maxSteps, - { - ...context, - replaceTargetBlock: - documentTarget?.placement === "replace-blocks" || - documentTarget?.placement === "replace-empty-block" || - insertionAnchor.strategy === "replace-empty-block" || - (replaceBlockIds?.length ?? 0) > 0, - replaceBlockIds, - }, - ); - } - - private async _runSelectionGeneration( - prompt: string, - selection: TextSelection, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - return this._executeGeneration( - prompt, - { type: "selection", selection }, - commandId, - maxSteps, - context, - ); - } - - private async _executeLocalOperation(input: { - prompt: string; - target: GenerationTarget; - blockId: string; - commandId?: string; - context?: GenerationExecutionContext; - abortController: AbortController; - baselineSuggestionIds: Set; - operation: AIRequestedOperation; - }): Promise { - const { - prompt, - target, - blockId, - commandId, - context, - abortController, - baselineSuggestionIds, - operation, - } = input; - const sessionTurnId = context?.sessionId - ? crypto.randomUUID() - : undefined; - const mutationMode: NonNullable = - "persistent-suggestions"; - const contentFormat = resolveLocalOperationContentFormat( - this._editor, - operation, - this._resolveContentFormat("block", context?.surface), - ); - const streamsMarkdownSelectionPreview = - operation.kind === "rewrite-selection" && - operation.target.kind === "scoped-range" && - contentFormat === "markdown" && - operation.target.blockIds.length > 0; - const applyStrategy: AIApplyStrategy | undefined = - (operation.kind === "rewrite-block" || - streamsMarkdownSelectionPreview || - (operation.kind === "document-transform" && - operation.target.kind === "document" && - (operation.target.placement === "replace-blocks" || - operation.target.placement === - "replace-empty-block"))) && - contentFormat === "markdown" - ? "markdown-full-replace" - : undefined; - const seedGeneration: GenerationState = { - id: crypto.randomUUID(), - zoneId: crypto.randomUUID(), - blockId, - target: target.type, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - prompt, - operation, - status: "streaming", - tokenCount: 0, - steps: [], - undoGroupId: crypto.randomUUID(), - text: "", - commandId, - suggestionIds: [], - route: - operation.kind === "rewrite-selection" - ? "selection-rewrite" - : operation.kind === "continue-block" - ? "cursor-context" - : "context-first", - mutationMode, - contentFormat, - applyStrategy, - planState: "none", - plan: null, - structuredIntent: null, - reviewItems: [], - structuredPreview: null, - targetKind: undefined, - blockClass: "flow", - adapterId: "flow-markdown", - transportKind: "flow-text", - mutationReceipt: null, - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - const existingSession = - context?.sessionId != null - ? (this._state.sessions.find( - (session) => session.id === context.sessionId, - ) ?? null) - : null; - const executionPrompt = buildSessionExecutionPrompt( - existingSession, - prompt, - ); - - if (context?.sessionId) { - const nextSelectionSnapshot = - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined; - this._updateSession(context.sessionId, { - status: "streaming", - operation, - activeTurnId: sessionTurnId, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : resolveSessionAnchor(this._editor.selection), - generationIds: appendUniqueString( - existingSession?.generationIds ?? [], - seedGeneration.id, - ), - promptHistory: [ - ...(existingSession?.promptHistory ?? []), - { - id: crypto.randomUUID(), - prompt, - createdAt: Date.now(), - generationId: seedGeneration.id, - operation, - }, - ], - turns: sessionTurnId - ? [ - ...(existingSession?.turns ?? []), - { - id: sessionTurnId, - prompt, - createdAt: Date.now(), - undoGroupId: seedGeneration.undoGroupId, - generationId: seedGeneration.id, - target: target.type, - operation, - status: "streaming", - suggestionIds: [], - reviewItemIds: [], - generatedBlockIds: [], - structuredPreview: null, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : undefined, - selection: - target.type === "selection" - ? resolveSessionSelectionSnapshot( - target.selection, - ) - : undefined, - }, - ] - : existingSession?.turns, - contextualPrompt: existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: - target.type === "selection" - ? { - ...existingSession.contextualPrompt - .anchor, - selectionSnapshot: - nextSelectionSnapshot, - focusBlockId: - target.selection.toRange().start - .blockId, - status: "valid", - } - : existingSession.contextualPrompt.anchor, - composer: { - ...existingSession.contextualPrompt.composer, - draftPrompt: "", - isSubmitting: true, - isOpen: true, - openReason: "user", - }, - } - : undefined, - }); - } - - this._setState({ - status: "thinking", - activeGeneration: seedGeneration, - commandMenuOpen: false, - lastRoute: seedGeneration.route, - activeSessionId: context?.sessionId ?? this._state.activeSessionId, - }); - this._setStreamEvents([ - createAIStreamEvent(seedGeneration, { - type: "generation-start", - prompt, - target: target.type, - }), - createAIStreamEvent(seedGeneration, { - type: "status", - status: "thinking", - }), - ]); - - let currentText = ""; - let currentMutationReceipt: AIMutationReceipt | null = null; - let sawStructuredFinalFrame = false; - let streamedSelectionSuggestionIds: string[] = []; - let lastStreamedSelectionPreviewText = ""; - const updatePreview = (text: string, phase: "preview" | "final") => { - currentText = text; - const nextStatus = - phase === "preview" && text.length > 0 - ? "writing" - : this._state.status; - if (phase === "preview" && text.length > 0) { - this._setState({ status: "writing" }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "status", - status: "writing", - }), - ); - } - this._resolveActiveGeneration({ - text, - status: "streaming", - operation, - }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "operation", - operation, - phase, - text, - }), - ); - void nextStatus; - }; - - try { - const stream = this._model!.stream({ - messages: [{ role: "user", content: executionPrompt }], - tools: [], - signal: abortController.signal, - requestMode: resolveGenerationRequestMode({ - ...context, - targetType: target.type, - operation, - }), - operation, - sessionId: context?.sessionId, - turnId: sessionTurnId, - generationId: seedGeneration.id, - }); - - for await (const event of stream) { - if (abortController.signal.aborted) { - break; - } - - if (event.type === "error") { - throw event.error; - } - - if (event.type === "conflict") { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "operation", - operation, - phase: "conflict", - reason: event.reason, - }), - ); - throw new Error(event.reason); - } - - if (event.type === "text-delta") { - if ( - operation.kind === "document-transform" || - streamsMarkdownSelectionPreview - ) { - currentText += event.delta; - if ( - streamsMarkdownSelectionPreview && - operation.target.kind === "scoped-range" - ) { - updatePreview(currentText, "preview"); - const previewRefresh = - this._refreshStreamingMarkdownBlockPreview( - operation.target.blockIds?.[0] ?? - operation.target.anchor.blockId, - currentText, - mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedSelectionSuggestionIds, - lastStreamedSelectionPreviewText, - true, - operation.target.blockIds, - ); - streamedSelectionSuggestionIds = - previewRefresh.suggestionIds; - lastStreamedSelectionPreviewText = - previewRefresh.normalizedText; - } - continue; - } - throw new Error( - "Local AI operations must stream typed operation payloads, not raw text deltas.", - ); - } - - if ( - event.type === "replace-preview" || - event.type === "insert-preview" - ) { - updatePreview(event.text, "preview"); - if ( - streamsMarkdownSelectionPreview && - operation.target.kind === "scoped-range" - ) { - const previewRefresh = - this._refreshStreamingMarkdownBlockPreview( - operation.target.blockIds?.[0] ?? - operation.target.anchor.blockId, - event.text, - mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedSelectionSuggestionIds, - lastStreamedSelectionPreviewText, - true, - operation.target.blockIds, - ); - streamedSelectionSuggestionIds = - previewRefresh.suggestionIds; - lastStreamedSelectionPreviewText = - previewRefresh.normalizedText; - } - continue; - } - - if ( - event.type === "replace-final" || - event.type === "insert-final" - ) { - sawStructuredFinalFrame = true; - updatePreview(event.text, "final"); - if ( - streamsMarkdownSelectionPreview && - operation.target.kind === "scoped-range" - ) { - this._rejectPreviewSuggestions( - streamedSelectionSuggestionIds, - ); - streamedSelectionSuggestionIds = []; - lastStreamedSelectionPreviewText = ""; - } - currentMutationReceipt = - this._commitRequestedOperationResult( - operation, - event.text, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); - continue; - } - - if (event.type === "done") { - break; - } - } - - if ( - !sawStructuredFinalFrame && - currentText.length > 0 && - operation.kind !== "document-transform" && - !streamsMarkdownSelectionPreview - ) { - throw new Error( - "Local AI operations must return a validated final payload before they can be applied.", - ); - } - if ( - !sawStructuredFinalFrame && - currentText.length > 0 && - operation.kind === "document-transform" - ) { - currentMutationReceipt = this._commitRequestedOperationResult( - operation, - currentText, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); - } else if ( - !sawStructuredFinalFrame && - currentText.length > 0 && - streamsMarkdownSelectionPreview - ) { - this._rejectPreviewSuggestions(streamedSelectionSuggestionIds); - streamedSelectionSuggestionIds = []; - lastStreamedSelectionPreviewText = ""; - currentMutationReceipt = this._commitRequestedOperationResult( - operation, - currentText, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); - } - - const suggestionIds = this.getSuggestions() - .map((item) => item.id) - .filter((id) => !baselineSuggestionIds.has(id)); - const mutationReceipt = - currentMutationReceipt ?? - buildMutationReceipt({ - status: currentText.length > 0 ? "noop" : "noop", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - const finalStatus = abortController.signal.aborted - ? "cancelled" - : "complete"; - this._setState({ - status: "idle", - activeGeneration: { - ...seedGeneration, - text: currentText, - status: finalStatus, - suggestionIds, - mutationReceipt, - }, - }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "generation-finish", - status: finalStatus, - text: currentText, - }), - ); - if (context?.sessionId) { - if (sessionTurnId) { - const localReceiptEvidence = mutationReceipt?.evidence; - const localGeneratedBlockIds = localReceiptEvidence - ? [ - ...new Set([ - ...localReceiptEvidence.affectedBlockIds, - ...localReceiptEvidence.createdBlockIds, - ]), - ] - : operation.kind === "rewrite-selection" && - operation.target.kind === "scoped-range" - ? [...operation.target.blockIds] - : []; - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: - finalStatus === "cancelled" - ? "cancelled" - : "complete", - suggestionIds, - generatedBlockIds: localGeneratedBlockIds, - }); - } - this._updateSession(context.sessionId, { - status: - finalStatus === "cancelled" ? "cancelled" : "complete", - pendingSuggestionIds: suggestionIds, - pendingReviewItemIds: [], - }); - } - return { - ...seedGeneration, - text: currentText, - status: finalStatus, - suggestionIds, - mutationReceipt, - }; - } catch (error) { - this._setState({ - status: "idle", - activeGeneration: { - ...seedGeneration, - text: currentText, - status: abortController.signal.aborted - ? "cancelled" - : "error", - }, - }); - if (context?.sessionId) { - if (sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: abortController.signal.aborted - ? "cancelled" - : "error", - }); - } - this._updateSession(context.sessionId, { - status: abortController.signal.aborted - ? "cancelled" - : "error", - }); - } - throw error; - } finally { - if (this._abortController === abortController) { - this._abortController = null; - } - } - } - - private async _executeGeneration( - prompt: string, - target: GenerationTarget, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - if (!this._model) { - throw new Error("No AI model configured"); - } - - this.cancelActiveGeneration(); - const toolRuntime = - getDocumentToolRuntime(this._editor) ?? EMPTY_TOOL_RUNTIME; - const abortController = new AbortController(); - this._abortController = abortController; - - const baselineSuggestionIds = new Set( - this.getSuggestions().map((item) => item.id), - ); - const blockId = - target.type === "block" - ? target.blockId - : target.selection.toRange().start.blockId; - const requestedOperation = context?.operation ?? null; - if ( - context?.surface === "bottom-chat" && - isLocalRequestedOperation(requestedOperation) - ) { - return this._executeLocalOperation({ - prompt, - target, - blockId, - commandId, - context, - abortController, - baselineSuggestionIds, - operation: requestedOperation, - }); - } - const requestedContentFormat = this._resolveContentFormat( - target.type, - context?.surface, - ); - let route = routeAIRequest({ - prompt, - selection: this._editor.selection, - blockType: this._editor.getBlock(blockId)?.type ?? null, - blockCount: this._editor.blockCount(), - suggestMode: this._state.suggestMode, - target: target.type, - contentFormat: requestedContentFormat, - surface: context?.surface, - }); - let workingSet = await this._buildWorkingSet( - toolRuntime, - route, - target, - blockId, - prompt, - ); - const refinedRoute = this._refineRouteWithWorkingSet(route, workingSet); - if (refinedRoute.lane !== route.lane) { - route = refinedRoute; - workingSet = await this._buildWorkingSet( - toolRuntime, - route, - target, - blockId, - prompt, - ); - } else { - route = refinedRoute; - } - const adapter = getBlockAdapter(route.adapterId); - const contentFormat = route.contentFormat; - let currentText = ""; - const streamingTarget = - this._editor.internals.getSlot( - "delta-stream:target", - ) ?? null; - let blockStreamingStarted = false; - const shouldStreamDirectly = route.shouldStreamDirectly; - const selectionRange = - target.type === "selection" ? target.selection.toRange() : null; - const selectionSourceText = - target.type === "selection" - ? resolveSelectionText(this._editor, target.selection) - : ""; - const shouldStreamSuggestedText = - route.mutationMode === "streaming-suggestions" && - route.plannerMode !== "structured" && - contentFormat === "text"; - const shouldReplaceMarkdownTarget = - context?.replaceTargetBlock === true || - (route.plannerMode !== "structured" && - contentFormat === "markdown" && - target.type === "block" && - (route.targetKind === "table" || - (context?.surface === "bottom-chat" && - shouldReplaceEmptyMarkdownTarget( - this._editor.getBlock(blockId), - )))); - const canStreamSelectionSuggestions = - shouldStreamSuggestedText && - target.type === "selection" && - selectionRange?.start.blockId === selectionRange?.end.blockId; - const canStreamBlockSuggestions = - shouldStreamSuggestedText && target.type === "block"; - const canStreamMarkdownBlockSuggestions = - route.mutationMode === "streaming-suggestions" && - route.plannerMode !== "structured" && - contentFormat === "markdown" && - target.type === "block" && - route.applyStrategy === "markdown-full-replace" && - context?.surface === "bottom-chat"; - let streamedSuggestionInitialized = false; - let streamedSuggestionLength = 0; - let streamedMarkdownSuggestionIds: string[] = []; - let lastStreamedMarkdownPreviewText = ""; - const sessionTurnId = context?.sessionId - ? crypto.randomUUID() - : undefined; - const existingSession = - context?.sessionId != null - ? (this._state.sessions.find( - (session) => session.id === context.sessionId, - ) ?? null) - : null; - const executionPrompt = buildSessionExecutionPrompt( - existingSession, - prompt, - ); - let shouldTrimLeadingBlankBlockText = - target.type === "block" && - shouldTrimLeadingBlankBlockGenerationText( - this._editor.getBlock(blockId), - ); - const useStructuredIntentTransport = - adapter.transportKind !== "flow-text" && - supportsStructuredIntent(this._model); - const generationPrompt = - useStructuredIntentTransport || - (adapter.id === "flow-markdown" && contentFormat === "markdown") - ? adapter.buildPrompt({ - prompt: executionPrompt, - targetKind: route.targetKind, - activeBlockId: blockId, - workingSet, - applyStrategy: route.applyStrategy, - }) - : route.plannerMode === "structured" - ? buildPlannerPrompt({ - prompt: executionPrompt, - targetKind: route.targetKind, - workingSet, - }) - : executionPrompt; - - const seedGeneration: GenerationState = { - id: crypto.randomUUID(), - zoneId: crypto.randomUUID(), - blockId, - target: target.type, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - prompt, - operation: requestedOperation, - status: "streaming", - tokenCount: 0, - steps: [], - undoGroupId: crypto.randomUUID(), - text: "", - commandId, - suggestionIds: [], - route: route.lane, - mutationMode: route.mutationMode, - contentFormat, - applyStrategy: route.applyStrategy, - planState: "none", - plan: null, - structuredIntent: null, - reviewItems: [], - structuredPreview: null, - targetKind: route.targetKind, - blockClass: route.blockClass, - adapterId: route.adapterId, - transportKind: route.transportKind, - mutationReceipt: null, - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - routeConfidence: workingSet?.routeConfidence, - structured: { - plannerMode: route.plannerMode, - executionMode: resolveExecutionMode(route.mutationMode), - targetKind: route.targetKind, - validationIssueCount: 0, - }, - fastApply: { - attempted: false, - succeeded: false, - }, - }, - }; - if (context?.sessionId) { - const nextSelectionSnapshot = - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined; - this._updateSession(context.sessionId, { - status: "streaming", - operation: requestedOperation, - activeTurnId: sessionTurnId, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : resolveSessionAnchor(this._editor.selection), - generationIds: appendUniqueString( - existingSession?.generationIds ?? [], - seedGeneration.id, - ), - promptHistory: [ - ...(existingSession?.promptHistory ?? []), - { - id: crypto.randomUUID(), - prompt, - createdAt: Date.now(), - generationId: seedGeneration.id, - operation: requestedOperation ?? undefined, - }, - ], - turns: sessionTurnId - ? [ - ...(existingSession?.turns ?? []), - { - id: sessionTurnId, - prompt, - createdAt: Date.now(), - undoGroupId: seedGeneration.undoGroupId, - generationId: seedGeneration.id, - target: target.type, - operation: requestedOperation ?? undefined, - status: "streaming", - suggestionIds: [], - reviewItemIds: [], - generatedBlockIds: [], - structuredPreview: null, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : undefined, - selection: - target.type === "selection" - ? resolveSessionSelectionSnapshot( - target.selection, - ) - : undefined, - }, - ] - : existingSession?.turns, - contextualPrompt: existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: - target.type === "selection" - ? { - ...existingSession.contextualPrompt - .anchor, - selectionSnapshot: - nextSelectionSnapshot, - focusBlockId: - target.selection.toRange().start - .blockId, - status: "valid", - } - : existingSession.contextualPrompt.anchor, - composer: { - ...existingSession.contextualPrompt.composer, - draftPrompt: "", - isSubmitting: true, - isOpen: true, - openReason: "user", - }, - } - : undefined, - }); - } - this._setState({ - status: "thinking", - activeGeneration: seedGeneration, - commandMenuOpen: false, - lastRoute: route.lane, - activeSessionId: context?.sessionId ?? this._state.activeSessionId, - }); - let currentStructuredPreview: GenerationStructuredPreviewState | null = - null; - let currentStructuredIntent: GenerationState["structuredIntent"] = null; - let currentMutationReceipt: AIMutationReceipt | null = null; - this._setStreamEvents([ - createAIStreamEvent(seedGeneration, { - type: "generation-start", - prompt, - target: target.type, - }), - createAIStreamEvent(seedGeneration, { - type: "status", - status: "thinking", - }), - ]); - - try { - const result = await runAgenticLoop({ - model: this._model, - editor: this._editor, - toolRuntime: route.allowToolUse - ? toolRuntime - : EMPTY_TOOL_RUNTIME, - prompt: generationPrompt, - blockId, - generationId: seedGeneration.id, - zoneId: seedGeneration.zoneId, - maxSteps: route.allowToolUse - ? (maxSteps ?? this._maxAgenticSteps) - : 1, - signal: abortController.signal, - requestMode: resolveGenerationRequestMode({ - ...context, - targetType: target.type, - }), - workingSet, - validateWorkingSet: (activeWorkingSet) => - this._validateWorkingSet(route, target, activeWorkingSet), - refreshWorkingSet: async () => - this._buildWorkingSet( - toolRuntime, - route, - target, - blockId, - prompt, - ), - onStatusChange: (status) => { - this._setState({ status }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "status", - status, - }), - ); - }, - onStep: (step) => { - const active = this._state.activeGeneration; - if (!active) return; - this._setState({ - activeGeneration: { - ...active, - steps: [...active.steps, step], - }, - }); - }, - onTextDelta: (delta) => { - const nextDelta = - target.type === "block" && - shouldTrimLeadingBlankBlockText - ? trimLeadingBlankBlockGenerationText(delta) - : delta; - if ( - shouldTrimLeadingBlankBlockText && - nextDelta.length > 0 - ) { - shouldTrimLeadingBlankBlockText = false; - } - if (nextDelta.length === 0) { - return; - } - currentText += nextDelta; - if (target.type === "block" && shouldStreamDirectly) { - streamingTarget?.appendDelta(nextDelta); - } else if ( - canStreamSelectionSuggestions && - selectionRange - ) { - if (!streamedSuggestionInitialized) { - this._applySuggestedAIOps( - [ - { - type: "replace-text", - blockId: selectionRange.start.blockId, - offset: selectionRange.start.offset, - length: - selectionRange.end.offset - - selectionRange.start.offset, - text: nextDelta, - }, - ], - context?.sessionId, - { undoGroupId: seedGeneration.undoGroupId }, - ); - streamedSuggestionInitialized = true; - streamedSuggestionLength = nextDelta.length; - } else if (nextDelta.length > 0) { - this._applySuggestedAIOps( - [ - { - type: "insert-text", - blockId: selectionRange.start.blockId, - offset: - selectionRange.end.offset + - streamedSuggestionLength, - text: nextDelta, - }, - ], - context?.sessionId, - { undoGroupId: seedGeneration.undoGroupId }, - ); - streamedSuggestionLength += nextDelta.length; - } - } else if ( - canStreamBlockSuggestions && - target.type === "block" - ) { - if (nextDelta.length > 0) { - this._applySuggestedAIOps( - [ - { - type: "insert-text", - blockId: target.blockId, - offset: - target.offset + - streamedSuggestionLength, - text: nextDelta, - }, - ], - context?.sessionId, - { undoGroupId: seedGeneration.undoGroupId }, - ); - streamedSuggestionLength += nextDelta.length; - } - } else if ( - canStreamMarkdownBlockSuggestions && - target.type === "block" - ) { - const previewRefresh = - this._refreshStreamingMarkdownBlockPreview( - target.blockId, - currentText, - route.mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedMarkdownSuggestionIds, - lastStreamedMarkdownPreviewText, - shouldReplaceMarkdownTarget, - context?.replaceBlockIds, - ); - streamedMarkdownSuggestionIds = - previewRefresh.suggestionIds; - lastStreamedMarkdownPreviewText = - previewRefresh.normalizedText; - } else if (target.type === "selection") { - this._inlineCompletion.showSuggestion({ - id: seedGeneration.id, - blockId: blockId, - offset: target.selection.toRange().start.offset, - text: currentText, - type: "inline", - }); - } - const active = this._state.activeGeneration; - if (!active) return; - this._setState({ - activeGeneration: { - ...active, - text: currentText, - status: "streaming", - }, - }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "text-delta", - delta: nextDelta, - text: currentText, - }), - ); - if ( - route.plannerMode === "structured" && - !useStructuredIntentTransport - ) { - const previewResult = parseStructuredPlanPreview( - currentText, - route.targetKind, - ); - if (previewResult?.plan) { - const nextStructuredPreview = - buildGenerationStructuredPreviewState( - this._editor, - { - planState: - previewResult.planState === - "validated" - ? "validated" - : "drafted", - plan: previewResult.plan, - }, - ); - if ( - !areStructuredValuesEqual( - currentStructuredPreview, - nextStructuredPreview, - ) - ) { - const patches = - buildStructuredPreviewPatchOperations( - currentStructuredPreview, - nextStructuredPreview, - ); - currentStructuredPreview = - nextStructuredPreview; - this._resolveActiveGeneration({ - structuredPreview: nextStructuredPreview, - }); - if (context?.sessionId && sessionTurnId) { - this._updateSessionTurn( - context.sessionId, - sessionTurnId, - { - reviewItemIds: - nextStructuredPreview.reviewItems.map( - (item) => item.id, - ), - structuredPreview: - nextStructuredPreview, - }, - ); - } - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "structured-preview", - preview: nextStructuredPreview, - patches, - }), - ); - } - } - } - }, - onStructuredData: (event) => { - if (!useStructuredIntentTransport) { - return; - } - const previewResult = - adapter.parsePreview?.({ - value: event.data, - targetKind: route.targetKind, - activeBlockId: blockId, - }) ?? null; - if (!previewResult?.intent) { - return; - } - currentStructuredIntent = previewResult.intent; - const compilation = compileStructuredIntentToPlan( - previewResult.intent, - { - activeBlockId: blockId, - }, - ); - if (!compilation.plan) { - return; - } - const nextStructuredPreview = - buildGenerationStructuredPreviewState(this._editor, { - planState: - previewResult.intentState === "validated" && - compilation.issues.length === 0 - ? "validated" - : "drafted", - plan: compilation.plan, - }); - if ( - areStructuredValuesEqual( - currentStructuredPreview, - nextStructuredPreview, - ) - ) { - return; - } - const patches = buildStructuredPreviewPatchOperations( - currentStructuredPreview, - nextStructuredPreview, - ); - currentStructuredPreview = nextStructuredPreview; - this._resolveActiveGeneration({ - structuredIntent: previewResult.intent, - structuredPreview: nextStructuredPreview, - }); - if (context?.sessionId && sessionTurnId) { - this._updateSessionTurn( - context.sessionId, - sessionTurnId, - { - reviewItemIds: - nextStructuredPreview.reviewItems.map( - (item) => item.id, - ), - structuredPreview: nextStructuredPreview, - }, - ); - } - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "app-partial", - data: event.data, - final: event.final, - }), - ); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "structured-preview", - preview: nextStructuredPreview, - patches, - }), - ); - }, - onToolCall: (event) => { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: event.input, - }), - ); - }, - onToolOutput: (event) => { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "tool-output", - toolCallId: event.toolCallId, - toolName: event.toolName, - part: event.part, - output: event.output, - }), - ); - }, - onToolResult: (event) => { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "tool-result", - toolCallId: event.toolCallId, - toolName: event.toolName, - output: event.output, - state: event.state, - }), - ); - }, - onDebug: (debug) => { - const active = this._state.activeGeneration; - if (!active) return; - this._setState({ - activeGeneration: { - ...active, - debug, - }, - }); - }, - onStreamingStart: (zoneId, targetBlockId) => { - if ( - target.type !== "block" || - !shouldStreamDirectly || - blockStreamingStarted - ) - return; - streamingTarget?.beginStreaming(zoneId, targetBlockId); - blockStreamingStarted = true; - }, - onStreamingEnd: (status) => { - if ( - target.type !== "block" || - !shouldStreamDirectly || - !blockStreamingStarted - ) - return; - streamingTarget?.endStreaming(status); - blockStreamingStarted = false; - }, - }); - - if ( - target.type === "selection" && - currentText.length > 0 && - !canStreamSelectionSuggestions - ) { - currentMutationReceipt = this._commitSelectionRewrite( - target.selection, - currentText, - route.mutationMode, - context?.sessionId, - ); - this._inlineCompletion.dismissSuggestion(); - } else if ( - target.type === "selection" && - currentText.length > 0 && - canStreamSelectionSuggestions - ) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - contextChars: selectionSourceText.length, - diffChars: currentText.length, - }); - } else if ( - target.type === "block" && - currentText.length > 0 && - !shouldStreamDirectly && - !canStreamBlockSuggestions && - !canStreamMarkdownBlockSuggestions && - route.plannerMode !== "structured" - ) { - currentMutationReceipt = this._commitBufferedBlockGeneration( - target.blockId, - currentText, - route.mutationMode, - contentFormat, - context?.sessionId, - { - applyStrategy: route.applyStrategy, - insertionOffset: target.offset, - workingSet, - replaceTargetBlock: shouldReplaceMarkdownTarget, - replaceBlockIds: context?.replaceBlockIds, - }, - ); - this._inlineCompletion.dismissSuggestion(); - } - - const suggestionIds = this.getSuggestions() - .map((item) => item.id) - .filter((id) => !baselineSuggestionIds.has(id)); - const structuredPlanResult = - route.plannerMode === "structured" && - !useStructuredIntentTransport - ? parseStructuredPlanResult(currentText, route.targetKind) - : null; - const structuredIntentResolution = useStructuredIntentTransport - ? (adapter.resolveResult?.({ - value: currentStructuredIntent, - targetKind: route.targetKind, - activeBlockId: blockId, - }) ?? null) - : null; - const structuredIntentResult = - structuredIntentResolution?.parseResult ?? null; - const structuredIntentCompilation = - structuredIntentResolution?.compilation ?? null; - const resolvedStructuredPlan = - structuredIntentCompilation?.plan ?? - structuredPlanResult?.plan ?? - null; - const planExecution = resolvedStructuredPlan - ? buildDocumentMutationPlanExecution( - this._editor, - resolvedStructuredPlan, - ) - : null; - const reviewItems = - resolvedStructuredPlan && - route.mutationMode !== "direct-stream" && - (!planExecution || !planExecution.reviewSafe) - ? buildStructuralReviewItems( - this._editor, - resolvedStructuredPlan, - ) - : []; - - if ( - resolvedStructuredPlan && - planExecution && - planExecution.issues.length === 0 - ) { - currentMutationReceipt = this._commitStructuredPlan( - planExecution.ops, - planExecution.reviewSafe, - route.mutationMode, - route.adapterId, - route.blockClass, - route.transportKind, - ); - } - if (!currentMutationReceipt) { - currentMutationReceipt = this._buildFallbackMutationReceipt({ - currentText, - suggestionIds, - reviewItems, - planExecutionIssueCount: planExecution?.issues.length ?? 0, - adapterId: route.adapterId, - blockClass: route.blockClass, - transportKind: route.transportKind, - }); - } - const structuredDebug = { - plannerMode: route.plannerMode, - executionMode: resolveExecutionMode(route.mutationMode), - targetKind: route.targetKind, - validationIssueCount: - (structuredPlanResult?.issues.length ?? 0) + - (structuredIntentResult?.issues.length ?? 0) + - (structuredIntentCompilation?.issues.length ?? 0) + - (planExecution?.issues.length ?? 0), - }; - const resolvedDebug = - this._state.activeGeneration?.id === seedGeneration.id - ? (this._state.activeGeneration.debug ?? - result.debug ?? - seedGeneration.debug!) - : (result.debug ?? seedGeneration.debug!); - const resolvedPlanState: GenerationState["planState"] = - planExecution && planExecution.issues.length > 0 - ? "rejected" - : structuredIntentResult?.intentState === "validated" && - (structuredIntentCompilation?.issues.length ?? 0) === - 0 - ? "validated" - : structuredIntentResult?.intentState === "drafted" - ? "drafted" - : (structuredPlanResult?.planState ?? - seedGeneration.planState); - - const finalGeneration: GenerationState = { - ...result, - blockId, - target: target.type, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - commandId, - text: currentText, - suggestionIds, - route: route.lane, - mutationMode: route.mutationMode, - contentFormat, - planState: resolvedPlanState, - plan: resolvedStructuredPlan, - structuredIntent: - structuredIntentResult?.intent ?? - currentStructuredIntent ?? - null, - reviewItems, - structuredPreview: resolvedStructuredPlan - ? buildGenerationStructuredPreviewState(this._editor, { - planState: - planExecution && - planExecution.issues.length === 0 - ? "validated" - : "drafted", - plan: resolvedStructuredPlan, - }) - : currentStructuredPreview, - targetKind: route.targetKind, - blockClass: route.blockClass, - adapterId: route.adapterId, - transportKind: route.transportKind, - mutationReceipt: currentMutationReceipt, - debug: { - ...resolvedDebug, - structured: structuredDebug, - }, - }; - this._abortController = null; - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "generation-finish", - status: finalGeneration.status, - text: currentText, - }), - ); - this._setState({ - status: "idle", - activeGeneration: finalGeneration, - }); - if (context?.sessionId) { - const structuredPreviewEvents = this.getStreamEvents().filter( - (event) => - event.type === "structured-preview" && - event.sessionId === context.sessionId, - ); - const lastStructuredPreviewEvent = - structuredPreviewEvents[structuredPreviewEvents.length - 1]; - const refreshedInlineReviewSelectionTarget = - context?.surface === "inline-edit" && - suggestionIds.length > 0 - ? (resolvePendingInlineSelectionTarget( - this._editor, - requestedOperation ?? undefined, - suggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor)) - : null; - if (sessionTurnId) { - const receiptEvidence = currentMutationReceipt?.evidence; - const generatedBlockIds = receiptEvidence - ? [ - ...new Set([ - ...receiptEvidence.affectedBlockIds, - ...receiptEvidence.createdBlockIds, - ]), - ] - : []; - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: - suggestionIds.length > 0 || reviewItems.length > 0 - ? "review" - : finalGeneration.status === "complete" - ? "complete" - : finalGeneration.status, - suggestionIds, - reviewItemIds: reviewItems.map((item) => item.id), - generatedBlockIds, - structuredPreview: - finalGeneration.structuredPreview ?? null, - anchor: refreshedInlineReviewSelectionTarget - ? resolveSessionAnchor( - refreshedInlineReviewSelectionTarget.selection, - ) - : undefined, - selection: refreshedInlineReviewSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineReviewSelectionTarget.selection, - ) - : undefined, - }); - } - const resolvedGenerationDebug = - this._state.activeGeneration?.id === finalGeneration.id - ? this._state.activeGeneration.debug - : finalGeneration.debug; - this._recordSessionFastApplyMetrics( - context.sessionId, - resolvedGenerationDebug?.fastApply, - ); - this._updateSession(context.sessionId, { - status: - finalGeneration.status === "complete" - ? "complete" - : finalGeneration.status, - pendingSuggestionIds: suggestionIds, - pendingReviewItemIds: reviewItems.map((item) => item.id), - metrics: { - ...(this._state.sessions.find( - (session) => session.id === context.sessionId, - )?.metrics ?? { - streamEventCount: 0, - patchCount: 0, - fastApply: createDefaultSessionFastApplyMetrics(), - }), - firstTokenMs: - resolvedGenerationDebug?.firstVisibleTextMs ?? - undefined, - totalMs: - resolvedGenerationDebug?.messageAssemblyLatencyMs != - null - ? resolvedGenerationDebug.messageAssemblyLatencyMs + - (resolvedGenerationDebug.toolExecutionMs ?? - 0) - : undefined, - toolMs: - resolvedGenerationDebug?.toolExecutionMs ?? - undefined, - streamEventCount: this._streamEvents.filter( - (event) => event.sessionId === context.sessionId, - ).length, - patchCount: - lastStructuredPreviewEvent?.type === - "structured-preview" - ? lastStructuredPreviewEvent.patches.length - : 0, - }, - }); - } - - if (finalGeneration.status === "complete") { - this._editor.internals.emit("diagnostic", { - level: "info", - source: "ai", - code: "GENERATION_COMPLETE", - message: "AI generation completed", - blockId, - generationId: finalGeneration.id, - }); - } - - return finalGeneration; - } catch (error) { - const isStaleWorkingSet = - error instanceof Error && error.name === "StaleWorkingSetError"; - const failedGeneration: GenerationState = { - ...(this._state.activeGeneration ?? seedGeneration), - blockId, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - prompt, - commandId, - text: currentText, - status: - abortController.signal.aborted || isStaleWorkingSet - ? "cancelled" - : "error", - targetKind: route.targetKind, - }; - this._abortController = null; - this._inlineCompletion.dismissSuggestion(); - if (target.type === "block" && blockStreamingStarted) { - streamingTarget?.endStreaming( - abortController.signal.aborted ? "cancelled" : "error", - ); - blockStreamingStarted = false; - } - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "generation-finish", - status: failedGeneration.status, - text: currentText, - }), - ); - this._setState({ - status: "idle", - activeGeneration: failedGeneration, - }); - if (context?.sessionId) { - if (sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: failedGeneration.status, - reviewItemIds: [], - structuredPreview: null, - }); - } - this._updateSession(context.sessionId, { - status: failedGeneration.status, - }); - } - if (abortController.signal.aborted || isStaleWorkingSet) { - return failedGeneration; - } - throw error; - } - } - - private _commitRequestedOperationResult( - operation: AIRequestedOperation, - text: string, - sessionId: string | undefined, - options: { - contentFormat: AIContentFormat; - applyStrategy?: AIApplyStrategy; - }, - ): AIMutationReceipt { - const conflictReason = resolveRequestedOperationConflict( - this._editor, - operation, - this._createSelectionSignature(this._editor.selection), - ); - if (conflictReason) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [conflictReason], - }); - } - - if (operation.kind === "rewrite-selection") { - const selection = resolveSelectionForRequestedOperation( - this._editor, - operation, - ); - if (!selection) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [ - "The requested selection rewrite target is no longer available.", - ], - }); - } - const markdownBlockIds = - options.contentFormat === "markdown" && - operation.target.kind === "scoped-range" && - operation.target.blockIds.length > 0 - ? operation.target.blockIds - : null; - if (markdownBlockIds) { - return this._commitBufferedBlockGeneration( - markdownBlockIds[0], - text, - "persistent-suggestions", - "markdown", - sessionId, - { - applyStrategy: options.applyStrategy, - replaceTargetBlock: true, - replaceBlockIds: markdownBlockIds, - }, - ); - } - return this._commitSelectionRewrite( - selection, - text, - "persistent-suggestions", - sessionId, - ); - } - - if (operation.kind === "rewrite-block") { - const target = - operation.target.kind === "block" ? operation.target : null; - if (!target) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested block rewrite target is invalid."], - }); - } - const selection = resolveFullBlockTextSelection( - this._editor, - target.blockId, - ); - if (selection && options.contentFormat === "text") { - return this._commitSelectionRewrite( - selection, - text, - "persistent-suggestions", - sessionId, - ); - } - return this._commitBufferedBlockGeneration( - target.blockId, - text, - "persistent-suggestions", - options.contentFormat, - sessionId, - { - applyStrategy: options.applyStrategy, - replaceTargetBlock: true, - }, - ); - } - - if (operation.kind === "document-transform") { - const target = - operation.target.kind === "document" ? operation.target : null; - if (!target) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [ - "The requested document transform target is invalid.", - ], - }); - } - const replaceBlockIds = target.blockIds?.filter( - (blockId) => this._editor.getBlock(blockId) != null, - ); - if (target.transform === "remove") { - const deleteBlockIds = - replaceBlockIds && replaceBlockIds.length > 0 - ? replaceBlockIds - : this._editor.documentState.blockOrder.filter( - (blockId) => - this._editor.getBlock(blockId) != null, - ); - const ops = deleteBlockIds.map((blockId) => ({ - type: "delete-block" as const, - blockId, - })); - if (ops.length === 0) { - return buildMutationReceipt({ - status: "noop", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._applySuggestedAIOps(ops, sessionId); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - const targetBlockId = - target.activeBlockId ?? - replaceBlockIds?.[0] ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id ?? - null; - if (!targetBlockId) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [ - "The requested document transform target is no longer available.", - ], - }); - } - return this._commitBufferedBlockGeneration( - targetBlockId, - text, - "persistent-suggestions", - options.contentFormat, - sessionId, - { - applyStrategy: options.applyStrategy, - replaceTargetBlock: - target.placement === "replace-blocks" || - target.placement === "replace-empty-block" || - (replaceBlockIds?.length ?? 0) > 0, - replaceBlockIds, - }, - ); - } - - const target = - operation.target.kind === "block" ? operation.target : null; - if (!target) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested continuation target is invalid."], - }); - } - return this._commitBufferedBlockGeneration( - target.blockId, - text, - "persistent-suggestions", - "text", - sessionId, - { - insertionOffset: target.insertionOffset, - }, - ); - } - - private _commitSelectionRewrite( - selection: TextSelection, - text: string, - mutationMode: NonNullable, - sessionId?: string, - ): AIMutationReceipt { - const selectedText = resolveSelectionText(this._editor, selection); - const ops = buildSelectionReplacementOps(this._editor, selection, text); - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - contextChars: selectedText.length, - diffChars: text.length, - }); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._editor.selectTextRange(selection.anchor, selection.focus); - this._editor.deleteSelection({ origin: "ai" }); - const nextSelection = this._editor.selection; - if (nextSelection?.type !== "text") { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: selectedText.length, - diffChars: text.length, - fallbackReason: "selection-lost", - }); - return buildMutationReceipt({ - status: "invalid", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["Selection rewrite lost the active text selection."], - }); - } - const caret = nextSelection.anchor; - if (text.length > 0) { - this._editor.apply( - [ - { - type: "insert-text", - blockId: caret.blockId, - offset: caret.offset, - text, - }, - ], - { origin: "ai" }, - ); - } - this._editor.selectTextRange( - { - blockId: caret.blockId, - offset: caret.offset + text.length, - }, - { - blockId: caret.blockId, - offset: caret.offset + text.length, - }, - ); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - contextChars: selectedText.length, - diffChars: text.length, - }); - return buildMutationReceipt({ - status: "applied", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - private _commitBufferedBlockGeneration( - blockId: string, - text: string, - mutationMode: NonNullable, - contentFormat: AIContentFormat, - sessionId?: string, - options?: { - applyStrategy?: AIApplyStrategy; - insertionOffset?: number; - workingSet?: AIWorkingSetEnvelope | null; - replaceTargetBlock?: boolean; - replaceBlockIds?: readonly string[]; - }, - ): AIMutationReceipt { - let fastApplyFallbackMode: "plain-markdown" | null = null; - if ( - contentFormat === "markdown" && - options?.applyStrategy === "markdown-fast-apply" && - (options?.replaceBlockIds?.length ?? 0) === 0 - ) { - const fastApplyReceipt = this._commitBufferedMarkdownFastApply( - blockId, - text, - mutationMode, - sessionId, - options.workingSet ?? null, - ); - if (fastApplyReceipt) { - return fastApplyReceipt; - } - if (!text.trim().startsWith(`<${MARKDOWN_FAST_APPLY_ROOT_TAG}>`)) { - // Backward compatibility: tolerate plain markdown when the model - // does not honor the fast-apply contract. - fastApplyFallbackMode = "plain-markdown"; - } else { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [ - "Fast apply contract could not be compiled safely.", - ], - }); - } - } - - const normalizedText = - contentFormat === "markdown" - ? normalizeFlowMarkdownOutput(text) - : text; - const scopedReplaceBlockIds = - contentFormat === "markdown" - ? (options?.replaceBlockIds?.filter( - (candidateBlockId, index, allBlockIds) => - allBlockIds.indexOf(candidateBlockId) === index && - this._editor.getBlock(candidateBlockId) != null, - ) ?? []) - : []; - if (contentFormat === "markdown" && scopedReplaceBlockIds.length > 0) { - if (normalizedText.trim().length > 0) { - const verification = this._verifyMarkdownFastApplyResult( - scopedReplaceBlockIds, - normalizedText, - ); - if (!verification.valid) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [ - "Scoped markdown replacement could not be verified safely.", - ], - }); - } - } - const ops = this._buildMarkdownScopedReplacementOps( - scopedReplaceBlockIds, - normalizedText, - ); - const scopedReplacementFallback = - this._summarizeFastApplyFallbackOps( - "scoped-replacement", - ops, - scopedReplaceBlockIds.length, - ); - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - this._recordFastApplyDebug({ - executionPath: "scoped-replacement", - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: ops.length > 0 ? "staged_suggestions" : "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - this._recordFastApplyDebug({ - executionPath: "scoped-replacement", - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: ops.length > 0 ? "applied" : "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - if ( - contentFormat === "markdown" && - (mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review") && - this._applySuggestedMarkdownPlaceholderReplacement( - blockId, - normalizedText, - sessionId, - options?.replaceTargetBlock, - options?.replaceBlockIds, - ) - ) { - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - [], - ), - }); - } - return buildMutationReceipt({ - status: "staged_suggestions", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - const ops = - contentFormat === "markdown" - ? this._buildMarkdownBlockGenerationOps( - blockId, - normalizedText, - options?.replaceTargetBlock, - options?.replaceBlockIds, - ) - : this._buildTextBlockGenerationOps( - blockId, - normalizedText, - options?.insertionOffset, - ); - if (ops.length === 0) { - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - ops, - ), - }); - } - return buildMutationReceipt({ - status: "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - ops, - ), - }); - } - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - ops, - ), - }); - } - return buildMutationReceipt({ - status: "applied", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - private _commitBufferedMarkdownFastApply( - blockId: string, - text: string, - mutationMode: NonNullable, - sessionId: string | undefined, - workingSet: AIWorkingSetEnvelope | null, - ): AIMutationReceipt | null { - const fastApplyScope = this._resolveMarkdownFastApplyScope( - blockId, - workingSet, - ); - if (!fastApplyScope) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - fallbackReason: "missing-scope", - }); - return null; - } - - const patchPlan = parseMarkdownPatchPlanContract(text); - if (patchPlan) { - const validation = validateDocumentMutationPlanShape( - patchPlan, - this._buildPlanValidationContext( - blockId, - fastApplyScope.blockIds, - ), - ); - if (!validation.valid) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - fallbackReason: "invalid-patch-plan", - verificationFailureReason: validation.issues[0]?.message, - }); - return null; - } - - const execution = buildDocumentMutationPlanExecution( - this._editor, - patchPlan, - ); - if (execution.issues.length > 0) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - fallbackReason: "patch-plan-execution", - verificationFailureReason: execution.issues[0]?.message, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return null; - } - - const verification = this._verifyFlowPatchPlanResult( - patchPlan, - execution.ops, - fastApplyScope.blockIds, - ); - if (!verification.valid) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - fallbackReason: "verification-failed", - verificationFailureReason: verification.reason, - untouchedBlockMutationCount: - verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return null; - } - - if (execution.ops.length === 0) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: - verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return buildMutationReceipt({ - status: "noop", - ops: execution.ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(execution.ops, sessionId); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: - verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return buildMutationReceipt({ - status: "staged_suggestions", - ops: execution.ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - this._editor.apply(execution.ops, { - origin: "ai", - undoGroup: true, - }); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: - verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return buildMutationReceipt({ - status: "applied", - ops: execution.ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - const contract = parseMarkdownFastApplyContract(text); - if (!contract) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - fallbackReason: "unparseable-contract", - }); - return null; - } - - const merged = applyMarkdownFastApply({ - originalMarkdown: fastApplyScope.markdown, - contract, - }); - if (!merged.success || !merged.mergedMarkdown) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - confidence: merged.confidence, - fallbackReason: merged.fallbackReason ?? "merge-failed", - verificationFailureReason: merged.issues[0], - }); - return null; - } - - const verification = this._verifyMarkdownFastApplyResult( - fastApplyScope.blockIds, - merged.mergedMarkdown, - ); - if (!verification.valid) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - fallbackReason: "verification-failed", - verificationFailureReason: verification.reason, - untouchedBlockMutationCount: 0, - }); - return null; - } - - const ops = this._buildMarkdownScopedReplacementOps( - fastApplyScope.blockIds, - merged.mergedMarkdown, - ); - const scopedReplacementFallback = this._summarizeFastApplyFallbackOps( - "scoped-replacement", - ops, - fastApplyScope.blockIds.length, - ); - if (ops.length === 0) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - untouchedBlockMutationCount: 0, - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - untouchedBlockMutationCount: 0, - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - untouchedBlockMutationCount: 0, - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: "applied", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - private _resolveMarkdownFastApplyScope( - blockId: string, - workingSet: AIWorkingSetEnvelope | null, - ): { markdown: string; blockIds: string[] } | null { - const context = - workingSet?.context && typeof workingSet.context === "object" - ? (workingSet.context as { - markdown?: string | null; - retrievedSpan?: AIWorkingSetRetrievedSpan | null; - markdownWindow?: { - blockIds?: string[]; - } | null; - }) - : null; - const markdown = context?.markdown?.trim() ?? ""; - const blockIds = context?.retrievedSpan?.blockIds?.length - ? context.retrievedSpan.blockIds - : context?.markdownWindow?.blockIds?.length - ? context.markdownWindow.blockIds - : [blockId]; - if (markdown.length === 0 || blockIds.length === 0) { - return null; - } - return { - markdown, - blockIds: [...new Set(blockIds)], - }; - } - - private _buildPlanValidationContext( - blockId: string, - scopeBlockIds: readonly string[], - ): Parameters[1] { - const knownBlockTypes = this._editor.schema - .allBlocks() - .filter((schema) => - shouldExposeBlockInTooling( - this._editor.documentProfile, - schema, - ), - ) - .map((schema) => schema.type); - const editableTargetBlockIds = scopeBlockIds.filter((targetBlockId) => { - const block = this._editor.getBlock(targetBlockId); - if (!block) { - return false; - } - const schema = this._editor.schema.resolve(block.type); - return shouldExposeBlockInTooling( - this._editor.documentProfile, - schema, - ); - }); - - return { - documentProfile: this._editor.documentProfile, - targetKind: this._resolvePlanValidationTargetKind(blockId), - knownBlockTypes, - allowedTargetBlockIds: [...scopeBlockIds], - editableTargetBlockIds, - }; - } - - private _resolvePlanValidationTargetKind(blockId: string): AITargetKind { - const blockType = this._editor.getBlock(blockId)?.type ?? null; - if (blockType === "database") { - return "database"; - } - if (blockType === "table") { - return "table"; - } - return "block"; - } - - private _verifyMarkdownFastApplyResult( - blockIds: readonly string[], - markdown: string, - ): { valid: boolean; reason?: string } { - if (markdown.trim().length === 0) { - return { valid: false, reason: "empty-merged-markdown" }; - } - const startBlockId = blockIds[0]; - const verificationResult = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: markdown, - position: startBlockId ? { before: startBlockId } : undefined, - surface: "ai-markdown-fast-apply-verify", - }); - if (verificationResult.blocks.length === 0) { - return { - valid: false, - reason: "markdown-parse-produced-no-blocks", - }; - } - return { valid: true }; - } - - private _verifyFlowPatchPlanResult( - plan: { - edits: Array<{ - locator: { blockId?: string; blockIds?: string[] }; - }>; - }, - ops: readonly DocumentOp[], - scopeBlockIds: readonly string[], - ): { - valid: boolean; - reason?: string; - untouchedBlockMutationCount: number; - } { - const targetedBlockIds = new Set( - plan.edits.flatMap((edit) => [ - ...(edit.locator.blockId ? [edit.locator.blockId] : []), - ...(edit.locator.blockIds ?? []), - ]), - ); - const scopeSet = new Set(scopeBlockIds); - const mutatedExistingBlockIds = new Set(); - const outOfScopeMutations = new Set(); - const createdBlockIds = new Set(); - - for (const op of ops) { - if (op.type === "insert-block") { - createdBlockIds.add(op.blockId); - } - for (const blockId of this._readBlockIdsFromOp(op)) { - if (scopeSet.has(blockId)) { - mutatedExistingBlockIds.add(blockId); - } else if ( - !createdBlockIds.has(blockId) && - op.type !== "insert-block" - ) { - outOfScopeMutations.add(blockId); - } - } - } - - if (outOfScopeMutations.size > 0) { - return { - valid: false, - reason: `flow-patch-mutated-outside-scope:${[...outOfScopeMutations].join(",")}`, - untouchedBlockMutationCount: 0, - }; - } - - const untouchedBlockMutationCount = [...mutatedExistingBlockIds].filter( - (blockId) => !targetedBlockIds.has(blockId), - ).length; - return { - valid: untouchedBlockMutationCount === 0, - reason: - untouchedBlockMutationCount > 0 - ? "flow-patch-mutated-untargeted-blocks" - : undefined, - untouchedBlockMutationCount, - }; - } - - private _buildMarkdownScopedReplacementOps( - blockIds: readonly string[], - text: string, - ): DocumentOp[] { - const startBlockId = blockIds[0]; - if (!startBlockId) { - return []; - } - const { ops } = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: text, - position: { before: startBlockId }, - surface: "ai-markdown-fast-apply", - }); - return [ - ...ops, - ...blockIds.map( - (currentBlockId) => - ({ - type: "delete-block", - blockId: currentBlockId, - }) satisfies DocumentOp, - ), - ]; - } - - private _summarizeFastApplyFallbackOps( - kind: "scoped-replacement" | "plain-markdown", - ops: readonly DocumentOp[], - targetBlockCount?: number, - ): { - kind: "scoped-replacement" | "plain-markdown"; - opsCount: number; - insertedBlockCount: number; - deletedBlockCount: number; - targetBlockCount?: number; - } { - let insertedBlockCount = 0; - let deletedBlockCount = 0; - for (const op of ops) { - if (op.type === "insert-block") { - insertedBlockCount += 1; - } else if (op.type === "delete-block") { - deletedBlockCount += 1; - } - } - return { - kind, - opsCount: ops.length, - insertedBlockCount, - deletedBlockCount, - targetBlockCount, - }; - } - - private _readBlockIdsFromOp(op: DocumentOp): string[] { - const blockIds = new Set(); - if ("blockId" in op && typeof op.blockId === "string") { - blockIds.add(op.blockId); - } - if ("targetBlockId" in op && typeof op.targetBlockId === "string") { - blockIds.add(op.targetBlockId); - } - if ("sourceBlockId" in op && typeof op.sourceBlockId === "string") { - blockIds.add(op.sourceBlockId); - } - return [...blockIds]; - } - - private _recordFastApplyDebug( - overrides: Partial< - NonNullable["fastApply"]> - >, - ): void { - const activeGeneration = this._state.activeGeneration; - if (!activeGeneration?.debug) { - return; - } - const currentFastApply = activeGeneration.debug.fastApply ?? { - attempted: false, - succeeded: false, - }; - this._resolveActiveGeneration({ - debug: { - ...activeGeneration.debug, - fastApply: { - ...currentFastApply, - ...overrides, - }, - }, - }); - } - - private _applySuggestedMarkdownPlaceholderReplacement( - blockId: string, - text: string, - sessionId?: string, - replaceTargetBlock?: boolean, - replaceBlockIds?: readonly string[], - ): DocumentOp[] | null { - const targetBlock = this._editor.getBlock(blockId); - if ( - !replaceTargetBlock && - !shouldReplaceEmptyMarkdownTarget(targetBlock) - ) { - return null; - } - - const { ops } = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: text, - position: { before: blockId }, - surface: "ai-markdown", - }); - if (ops.length === 0) { - return null; - } - - const deleteBlockIds = resolveReplacementDeleteBlockIds( - this._editor, - blockId, - replaceBlockIds, - ); - const replacementOps = [ - ...ops, - ...deleteBlockIds.map((nextBlockId) => ({ - type: "delete-block" as const, - blockId: nextBlockId, - })), - ] satisfies DocumentOp[]; - this._applySuggestedAIOps(replacementOps, sessionId); - return replacementOps; - } - - private _refreshStreamingMarkdownBlockPreview( - blockId: string, - text: string, - mutationMode: NonNullable, - sessionId: string | undefined, - baselineSuggestionIds: ReadonlySet, - previewSuggestionIds: readonly string[], - previousNormalizedText: string, - replaceTargetBlock?: boolean, - replaceBlockIds?: readonly string[], - ): { suggestionIds: string[]; normalizedText: string } { - const normalizedText = normalizeFlowMarkdownOutput(text); - if (normalizedText === previousNormalizedText) { - return { - suggestionIds: [...previewSuggestionIds], - normalizedText, - }; - } - - this._rejectPreviewSuggestions(previewSuggestionIds); - - if ( - normalizedText.trim().length === 0 && - !replaceTargetBlock && - (replaceBlockIds?.length ?? 0) === 0 - ) { - return { - suggestionIds: [], - normalizedText, - }; - } - - this._commitBufferedBlockGeneration( - blockId, - normalizedText, - mutationMode, - "markdown", - sessionId, - { replaceTargetBlock, replaceBlockIds }, - ); - - return { - suggestionIds: this.getSuggestions() - .map((item) => item.id) - .filter( - (suggestionId) => !baselineSuggestionIds.has(suggestionId), - ), - normalizedText, - }; - } - - private _commitStructuredPlan( - ops: DocumentOp[], - reviewSafe: boolean, - mutationMode: NonNullable, - adapterId: NonNullable, - blockClass: NonNullable, - transportKind: NonNullable, - ): AIMutationReceipt { - if (ops.length === 0) { - return buildMutationReceipt({ - status: "noop", - ops, - adapterId, - blockClass, - transportKind, - }); - } - - if (mutationMode === "direct-stream") { - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - return buildMutationReceipt({ - status: "applied", - ops, - adapterId, - blockClass, - transportKind, - }); - } - - if (reviewSafe) { - this._applySuggestedAIOps(ops); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId, - blockClass, - transportKind, - }); - } - return buildMutationReceipt({ - status: "staged_review", - ops, - adapterId, - blockClass, - transportKind, - }); - } - - private _buildFallbackMutationReceipt(input: { - currentText: string; - suggestionIds: readonly string[]; - reviewItems: readonly StructuralReviewItem[]; - planExecutionIssueCount: number; - adapterId: NonNullable; - blockClass: NonNullable; - transportKind: NonNullable; - }): AIMutationReceipt { - if (input.planExecutionIssueCount > 0) { - return buildMutationReceipt({ - status: "invalid", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - issues: ["The generated mutation plan could not be executed."], - }); - } - if (input.reviewItems.length > 0) { - return buildMutationReceipt({ - status: "staged_review", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - }); - } - if (input.suggestionIds.length > 0) { - return buildMutationReceipt({ - status: "staged_suggestions", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - }); - } - return buildMutationReceipt({ - status: input.currentText.trim().length > 0 ? "applied" : "noop", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - }); - } - - private async _buildWorkingSet( - toolRuntime: ToolRuntime, - route: ReturnType, - target: GenerationTarget, - blockId: string, - prompt: string, - ): Promise { - const selectionSignature = this._createSelectionSignature( - this._editor.selection, - ); - if (target.type === "selection") { - const trackedBlockIds = [ - ...new Set(target.selection.toRange().blockRange), - ]; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "selection", - routeConfidence: route.confidence, - context: { - selection: target.selection, - selectedText: resolveSelectionText( - this._editor, - target.selection, - ), - }, - trackedBlockIds, - blockRevisions: this._captureBlockRevisions(trackedBlockIds), - selectionSignature, - }; - } - - if (route.useCursorContext) { - const retrievedSpan = - await this._resolveMarkdownFastApplyRetrievedSpan( - toolRuntime, - route, - blockId, - prompt, - ); - if ( - route.applyStrategy === "markdown-fast-apply" && - retrievedSpan - ) { - const context = (await toolRuntime.executeTool( - "get_context", - { - format: "markdown", - includeSelection: true, - includeSuggestions: this._state.suggestMode, - range: retrievedSpan.range, - }, - {} as never, - )) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "cursor-context", - context: { - ...context, - retrievedSpan, - }, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: retrievedSpan.blockIds.length, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: - context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(retrievedSpan.blockIds)], - blockRevisions: this._captureBlockRevisions( - retrievedSpan.blockIds, - ), - selectionSignature, - }; - } - const context = (await toolRuntime.executeTool( - "get_cursor_context", - { includeSuggestions: this._state.suggestMode }, - {} as never, - )) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - const trackedBlockIds = [ - blockId, - ...(context.surroundingBlocks ?? []).map((block) => block.id), - ]; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "cursor-context", - context, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: - context.surroundingBlocks?.length ?? 0, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: - context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(trackedBlockIds)], - blockRevisions: this._captureBlockRevisions(trackedBlockIds), - selectionSignature, - }; - } - - if (route.useDocumentSummary) { - const retrievedSpan = - await this._resolveMarkdownFastApplyRetrievedSpan( - toolRuntime, - route, - blockId, - prompt, - ); - if ( - route.applyStrategy === "markdown-fast-apply" && - retrievedSpan - ) { - const context = (await toolRuntime.executeTool( - "get_context", - { - format: "markdown", - includeSelection: true, - includeSuggestions: this._state.suggestMode, - range: retrievedSpan.range, - }, - {} as never, - )) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "document-summary", - context: { - ...context, - retrievedSpan, - }, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: retrievedSpan.blockIds.length, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: - context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(retrievedSpan.blockIds)], - blockRevisions: this._captureBlockRevisions( - retrievedSpan.blockIds, - ), - selectionSignature, - }; - } - const context = (await toolRuntime.executeTool( - "get_context", - { - format: "markdown", - includeSelection: true, - includeSuggestions: this._state.suggestMode, - range: { - startBlockId: blockId, - endBlockId: blockId, - }, - }, - {} as never, - )) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - const trackedBlockIds = [ - blockId, - ...(context.surroundingBlocks ?? []).map((block) => block.id), - ]; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "document-summary", - context, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: - context.surroundingBlocks?.length ?? 0, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: - context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(trackedBlockIds)], - blockRevisions: this._captureBlockRevisions(trackedBlockIds), - selectionSignature, - }; - } - - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "document-summary", - context: null, - routeConfidence: route.confidence, - trackedBlockIds: [blockId], - blockRevisions: this._captureBlockRevisions([blockId]), - selectionSignature, - }; - } - - private _refineRouteWithWorkingSet( - route: ReturnType, - workingSet: AIWorkingSetEnvelope | null, - ): ReturnType { - if (!workingSet?.context || typeof workingSet.context !== "object") { - return route; - } - const context = workingSet.context as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - return refineRouteWithNavigator(route, { - surroundingBlockCount: context.surroundingBlocks?.length ?? 0, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: - context.structuredTarget?.target?.kind ?? null, - }); - } - - private _validateWorkingSet( - route: ReturnType, - target: GenerationTarget, - workingSet: AIWorkingSetEnvelope | null, - ): { valid: boolean; canRefresh: boolean; reason?: string } { - if (!workingSet) { - return { valid: true, canRefresh: false }; - } - - const selectionSignature = this._createSelectionSignature( - this._editor.selection, - ); - const selectionChanged = - workingSet.selectionSignature !== selectionSignature; - const revisionChanged = - workingSet.documentVersion !== this._documentVersion || - workingSet.trackedBlockIds.some( - (blockId) => - this._editor.getBlockRevision(blockId) !== - workingSet.blockRevisions[blockId], - ); - - if (!selectionChanged && !revisionChanged) { - return { valid: true, canRefresh: false }; - } - - if ( - route.lane === "selection-rewrite" || - route.lane === "cursor-context" - ) { - return { - valid: false, - canRefresh: false, - reason: selectionChanged - ? "selection-provenance-changed" - : "local-context-changed", - }; - } - - return { - valid: false, - canRefresh: target.type === "block", - reason: revisionChanged - ? "document-revision-mismatch" - : "selection-changed", - }; - } - - private _resolveMarkdownFastApplyWindow( - route: ReturnType, - blockId: string, - ): { - range: { startBlockId: string; endBlockId: string }; - blockIds: string[]; - } | null { - const blocks = Array.from(this._editor.blocks()); - const blockIndex = blocks.findIndex((block) => block.id === blockId); - if (blockIndex === -1) { - return null; - } - - const radius = - route.targetKind === "table" - ? 0 - : route.intent === "continue" - ? 0 - : route.intent === "rewrite" || - route.intent === "local-edit" - ? 1 - : 0; - const startIndex = Math.max(0, blockIndex - radius); - const endIndex = Math.min(blocks.length - 1, blockIndex + radius); - const blockIds = blocks - .slice(startIndex, endIndex + 1) - .map((block) => block.id); - return { - range: { - startBlockId: blockIds[0] ?? blockId, - endBlockId: blockIds[blockIds.length - 1] ?? blockId, - }, - blockIds, - }; - } - - private async _resolveMarkdownFastApplyRetrievedSpan( - toolRuntime: ToolRuntime, - route: ReturnType, - blockId: string, - prompt: string, - ): Promise { - if (route.applyStrategy !== "markdown-fast-apply") { - return null; - } - - try { - const retrieved = (await toolRuntime.executeTool( - "retrieve_document_spans", - { - query: prompt, - maxResults: 1, - includeSuggestions: this._state.suggestMode, - activeBlockId: blockId, - targetBlockId: blockId, - }, - {} as never, - )) as { - spans?: AIWorkingSetRetrievedSpan[]; - }; - const retrievedSpan = retrieved.spans?.[0] ?? null; - if (retrievedSpan?.blockIds?.length) { - return retrievedSpan; - } - } catch { - // Older test fixtures or stale builds may not register the retriever yet. - } - - const markdownWindow = this._resolveMarkdownFastApplyWindow( - route, - blockId, - ); - if (!markdownWindow) { - return null; - } - return { - id: `span:${markdownWindow.blockIds.join(":")}`, - blockIds: markdownWindow.blockIds, - range: markdownWindow.range, - blockTypes: [], - headingPath: [], - preview: "", - markdown: "", - score: 0, - rationale: "window-fallback", - neighbors: { - beforeBlockId: null, - afterBlockId: null, - }, - }; - } - - private _applySuggestedAIOps( - ops: DocumentOp[], - sessionId?: string, - options?: { undoGroupId?: string }, - ): void { - const session = - sessionId != null - ? (this._state.sessions.find((item) => item.id === sessionId) ?? - null) - : null; - const activeGeneration = this._state.activeGeneration; - const undoGroupId = - options?.undoGroupId ?? - (session?.surface === "bottom-chat" && - activeGeneration != null && - activeGeneration.sessionId === sessionId - ? activeGeneration.undoGroupId - : undefined); - if (this._state.suggestMode && !sessionId) { - this._editor.apply(ops, { - origin: "ai", - ...(undoGroupId ? { undoGroupId } : { undoGroup: true }), - }); - return; - } - - const intercepted = interceptApplyForSuggestMode( - ops, - this._editor, - this._author, - "ai", - readModelId(this._model), - sessionId, - ); - const origin = sessionId ? AI_SESSION_SUGGESTION_ORIGIN : "extension"; - this._editor.apply(intercepted, { - origin, - ...(undoGroupId ? { undoGroupId } : { undoGroup: true }), - }); - } - - private _captureBlockRevisions(blockIds: string[]): Record { - return Object.fromEntries( - blockIds.map((trackedBlockId) => [ - trackedBlockId, - this._editor.getBlockRevision(trackedBlockId), - ]), - ); - } - - private _resolveContentFormat( - target: GenerationState["target"], - surface?: AISurface, - ): AIContentFormat { - if (target === "selection") { - return this._contentFormat.selectionRewrite; - } - return this._contentFormat.blockGeneration; - } - - private _buildTextBlockGenerationOps( - blockId: string, - text: string, - insertionOffset?: number, - ): DocumentOp[] { - const targetBlock = this._editor.getBlock(blockId); - const normalizedText = shouldTrimLeadingBlankBlockGenerationText( - targetBlock, - ) - ? trimLeadingBlankBlockGenerationText(text) - : text; - if (normalizedText.length === 0) { - return []; - } - return [ - { - type: "insert-text", - blockId, - offset: - insertionOffset ?? targetBlock?.textContent().length ?? 0, - text: normalizedText, - }, - ]; - } - - private _buildMarkdownBlockGenerationOps( - blockId: string, - text: string, - replaceTargetBlock?: boolean, - replaceBlockIds?: readonly string[], - ): DocumentOp[] { - const targetBlock = this._editor.getBlock(blockId); - if (!targetBlock) { - return []; - } - - const { ops } = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: text, - position: { after: blockId }, - surface: "ai-markdown", - }); - if ( - !replaceTargetBlock && - !shouldReplaceEmptyMarkdownTarget(targetBlock) - ) { - return ops; - } - - const deleteBlockIds = resolveReplacementDeleteBlockIds( - this._editor, - blockId, - replaceBlockIds, - ); - return [ - ...ops, - ...deleteBlockIds.map((nextBlockId) => ({ - type: "delete-block" as const, - blockId: nextBlockId, - })), - ]; - } - - private _createSelectionSignature( - selection: SelectionState, - ): string | null { - if (!selection) { - return null; - } - if (selection.type === "text") { - return [ - "text", - selection.anchor.blockId, - selection.anchor.offset, - selection.focus.blockId, - selection.focus.offset, - String(selection.isCollapsed), - ].join(":"); - } - if (selection.type === "block") { - return `block:${selection.blockIds.join(",")}`; - } - if (selection.type === "cell") { - return [ - "cell", - selection.blockId, - selection.anchor.row, - selection.anchor.col, - selection.head.row, - selection.head.col, - ].join(":"); - } - return selection.type; - } - - private _setState(partial: Partial): void { - const previousState = this._state; - const nextState = { ...this._state, ...partial }; - if (areAIControllerStatesEqual(previousState, nextState)) { - return; - } - this._state = nextState; - if ( - !this._isRestoringInlineHistory && - !this._pendingInlineHistoryRestore - ) { - this._recordInlineHistorySnapshot(previousState, nextState); - } - this._editor.requestDecorationUpdate(); - this._emit(); - } - - private _resolveActiveGeneration( - overrides: Partial, - ): void { - const activeGeneration = this._state.activeGeneration; - if (!activeGeneration) { - return; - } - - this._setState({ - activeGeneration: { - ...activeGeneration, - ...overrides, - plan: - overrides.planState === "none" || - overrides.planState === "rejected" - ? null - : (overrides.plan ?? activeGeneration.plan), - reviewItems: - overrides.planState === "none" || - overrides.planState === "rejected" - ? [] - : (overrides.reviewItems ?? - activeGeneration.reviewItems ?? - []), - structuredPreview: - overrides.planState === "none" || - overrides.planState === "rejected" - ? null - : (overrides.structuredPreview ?? - activeGeneration.structuredPreview ?? - null), - suggestionIds: - overrides.suggestionIds ?? - activeGeneration.suggestionIds ?? - [], - }, - }); - } - - private _resolveSessionTurn( - sessionId: string, - turnId: string, - resolution: AISessionResolution, - options?: { finalizeSession?: boolean }, - ): boolean { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - const turn = session?.turns.find((item) => item.id === turnId); - if (!session || !turn) { - return false; - } - const isBottomChatDocumentTurn = - session.surface === "bottom-chat" && - (turn.target === "document" || - turn.operation?.kind === "document-transform" || - (turn.operation?.kind === "rewrite-selection" && - turn.operation.target.kind === "scoped-range" && - (turn.operation.target.scope === "document" || - turn.operation.target.contentFormat === "markdown"))); - const turnUndoGroupId = isBottomChatDocumentTurn - ? turn.undoGroupId - : undefined; - const turnSuggestionResolutionOrigin = - turnUndoGroupId != null ? AI_SESSION_SUGGESTION_ORIGIN : undefined; - const undoHistoryBeforeSnapshot = this._undoHistoryMetadata - ? this._createInlineTurnUndoBeforeSnapshot(sessionId, turnId) - : null; - const refreshedInlineSelectionTarget = - session.surface === "inline-edit" && resolution === "accept" - ? (resolveAcceptedInlineSelectionTarget( - this._editor, - turn.operation, - turn.suggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor)) - : null; - const resolveSuggestionsForTurn = - resolution === "accept" - ? (suggestionIds: readonly string[]) => - acceptSuggestions(this._editor, suggestionIds, { - origin: turnSuggestionResolutionOrigin, - undoGroupId: turnUndoGroupId, - }) - : (suggestionIds: readonly string[]) => - rejectSuggestions(this._editor, suggestionIds, { - origin: turnSuggestionResolutionOrigin, - undoGroupId: turnUndoGroupId, - }); - const resolveReviewItems = - resolution === "accept" - ? (reviewItemIds: readonly string[]) => - this.acceptReviewItems(reviewItemIds) - : (reviewItemIds: readonly string[]) => - this.rejectReviewItems(reviewItemIds); - let resolved = false; - resolved = resolveSuggestionsForTurn(turn.suggestionIds) || resolved; - if ( - this._state.activeGeneration?.sessionId === sessionId && - this._state.activeGeneration.turnId === turnId && - this._state.activeGeneration.planState === "validated" && - turn.reviewItemIds.length > 0 - ) { - resolved = resolveReviewItems(turn.reviewItemIds) || resolved; - } - if (!resolved) { - return false; - } - this._updateSessionTurn(sessionId, turnId, { - status: resolution === "accept" ? "accepted" : "rejected", - suggestionIds: [], - reviewItemIds: [], - structuredPreview: null, - anchor: refreshedInlineSelectionTarget - ? resolveSessionAnchor(refreshedInlineSelectionTarget.selection) - : undefined, - selection: refreshedInlineSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineSelectionTarget.selection, - ) - : undefined, - }); - if (refreshedInlineSelectionTarget) { - this._updateSession(sessionId, { - target: refreshedInlineSelectionTarget, - anchor: resolveSessionAnchor( - refreshedInlineSelectionTarget.selection, - ), - contextualPrompt: session.contextualPrompt - ? { - ...session.contextualPrompt, - anchor: resolveContextualPromptAnchor( - refreshedInlineSelectionTarget, - ), - } - : undefined, - }); - } - if (options?.finalizeSession === false) { - if (undoHistoryBeforeSnapshot) { - this._undoHistoryMetadata?.setCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - { - before: undoHistoryBeforeSnapshot, - after: createInlineHistorySnapshot( - this._editor, - this._state.sessions, - this._state.activeSessionId ?? null, - this._documentVersion, - { kind: "document-coupled" }, - ), - }, - ); - } - return true; - } - const nextSession = - this._state.sessions.find((item) => item.id === sessionId) ?? - session; - this._updateSession(sessionId, { - status: "complete", - contextualPrompt: closeInlineSessionPrompt(nextSession), - }); - if (undoHistoryBeforeSnapshot) { - this._undoHistoryMetadata?.setCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - { - before: undoHistoryBeforeSnapshot, - after: createInlineHistorySnapshot( - this._editor, - this._state.sessions, - this._state.activeSessionId ?? null, - this._documentVersion, - { kind: "document-coupled" }, - ), - }, - ); - } - return true; - } - - private _createInlineTurnUndoBeforeSnapshot( - sessionId: string, - turnId: string, - ): AIInlineHistorySnapshot { - const session = - this._state.sessions.find((item) => item.id === sessionId) ?? null; - if (session?.surface === "inline-edit") { - const reviewSnapshot = - this._findInlineHistorySnapshotForResolvedTurn(session, "undo"); - if (reviewSnapshot) { - const restoredSessions = reviewSnapshot.sessions.map( - (snapshotSession) => { - if ( - snapshotSession.id !== sessionId || - snapshotSession.surface !== "inline-edit" || - !snapshotSession.contextualPrompt - ) { - return snapshotSession; - } - const snapshotTurn = - snapshotSession.turns.find( - (turn) => turn.id === turnId, - ) ?? null; - if (!snapshotTurn) { - return snapshotSession; - } - return { - ...snapshotSession, - contextualPrompt: { - ...snapshotSession.contextualPrompt, - composer: { - ...snapshotSession.contextualPrompt - .composer, - draftPrompt: - snapshotSession.contextualPrompt - .composer.draftPrompt || - snapshotTurn.prompt, - }, - }, - }; - }, - ); - return createInlineHistorySnapshot( - this._editor, - restoredSessions, - sessionId, - this._documentVersion, - { kind: "document-coupled" }, - ); - } - } - const historySessions = this._state.sessions.map((session) => { - if ( - session.id !== sessionId || - session.surface !== "inline-edit" || - !session.contextualPrompt - ) { - return session; - } - const targetTurn = - session.turns.find((turn) => turn.id === turnId) ?? null; - if (targetTurn?.status !== "review") { - return session; - } - return { - ...session, - contextualPrompt: { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: true, - isSubmitting: false, - }, - }, - }; - }); - const nextActiveSessionId = historySessions.some( - (session) => - session.id === sessionId && - session.surface === "inline-edit" && - session.contextualPrompt?.composer.isOpen, - ) - ? sessionId - : (this._state.activeSessionId ?? null); - return createInlineHistorySnapshot( - this._editor, - historySessions, - nextActiveSessionId, - this._documentVersion, - { kind: "document-coupled" }, - ); - } - - private _updateSession( - sessionId: string, - overrides: Partial, - ): void { - const nextSessions = this._state.sessions.map((session) => - session.id !== sessionId - ? session - : { - ...session, - ...overrides, - contextualPrompt: - (overrides.contextualPrompt ?? - session.contextualPrompt) - ? { - ...(session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? - session.target, - )), - ...(overrides.contextualPrompt ?? {}), - anchor: { - ...( - session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? - session.target, - ) - ).anchor, - ...(overrides.contextualPrompt - ?.anchor ?? {}), - }, - composer: { - ...( - session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? - session.target, - ) - ).composer, - ...(overrides.contextualPrompt - ?.composer ?? {}), - isSubmitting: - overrides.contextualPrompt - ?.composer?.isSubmitting ?? - (overrides.status === - "streaming" - ? true - : overrides.status - ? false - : ( - session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? - session.target, - ) - ).composer - .isSubmitting), - }, - } - : undefined, - updatedAt: Date.now(), - metrics: { - ...session.metrics, - ...(overrides.metrics ?? {}), - }, - }, - ); - if (nextSessions === this._state.sessions) { - return; - } - this._setState({ - sessions: nextSessions, - activeSessionId: - this._state.activeSessionId === sessionId || - this._state.activeSessionId == null - ? sessionId - : this._state.activeSessionId, - }); - } - - private _recordSessionFastApplyMetrics( - sessionId: string, - fastApply: FastApplyDebugState | undefined, - ): void { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session) { - return; - } - this._updateSession(sessionId, { - metrics: { - ...session.metrics, - fastApply: accumulateSessionFastApplyMetrics( - session.metrics.fastApply, - fastApply, - ), - }, - }); - } - - private _updateSessionTurn( - sessionId: string, - turnId: string, - overrides: Partial, - ): void { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if (!session) { - return; - } - const nextTurns = session.turns.map((turn) => - turn.id !== turnId - ? turn - : { - ...turn, - ...overrides, - }, - ); - if (areStructuredValuesEqual(session.turns, nextTurns)) { - return; - } - const pendingSuggestionIds = [ - ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), - ]; - const pendingReviewItemIds = [ - ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), - ]; - this._updateSession(sessionId, { - turns: nextTurns, - pendingSuggestionIds, - pendingReviewItemIds, - }); - } - - private _syncSessionsFromDocument(): boolean { - if (this._state.sessions.length === 0) { - return false; - } - const nextSessions = this._state.sessions.map((session) => { - const nextTurns = session.turns.map((turn) => { - const suggestionIds = turn.suggestionIds.filter( - (sessionSuggestionId) => - this._suggestions.some( - (suggestion) => - suggestion.id === sessionSuggestionId, - ), - ); - const activeGenerationMatchesTurn = - this._state.activeGeneration?.sessionId === session.id && - this._state.activeGeneration.turnId === turn.id; - const activeGenerationForTurn = activeGenerationMatchesTurn - ? this._state.activeGeneration - : null; - const reviewItemIds = activeGenerationForTurn - ? (activeGenerationForTurn.reviewItems ?? []) - .map((item) => item.id) - .filter((id) => turn.reviewItemIds.includes(id)) - : []; - const structuredPreview = activeGenerationForTurn - ? (activeGenerationForTurn.structuredPreview ?? - turn.structuredPreview ?? - null) - : turn.reviewItemIds.length > 0 - ? (turn.structuredPreview ?? null) - : null; - return { - ...turn, - suggestionIds, - reviewItemIds, - structuredPreview, - }; - }); - const pendingSuggestionIds = [ - ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), - ]; - const pendingReviewItemIds = [ - ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), - ]; - const nextStatus = - pendingSuggestionIds.length === 0 && - pendingReviewItemIds.length === 0 && - session.status === "streaming" - ? "complete" - : session.status; - return { - ...session, - status: nextStatus, - turns: nextTurns, - pendingSuggestionIds, - pendingReviewItemIds, - }; - }); - if (areSessionsEqual(this._state.sessions, nextSessions)) { - return false; - } - this._setState({ - sessions: nextSessions, - }); - return true; - } - - private _setStreamEvents(nextEvents: readonly AIStreamEvent[]): void { - this._streamEvents = nextEvents; - this._emitStreamEvents(); - } - - private _appendStreamEvent(event: AIStreamEvent): void { - const lastEvent = this._streamEvents[this._streamEvents.length - 1]; - if ( - lastEvent?.type === "status" && - event.type === "status" && - lastEvent.generationId === event.generationId && - lastEvent.status === event.status - ) { - return; - } - const nextEvents = - this._streamEvents.length >= MAX_STREAM_EVENTS - ? [...this._streamEvents.slice(-(MAX_STREAM_EVENTS - 1)), event] - : [...this._streamEvents, event]; - this._setStreamEvents(nextEvents); - } - - private _emit(): void { - for (const listener of this._listeners) { - listener(); - } - for (const listener of this._sessionListeners) { - listener(); - } - } - - private _emitStreamEvents(): void { - for (const listener of this._streamEventListeners) { - listener(); - } - } - - private _syncSuggestionsFromDocument(): boolean { - const nextSuggestions = readAllSuggestions(this._editor); - if (areSuggestionsEqual(this._suggestions, nextSuggestions)) { - return false; - } - this._suggestions = nextSuggestions; - return true; - } - - private _recordInlineHistorySnapshot( - previousState: AIControllerState, - nextState: AIControllerState, - ): void { - if (!didInlineHistoryCheckpointChange(previousState, nextState)) { - return; - } - if ( - previousState.sessions === nextState.sessions && - previousState.activeSessionId === nextState.activeSessionId - ) { - return; - } - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; - const nextHistory = this._inlineHistory.slice( - 0, - this._inlineHistoryIndex + 1, - ); - if (nextHistory.length === 0) { - const baselineSnapshot = createInlineHistorySnapshot( - this._editor, - previousState.sessions, - previousState.activeSessionId ?? null, - this._documentVersion, - ); - nextHistory.push(baselineSnapshot); - } - const previousSnapshot = - nextHistory[nextHistory.length - 1] ?? currentSnapshot ?? null; - const snapshot = createInlineHistorySnapshot( - this._editor, - nextState.sessions, - nextState.activeSessionId ?? null, - this._documentVersion, - { - kind: - previousSnapshot?.documentVersion === this._documentVersion - ? "ui-local" - : "document-coupled", - }, - ); - if ( - currentSnapshot && - areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) - ) { - return; - } - const currentUndoMetadata = - this._undoHistoryMetadata?.getCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - ) ?? null; - const shouldPersistUndoSnapshot = - previousSnapshot != null && - (snapshot.kind === "document-coupled" || - currentUndoMetadata?.after?.documentVersion === - this._documentVersion); - if (shouldPersistUndoSnapshot && previousSnapshot) { - this._undoHistoryMetadata?.setCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - { - before: currentUndoMetadata?.before ?? previousSnapshot, - after: snapshot, - }, - ); - } - nextHistory.push(snapshot); - this._inlineHistory = nextHistory; - this._inlineHistoryIndex = nextHistory.length - 1; - } - - private _recordInlinePromptSubmissionCheckpoint( - sessionId: string, - prompt: string, - ): void { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if ( - !session || - session.surface !== "inline-edit" || - !session.contextualPrompt - ) { - return; - } - const checkpointState: AIControllerState = { - ...this._state, - activeSessionId: sessionId, - sessions: this._state.sessions.map((item) => - item.id !== sessionId - ? item - : { - ...item, - contextualPrompt: { - ...item.contextualPrompt!, - composer: { - ...item.contextualPrompt!.composer, - draftPrompt: prompt, - isOpen: true, - isSubmitting: false, - }, - }, - }, - ), - }; - const snapshot = createInlineHistorySnapshot( - this._editor, - checkpointState.sessions, - checkpointState.activeSessionId ?? null, - this._documentVersion, - { kind: "ui-local" }, - ); - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; - if ( - currentSnapshot && - areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) - ) { - return; - } - const nextHistory = this._inlineHistory.slice( - 0, - this._inlineHistoryIndex + 1, - ); - nextHistory.push(snapshot); - this._inlineHistory = nextHistory; - this._inlineHistoryIndex = nextHistory.length - 1; - } - - private _resolveInlineHistoryTargetIndex( - direction: AIInlineHistoryDirection, - options?: { shortcutOnly?: boolean }, - ): number { - const step = direction === "undo" ? -1 : 1; - if (!options?.shortcutOnly) { - return this._inlineHistoryIndex + step; - } - const currentSnapshot = - this._inlineHistory[this._inlineHistoryIndex] ?? null; - const scopedSessionId = this._resolveShortcutInlineHistorySessionId( - currentSnapshot, - direction, - ); - const waypoints = - this._buildInlineShortcutHistoryWaypoints(scopedSessionId); - if (waypoints.length === 0) { - return -1; - } - const currentWaypointIndex = - this._resolveCurrentInlineShortcutWaypointIndex( - waypoints, - scopedSessionId, - ); - if (currentWaypointIndex < 0) { - return -1; - } - const targetWaypoint = waypoints[currentWaypointIndex + step]; - return targetWaypoint?.representativeIndex ?? -1; - } - - private _resolveShortcutInlineHistorySessionId( - currentSnapshot: AIInlineHistorySnapshot | null, - direction: AIInlineHistoryDirection, - ): string | null { - const activeSession = this.getActiveSession(); - if (activeSession?.surface === "inline-edit") { - return activeSession.id; - } - const selection = this._editor.selection; - if ( - currentSnapshot && - selection?.type === "text" && - !selection.isCollapsed - ) { - const matchingSession = [...currentSnapshot.sessions] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - sessionSelectionMatches(session, selection), - ); - if (matchingSession) { - return matchingSession.id; - } - } - if ( - currentSnapshot?.activeSessionId && - currentSnapshot.sessions.some( - (session) => - session.id === currentSnapshot.activeSessionId && - session.surface === "inline-edit", - ) - ) { - return currentSnapshot.activeSessionId; - } - const currentInlineSession = - [...(currentSnapshot?.sessions ?? [])] - .reverse() - .find((session) => session.surface === "inline-edit") ?? null; - if (currentInlineSession) { - return currentInlineSession.id; - } - const step = direction === "undo" ? -1 : 1; - let searchIndex = this._inlineHistoryIndex + step; - while (searchIndex >= 0 && searchIndex < this._inlineHistory.length) { - const searchSnapshot = this._inlineHistory[searchIndex]; - const matchingSelectionSession = - selection?.type === "text" && !selection.isCollapsed - ? ([...(searchSnapshot?.sessions ?? [])] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - sessionSelectionMatches(session, selection), - ) ?? null) - : null; - if (matchingSelectionSession) { - return matchingSelectionSession.id; - } - const searchInlineSession = - [...(searchSnapshot?.sessions ?? [])] - .reverse() - .find((session) => session.surface === "inline-edit") ?? - null; - if (searchInlineSession) { - return searchInlineSession.id; - } - searchIndex += step; - } - return null; - } - - private _buildInlineShortcutHistoryWaypoints( - sessionId: string | null, - ): AIInlineShortcutHistoryWaypoint[] { - const waypoints: AIInlineShortcutHistoryWaypoint[] = []; - for (let index = 0; index < this._inlineHistory.length; index += 1) { - const snapshot = this._inlineHistory[index]; - if (!snapshot || snapshot.kind === "ui-local") { - continue; - } - const state = resolveInlineShortcutHistoryState( - snapshot, - sessionId, - ); - if (!state) { - continue; - } - const previousWaypoint = waypoints[waypoints.length - 1] ?? null; - if ( - previousWaypoint && - areInlineShortcutHistoryStatesEqual( - previousWaypoint.state, - state, - ) - ) { - previousWaypoint.endIndex = index; - if ( - shouldReplaceInlineShortcutWaypointRepresentative( - previousWaypoint.state, - this._inlineHistory[ - previousWaypoint.representativeIndex - ] ?? null, - snapshot, - ) - ) { - previousWaypoint.representativeIndex = index; - } - continue; - } - waypoints.push({ - startIndex: index, - endIndex: index, - representativeIndex: index, - state, - }); - } - return waypoints; - } - - private _resolveCurrentInlineShortcutWaypointIndex( - waypoints: readonly AIInlineShortcutHistoryWaypoint[], - sessionId: string | null, - ): number { - const currentSnapshot = - this._inlineHistory[this._inlineHistoryIndex] ?? null; - const currentState = currentSnapshot - ? resolveInlineShortcutHistoryState(currentSnapshot, sessionId) - : null; - if (currentState) { - const currentIndex = waypoints.findIndex( - (waypoint) => - this._inlineHistoryIndex >= waypoint.startIndex && - this._inlineHistoryIndex <= waypoint.endIndex && - areInlineShortcutHistoryStatesEqual( - waypoint.state, - currentState, - ), - ); - if (currentIndex >= 0) { - return currentIndex; - } - const matchingIndex = waypoints.findIndex((waypoint) => - areInlineShortcutHistoryStatesEqual( - waypoint.state, - currentState, - ), - ); - if (matchingIndex >= 0) { - return matchingIndex; - } - } - for (let index = waypoints.length - 1; index >= 0; index -= 1) { - if ( - waypoints[index]!.representativeIndex <= - this._inlineHistoryIndex - ) { - return index; - } - } - return waypoints.length > 0 ? 0 : -1; - } - - private _canHandleInlineHistoryShortcut( - direction: AIInlineHistoryDirection, - options?: { shortcutOnly?: boolean }, - ): boolean { - const targetIndex = this._resolveInlineHistoryTargetIndex( - direction, - options, - ); - const targetSnapshot = this._inlineHistory[targetIndex]; - if (!targetSnapshot) { - return false; - } - if (targetSnapshot.kind !== "ui-local") { - return true; - } - return direction === "undo" - ? !this._editor.undoManager.canUndo() - : !this._editor.undoManager.canRedo(); - } - - private _navigateInlineHistory( - direction: AIInlineHistoryDirection, - options?: { shortcutOnly?: boolean }, - ): boolean { - const targetIndex = this._resolveInlineHistoryTargetIndex( - direction, - options, - ); - const targetSnapshot = this._inlineHistory[targetIndex]; - if (!targetSnapshot) { - return false; - } - const currentSnapshot = - this._inlineHistory[this._inlineHistoryIndex] ?? null; - const shortcutSessionId = options?.shortcutOnly - ? this._resolveShortcutInlineHistorySessionId( - currentSnapshot, - direction, - ) - : null; - if (targetSnapshot.kind === "ui-local") { - this._applyInlineHistorySnapshot(targetSnapshot, { - historyTraversal: true, - }); - this._inlineHistoryIndex = targetIndex; - return true; - } - if ( - currentSnapshot && - currentSnapshot.documentVersion !== targetSnapshot.documentVersion - ) { - const targetState = resolveInlineShortcutHistoryState( - targetSnapshot, - shortcutSessionId ?? - targetSnapshot.sessionId ?? - targetSnapshot.activeSessionId ?? - null, - ); - this._pendingInlineHistoryRestore = { - direction, - targetSnapshotId: targetSnapshot.id, - targetDocumentVersion: targetSnapshot.documentVersion, - shortcutOnly: options?.shortcutOnly === true, - sessionId: shortcutSessionId, - targetState, - }; - const restored = - direction === "undo" - ? this._editor.undoManager.undo() - : this._editor.undoManager.redo(); - if (!restored) { - this._pendingInlineHistoryRestore = null; - } - return restored; - } - const resolvedTargetSnapshot = options?.shortcutOnly - ? this._resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot, - shortcutSessionId, - ) - : targetSnapshot; - this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { - historyTraversal: true, - }); - this._inlineHistoryIndex = targetIndex; - return true; - } - - private _applyInlineHistorySnapshot( - snapshot: AIInlineHistorySnapshot, - options?: { historyTraversal?: boolean }, - ): void { - this._isRestoringInlineHistory = true; - try { - const restoredSessions = cloneInlineHistorySessions( - this._editor, - snapshot.sessions, - ).map((session) => { - if ( - !options?.historyTraversal || - !session.contextualPrompt?.composer.isOpen - ) { - return session; - } - return { - ...session, - contextualPrompt: { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - openReason: "history" as const, - }, - }, - }; - }); - this._setState({ - status: "idle", - activeGeneration: null, - sessions: restoredSessions, - activeSessionId: snapshot.activeSessionId, - }); - } finally { - this._isRestoringInlineHistory = false; - } - } - - private _restoreInlineHistorySnapshotFromUndo( - snapshot: AIInlineHistorySnapshot, - ): void { - const targetIndex = this._inlineHistory.findIndex( - (item) => item.id === snapshot.id, - ); - if (targetIndex >= 0) { - this._inlineHistoryIndex = targetIndex; - this._applyInlineHistorySnapshot( - this._inlineHistory[targetIndex]!, - { - historyTraversal: true, - }, - ); - return; - } - this._applyInlineHistorySnapshot(snapshot, { historyTraversal: true }); - const nextHistory = this._inlineHistory.slice( - 0, - this._inlineHistoryIndex + 1, - ); - nextHistory.push(snapshot); - this._inlineHistory = nextHistory; - this._inlineHistoryIndex = nextHistory.length - 1; - } - - private _findInlineHistorySnapshotForResolvedTurn( - session: AISession, - direction: AIInlineHistoryDirection, - ): AIInlineHistorySnapshot | null { - const latestTurnId = - session.turns[session.turns.length - 1]?.id ?? null; - if (!latestTurnId) { - return null; - } - for ( - let index = this._inlineHistory.length - 1; - index >= 0; - index -= 1 - ) { - const snapshot = this._inlineHistory[index]; - const snapshotSession = - snapshot?.sessions.find( - (item) => - item.id === session.id && - item.surface === "inline-edit", - ) ?? null; - if (!snapshotSession) { - continue; - } - const snapshotTurn = - snapshotSession.turns.find( - (turn) => turn.id === latestTurnId, - ) ?? null; - if (!snapshotTurn) { - continue; - } - if ( - direction === "undo" && - snapshotSession.contextualPrompt?.composer.isOpen && - snapshotTurn.status === "review" - ) { - return snapshot; - } - if ( - direction === "redo" && - !snapshotSession.contextualPrompt?.composer.isOpen && - (snapshotTurn.status === "accepted" || - snapshotTurn.status === "rejected") - ) { - return snapshot; - } - } - return null; - } - - private _resolveInlineHistoryTraversalSnapshot( - targetSnapshot: AIInlineHistorySnapshot, - ): AIInlineHistorySnapshot { - if (targetSnapshot.kind === "ui-local") { - return targetSnapshot; - } - const scopedSessionId = - targetSnapshot.sessionId ?? targetSnapshot.activeSessionId; - const targetState = resolveInlineShortcutHistoryState( - targetSnapshot, - scopedSessionId, - ); - if (!targetState) { - return targetSnapshot; - } - let resolvedSnapshot = targetSnapshot; - for (const snapshot of this._inlineHistory) { - if (snapshot.documentVersion !== targetSnapshot.documentVersion) { - continue; - } - const snapshotState = resolveInlineShortcutHistoryState( - snapshot, - scopedSessionId, - ); - if ( - !snapshotState || - !areInlineShortcutHistoryStatesEqual(snapshotState, targetState) - ) { - continue; - } - if ( - shouldReplaceInlineShortcutWaypointRepresentative( - targetState, - resolvedSnapshot, - snapshot, - ) - ) { - resolvedSnapshot = snapshot; - } - } - return resolvedSnapshot; - } - - private _resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot: AIInlineHistorySnapshot, - fallbackSessionId?: string | null, - ): AIInlineHistorySnapshot { - const scopedSessionId = - targetSnapshot.sessionId ?? - targetSnapshot.activeSessionId ?? - fallbackSessionId ?? - null; - const targetState = resolveInlineShortcutHistoryState( - targetSnapshot, - scopedSessionId, - ); - if (targetState?.phase !== "none" || !scopedSessionId) { - return this._resolveInlineHistoryTraversalSnapshot(targetSnapshot); - } - return createInlineHistorySnapshot( - this._editor, - targetSnapshot.sessions.filter( - (session) => session.id !== scopedSessionId, - ), - targetSnapshot.activeSessionId === scopedSessionId - ? null - : targetSnapshot.activeSessionId, - targetSnapshot.documentVersion, - { kind: targetSnapshot.kind }, - ); - } - - private _scheduleQueuedInlineHistoryShortcutFlush(): void { - if ( - this._queuedInlineHistoryShortcutFlushScheduled || - this._queuedInlineHistoryShortcutDirections.length === 0 - ) { - return; - } - this._queuedInlineHistoryShortcutFlushScheduled = true; - queueMicrotask(() => { - this._queuedInlineHistoryShortcutFlushScheduled = false; - if (this._pendingInlineHistoryRestore) { - this._scheduleQueuedInlineHistoryShortcutFlush(); - return; - } - const nextDirection = - this._queuedInlineHistoryShortcutDirections.shift() ?? null; - if (!nextDirection) { - return; - } - this._navigateInlineHistory(nextDirection, { shortcutOnly: true }); - if (this._queuedInlineHistoryShortcutDirections.length > 0) { - this._scheduleQueuedInlineHistoryShortcutFlush(); - } - }); - } - - private _resolvePendingInlineHistoryRestoreTargetIndex( - request: AIInlineHistoryRestoreRequest, - ): number { - const exactTargetIndex = this._inlineHistory.findIndex( - (snapshot) => snapshot.id === request.targetSnapshotId, - ); - if (exactTargetIndex >= 0) { - return exactTargetIndex; - } - if (!request.targetState) { - return -1; - } - let resolvedTargetIndex = -1; - const scopedSessionId = - request.sessionId ?? request.targetState.sessionId; - for (let index = 0; index < this._inlineHistory.length; index += 1) { - const snapshot = this._inlineHistory[index]; - if (!snapshot || snapshot.kind === "ui-local") { - continue; - } - if (snapshot.documentVersion !== request.targetDocumentVersion) { - continue; - } - const snapshotState = resolveInlineShortcutHistoryState( - snapshot, - scopedSessionId ?? null, - ); - if ( - !snapshotState || - !areInlineShortcutHistoryStatesEqual( - snapshotState, - request.targetState, - ) - ) { - continue; - } - if ( - resolvedTargetIndex < 0 || - shouldReplaceInlineShortcutWaypointRepresentative( - request.targetState, - this._inlineHistory[resolvedTargetIndex] ?? null, - snapshot, - ) - ) { - resolvedTargetIndex = index; - } - } - return resolvedTargetIndex; - } - - private _handleHistoryApplied(event: HistoryAppliedEvent): void { - if ( - this._pendingInlineHistoryRestore && - this._pendingInlineHistoryRestore.direction === event.kind - ) { - const targetIndex = - this._resolvePendingInlineHistoryRestoreTargetIndex( - this._pendingInlineHistoryRestore, - ); - if (targetIndex >= 0) { - this._inlineHistoryIndex = targetIndex; - const targetSnapshot = this._inlineHistory[targetIndex]!; - const resolvedTargetSnapshot = this._pendingInlineHistoryRestore - .shortcutOnly - ? this._resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot, - this._pendingInlineHistoryRestore.sessionId ?? null, - ) - : this._resolveInlineHistoryTraversalSnapshot( - targetSnapshot, - ); - this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { - historyTraversal: true, - }); - } - this._pendingInlineHistoryRestore = null; - this._scheduleQueuedInlineHistoryShortcutFlush(); - return; - } - if (this._handledUndoHistoryRequestId === event.requestId) { - this._handledUndoHistoryRequestId = null; - return; - } - const selection = event.selection; - if (selection?.type !== "text" || selection.isCollapsed) { - return; - } - const matchingSession = [...this._state.sessions] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - session.status !== "cancelled" && - sessionSelectionMatches(session, selection), - ); - if (!matchingSession) { - return; - } - this._setInlineSessionComposerOpen(matchingSession.id, true, { - openReason: "history", - }); - } - - private _setInlineSessionComposerOpen( - sessionId: string, - isOpen: boolean, - options?: { openReason?: "user" | "history" }, - ): void { - const session = this._state.sessions.find( - (item) => item.id === sessionId, - ); - if ( - !session || - session.surface !== "inline-edit" || - !session.contextualPrompt - ) { - return; - } - const nextActiveSessionId = isOpen - ? sessionId - : this._state.activeSessionId === sessionId - ? null - : this._state.activeSessionId; - if ( - session.contextualPrompt.composer.isOpen === isOpen && - nextActiveSessionId === this._state.activeSessionId - ) { - return; - } - const nextSessions = this._state.sessions.map((item) => - item.id !== sessionId - ? item - : { - ...item, - contextualPrompt: { - ...item.contextualPrompt!, - composer: { - ...item.contextualPrompt!.composer, - isOpen, - openReason: isOpen - ? (options?.openReason ?? "user") - : item.contextualPrompt!.composer - .openReason, - }, - }, - updatedAt: Date.now(), - }, - ); - this._setState({ - sessions: nextSessions, - activeSessionId: nextActiveSessionId, - }); - } -} - -export function aiExtension(config: AIExtensionConfig = {}): Extension { - let unsubscribeBeforeApply: (() => void) | null = null; - let unsubscribeTrackedOrigins: (() => void) | null = null; - let controller: AIControllerImpl | null = null; - let inlineCompletion: AIInlineCompletionController | null = null; - let releaseInlineCompletion: (() => void) | null = null; - let inlineHistory: AIInlineHistoryService | null = null; - let reviewController: AIReviewService | null = null; - let activeEditor: Editor | null = null; - - return defineExtension({ - name: AI_EXTENSION_NAME, - dependencies: ["document-ops", "delta-stream", "undo"], - keyBindings: AI_SHORTCUT_KEY_BINDINGS, - - activateClient: async ({ editor }) => { - activeEditor = editor; - const inlineCompletionRegistration = - ensureInlineCompletionController(editor); - inlineCompletion = inlineCompletionRegistration.controller; - releaseInlineCompletion = inlineCompletionRegistration.release; - controller = new AIControllerImpl(editor, config, { - inlineCompletion, - }); - inlineHistory = new AIInlineHistoryService({ - canUndoInlineHistory: () => - controller ? controller.canUndoInlineHistory() : false, - canRedoInlineHistory: () => - controller ? controller.canRedoInlineHistory() : false, - canHandleShortcut: (direction) => - controller - ? controller.canHandleInlineHistoryShortcut(direction) - : false, - handleShortcut: (direction) => - controller - ? controller.handleInlineHistoryShortcut(direction) - : false, - undoInlineHistory: () => - controller ? controller.undoInlineHistory() : false, - redoInlineHistory: () => - controller ? controller.redoInlineHistory() : false, - }); - reviewController = new AIReviewService({ - getSuggestions: () => controller?.getSuggestions() ?? [], - acceptSuggestion: (id) => - controller?.acceptSuggestion(id) ?? false, - rejectSuggestion: (id) => - controller?.rejectSuggestion(id) ?? false, - acceptAllSuggestions: () => controller?.acceptAllSuggestions(), - rejectAllSuggestions: () => controller?.rejectAllSuggestions(), - }); - editor.internals.setSlot(AI_CONTROLLER_SLOT, controller); - editor.internals.setSlot(AI_INLINE_HISTORY_SLOT, inlineHistory); - editor.internals.setSlot( - AI_REVIEW_CONTROLLER_SLOT, - reviewController, - ); - unsubscribeTrackedOrigins = - editor.undoManager.registerTrackedOrigins([ - AI_SESSION_SUGGESTION_ORIGIN, - SUGGESTION_RESOLUTION_ORIGIN, - ]); - - unsubscribeBeforeApply = editor.onBeforeApply( - (ops, options) => { - if (!controller?.getState().suggestMode) return ops; - if (shouldBypassSuggestMode(options.origin)) return ops; - const originType = options.origin - ? getOpOriginType(options.origin) - : undefined; - return interceptApplyForSuggestMode( - ops, - editor, - originType === "ai" - ? "assistant" - : (config.author ?? "user"), - originType === "ai" ? "ai" : "user", - readModelId(config.model), - ); - }, - { priority: 200 }, - ); - }, - - deactivateClient: async () => { - controller?.cancelActiveGeneration(); - controller?.destroy(); - activeEditor?.internals.setSlot(AI_CONTROLLER_SLOT, null); - activeEditor?.internals.setSlot(AI_INLINE_HISTORY_SLOT, null); - activeEditor?.internals.setSlot(AI_REVIEW_CONTROLLER_SLOT, null); - releaseInlineCompletion?.(); - unsubscribeTrackedOrigins?.(); - unsubscribeTrackedOrigins = null; - unsubscribeBeforeApply?.(); - unsubscribeBeforeApply = null; - controller = null; - inlineCompletion = null; - releaseInlineCompletion = null; - inlineHistory = null; - reviewController = null; - activeEditor = null; - }, - - observe: (events, editor) => { - if (!controller) { - editor.requestDecorationUpdate(); - return; - } - controller.handleDocumentChange(events); - }, - - decorations: () => { - const decorations = controller?.buildDecorations() ?? []; - const inlineDecorations = - activeEditor?.internals.getSlot( - AI_AUTOCOMPLETE_CONTROLLER_SLOT, - ) == null - ? (inlineCompletion?.buildDecorations() ?? []) - : []; - return createDecorationSet([...decorations, ...inlineDecorations]); - }, - }); -} - -export function getAIController(editor: Editor): AIController | null { - return editor.internals.getSlot(AI_CONTROLLER_SLOT) ?? null; -} - -export function getInlineCompletionController( - editor: Editor, -): AIInlineCompletionController | null { - return getInlineCompletionControllerFromCore(editor); -} - -export function getAIInlineCompletionController( - editor: Editor, -): AIInlineCompletionController | null { - return getInlineCompletionController(editor); -} - -export function getAIInlineHistoryController( - editor: Editor, -): AIInlineHistoryController | null { - return ( - editor.internals.getSlot( - AI_INLINE_HISTORY_SLOT, - ) ?? null - ); -} - -export function getAIReviewController( - editor: Editor, -): AIReviewController | null { - return ( - editor.internals.getSlot( - AI_REVIEW_CONTROLLER_SLOT, - ) ?? null - ); -} - -function resolveOrderedReviewItems( - reviewItems: readonly StructuralReviewItem[], - ids: readonly string[], -): StructuralReviewItem[] { - const remainingIds = new Set(ids); - const orderedReviewItems: StructuralReviewItem[] = []; - for (const reviewItem of reviewItems) { - if (!remainingIds.has(reviewItem.id)) { - continue; - } - orderedReviewItems.push(reviewItem); - remainingIds.delete(reviewItem.id); - } - return orderedReviewItems; -} - -function sortReviewItemsForRemoval( - reviewItems: readonly StructuralReviewItem[], -): StructuralReviewItem[] { - return [...reviewItems].sort(compareReviewItemRemovalOrder); -} - -function compareReviewItemRemovalOrder( - left: StructuralReviewItem, - right: StructuralReviewItem, -): number { - const maxPathLength = Math.max( - left.bundlePath.length, - right.bundlePath.length, - ); - for (let index = 0; index < maxPathLength; index += 1) { - const leftPart = left.bundlePath[index] ?? -1; - const rightPart = right.bundlePath[index] ?? -1; - if (leftPart !== rightPart) { - return rightPart - leftPart; - } - } - - const leftStepIndex = left.stepIndex ?? -1; - const rightStepIndex = right.stepIndex ?? -1; - return rightStepIndex - leftStepIndex; -} - -function resolveActiveBlockId(selection: SelectionState): string | null { - if (!selection) return null; - if (selection.type === "text") return selection.focus.blockId; - if (selection.type === "block") return selection.blockIds[0] ?? null; - if (selection.type === "cell") return selection.blockId; - return null; -} - -function readModelId(model: ModelAdapter | undefined): string | undefined { - if (!model || typeof model !== "object") return undefined; - const candidate = model as ModelAdapter & { - name?: string; - modelId?: string; - }; - return candidate.modelId ?? candidate.name; -} - -function supportsStructuredIntent(model: ModelAdapter | undefined): boolean { - return model?.capabilities?.structuredIntent === true; -} - -type AIStreamEventInput = - | { - type: "generation-start"; - prompt: string; - target: GenerationState["target"]; - } - | { - type: "status"; - status: AIControllerState["status"]; - } - | { - type: "text-delta"; - delta: string; - text: string; - } - | { - type: "operation"; - operation: AIRequestedOperation; - phase: "preview" | "final" | "conflict"; - text?: string; - reason?: string; - } - | { - type: "app-partial"; - data: unknown; - final: boolean; - } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - } - | { - type: "tool-output"; - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - } - | { - type: "tool-result"; - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - } - | { - type: "structured-preview"; - preview: GenerationStructuredPreviewState; - patches: readonly { - op: "add" | "remove" | "replace"; - path: string; - value?: unknown; - }[]; - } - | { - type: "generation-finish"; - status: GenerationState["status"]; - text: string; - }; - -function createAIStreamEvent( - generation: Pick< - GenerationState, - "id" | "zoneId" | "blockId" | "sessionId" - >, - event: AIStreamEventInput, -): AIStreamEvent { - return { - ...event, - generationId: generation.id, - sessionId: generation.sessionId, - zoneId: generation.zoneId, - blockId: generation.blockId, - timestamp: Date.now(), - }; -} - -function resolvePromptTarget( - selection: SelectionState, - target: "auto" | "selection" | "block" | "document" | undefined, -): "selection" | "block" | "document" { - if (target === "selection") { - return "selection"; - } - if (target === "block") { - return "block"; - } - if (target === "document") { - return "document"; - } - return selection?.type === "text" && !selection.isCollapsed - ? "selection" - : "block"; -} - -function resolveSessionTarget( - editor: Editor, - target: "auto" | "selection" | "block" | "document" | undefined, -): AISessionTarget { - if (target === "document") { - return { kind: "document" }; - } - const selection = editor.selection; - if ( - (target === "selection" || target === "auto") && - selection?.type === "text" && - !selection.isCollapsed - ) { - const range = selection.toRange(); - const selectionSnapshot = resolveSessionSelectionSnapshot(selection); - return { - kind: "selection", - selection: recreateTextSelection(editor, selectionSnapshot), - blockId: range.start.blockId, - }; - } - const blockId = - target === "block" || target === "auto" - ? (resolveActiveBlockId(selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null) - : null; - return blockId ? { kind: "block", blockId } : { kind: "document" }; -} - -function resolveSessionAnchor( - selection: SelectionState | TextSelection, -): AISession["anchor"] | undefined { - if (selection?.type !== "text") { - return undefined; - } - const range = selection.toRange(); - return { - blockId: range.start.blockId, - from: range.start.offset, - to: range.end.offset, - }; -} - -function resolveSessionSelectionSnapshot( - selection: TextSelection, -): AISessionSelectionSnapshot { - return { - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - blockRange: [...selection.blockRange], - isMultiBlock: selection.isMultiBlock, - }; -} - -function resolveContextualPromptAnchor( - target: AISessionTarget, -): NonNullable["anchor"] { - if (target.kind === "selection") { - const range = target.selection.toRange(); - return { - kind: "text-range", - selectionSnapshot: resolveSessionSelectionSnapshot( - target.selection, - ), - focusBlockId: range.start.blockId, - status: "valid", - lastResolvedRect: null, - }; - } - if (target.kind === "block") { - return { - kind: "block", - focusBlockId: target.blockId, - status: "valid", - lastResolvedRect: null, - }; - } - return { - kind: "document", - focusBlockId: null, - status: "valid", - lastResolvedRect: null, - }; -} - -function resolveContextualPromptState( - target: AISessionTarget, -): NonNullable { - return { - anchor: resolveContextualPromptAnchor(target), - composer: { - draftPrompt: "", - isOpen: true, - isSubmitting: false, - canSubmitFollowUp: true, - openReason: "user", - }, - }; -} - -function createInlineHistorySnapshot( - editor: Editor, - sessions: readonly AISession[], - activeSessionId: string | null, - documentVersion: number, - options?: { - kind?: AIInlineHistorySnapshot["kind"]; - }, -): AIInlineHistorySnapshot { - return { - id: crypto.randomUUID(), - sessionId: activeSessionId, - sessions: cloneInlineHistorySessions(editor, sessions), - activeSessionId, - documentVersion, - kind: options?.kind ?? "document-coupled", - }; -} - -function cloneSessionTarget( - editor: Editor, - target: AISessionTarget, -): AISessionTarget { - if (target.kind !== "selection") { - return { ...target }; - } - return { - kind: "selection", - blockId: target.blockId, - selection: recreateTextSelection( - editor, - resolveSessionSelectionSnapshot(target.selection), - ), - }; -} - -function cloneInlineHistorySessions( - editor: Editor, - sessions: readonly AISession[], -): AISession[] { - return sessions.map((session) => ({ - ...session, - target: cloneSessionTarget(editor, session.target), - contextualPrompt: session.contextualPrompt - ? { - ...session.contextualPrompt, - anchor: { - ...session.contextualPrompt.anchor, - selectionSnapshot: session.contextualPrompt.anchor - .selectionSnapshot - ? { - ...session.contextualPrompt.anchor - .selectionSnapshot, - anchor: { - ...session.contextualPrompt.anchor - .selectionSnapshot.anchor, - }, - focus: { - ...session.contextualPrompt.anchor - .selectionSnapshot.focus, - }, - blockRange: [ - ...session.contextualPrompt.anchor - .selectionSnapshot.blockRange, - ], - } - : undefined, - }, - composer: { - ...session.contextualPrompt.composer, - }, - } - : undefined, - turns: session.turns.map((turn) => ({ - ...turn, - suggestionIds: [...turn.suggestionIds], - reviewItemIds: [...turn.reviewItemIds], - anchor: turn.anchor ? { ...turn.anchor } : undefined, - selection: turn.selection - ? { - ...turn.selection, - anchor: { ...turn.selection.anchor }, - focus: { ...turn.selection.focus }, - blockRange: [...turn.selection.blockRange], - } - : undefined, - })), - promptHistory: session.promptHistory.map((prompt) => ({ ...prompt })), - generationIds: [...session.generationIds], - pendingSuggestionIds: [...session.pendingSuggestionIds], - pendingReviewItemIds: [...session.pendingReviewItemIds], - metrics: { - ...session.metrics, - fastApply: { ...session.metrics.fastApply }, - }, - anchor: session.anchor ? { ...session.anchor } : undefined, - })); -} - -function recreateTextSelection( - editor: Editor, - snapshot: AISessionSelectionSnapshot, -): TextSelection { - const blockRange = resolveSelectionSnapshotBlockRange(editor, snapshot); - const isCollapsed = - snapshot.anchor.blockId === snapshot.focus.blockId && - snapshot.anchor.offset === snapshot.focus.offset; - const documentRange = { - start: resolveSelectionSnapshotRangeStart(snapshot, blockRange), - end: resolveSelectionSnapshotRangeEnd(snapshot, blockRange), - get isMultiBlock() { - return blockRange.length > 1; - }, - get blockRange() { - return [...blockRange]; - }, - contains(point: { blockId: string; offset: number }): boolean { - if (!blockRange.includes(point.blockId)) { - return false; - } - const isSingleBlock = blockRange.length === 1; - if (isSingleBlock) { - return ( - point.offset >= this.start.offset && - point.offset <= this.end.offset - ); - } - if (point.blockId === this.start.blockId) { - return point.offset >= this.start.offset; - } - if (point.blockId === this.end.blockId) { - return point.offset <= this.end.offset; - } - return true; - }, - overlaps(other: { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - contains: (point: { blockId: string; offset: number }) => boolean; - }): boolean { - return ( - this.contains(other.start) || - this.contains(other.end) || - other.contains(this.start) - ); - }, - equals(other: { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - }): boolean { - return ( - this.start.blockId === other.start.blockId && - this.start.offset === other.start.offset && - this.end.blockId === other.end.blockId && - this.end.offset === other.end.offset - ); - }, - toTextSelection() { - return recreateTextSelection(editor, snapshot); - }, - }; - return { - type: "text", - anchor: { ...snapshot.anchor }, - focus: { ...snapshot.focus }, - get isCollapsed() { - return isCollapsed; - }, - get isMultiBlock() { - return blockRange.length > 1; - }, - get blockRange() { - return [...blockRange]; - }, - toRange() { - return documentRange; - }, - }; -} - -function resolveSelectionSnapshotBlockRange( - editor: Editor, - snapshot: AISessionSelectionSnapshot, -): string[] { - if (snapshot.blockRange.length > 0) { - return [...snapshot.blockRange]; - } - const blockOrder = editor.documentState.blockOrder; - const anchorIndex = blockOrder.indexOf(snapshot.anchor.blockId); - const focusIndex = blockOrder.indexOf(snapshot.focus.blockId); - if (anchorIndex === -1 || focusIndex === -1) { - return [snapshot.anchor.blockId]; - } - const startIndex = Math.min(anchorIndex, focusIndex); - const endIndex = Math.max(anchorIndex, focusIndex); - return blockOrder.slice(startIndex, endIndex + 1); -} - -function resolveSelectionSnapshotRangeStart( - snapshot: AISessionSelectionSnapshot, - blockRange: readonly string[], -): { blockId: string; offset: number } { - if (blockRange.length <= 1) { - return { - blockId: snapshot.anchor.blockId, - offset: Math.min(snapshot.anchor.offset, snapshot.focus.offset), - }; - } - const firstBlockId = blockRange[0] ?? snapshot.anchor.blockId; - return snapshot.anchor.blockId === firstBlockId - ? { ...snapshot.anchor } - : { ...snapshot.focus }; -} - -function resolveSelectionSnapshotRangeEnd( - snapshot: AISessionSelectionSnapshot, - blockRange: readonly string[], -): { blockId: string; offset: number } { - if (blockRange.length <= 1) { - return { - blockId: snapshot.anchor.blockId, - offset: Math.max(snapshot.anchor.offset, snapshot.focus.offset), - }; - } - const lastBlockId = - blockRange[blockRange.length - 1] ?? snapshot.focus.blockId; - return snapshot.anchor.blockId === lastBlockId - ? { ...snapshot.anchor } - : { ...snapshot.focus }; -} - -function resolveRequestedOperationForSession( - editor: Editor, - session: AISession, - prompt: string, - options: AICommandExecutionOptions | undefined, - documentVersion: number, -): AIRequestedOperation { - const explicitTarget = options?.target; - const promptIntent = classifyPromptIntent(prompt); - const capturedSelection = resolveSessionSelectionTarget(editor, session); - const liveSelection = - session.surface === "inline-edit" - ? capturedSelection - : editor.selection?.type === "text" && !editor.selection.isCollapsed - ? editor.selection - : capturedSelection; - const activeBlockId = - options?.blockId ?? - resolveSessionBlockId(editor, session) ?? - resolveActiveBlockId(editor.selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null; - const documentActiveBlockId = - options?.blockId ?? - resolveActiveBlockId(editor.selection) ?? - session.anchor?.blockId ?? - null; - const resolvedEditProposal = resolveResolvedEditProposal( - editor, - session, - prompt, - promptIntent, - explicitTarget, - liveSelection, - "markdown", - ); - const clearDocument = - session.target.kind === "document" && isClearDocumentPrompt(prompt); - const documentBlockIds = editor.documentState.blockOrder.filter( - (blockId) => editor.getBlock(blockId) != null, - ); - const documentTransformPlan = clearDocument - ? { - blockIds: documentBlockIds, - placement: "replace-blocks" as const, - transform: "remove" as const, - } - : undefined; - - if (resolvedEditProposal) { - return createRewriteSelectionOperationFromResolvedTarget( - editor, - resolvedEditProposal.target, - resolvedEditProposal.promptIntent, - documentVersion, - ); - } - if (promptIntent === "continue" && activeBlockId) { - if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { - return createDocumentTransformOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - { - blockIds: [activeBlockId], - placement: "append-after-block", - transform: "write", - }, - ); - } - return createContinueBlockOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - ); - } - if ( - activeBlockId && - (promptIntent === "rewrite" || - (promptIntent === "local-edit" && - (editor.getBlock(activeBlockId)?.textContent().length ?? 0) > - 0) || - explicitTarget === "block") - ) { - if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { - return createDocumentTransformOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - { - blockIds: [activeBlockId], - placement: "replace-blocks", - transform: "rewrite", - }, - ); - } - return createRewriteBlockOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - ); - } - if (explicitTarget === "document") { - return createDocumentTransformOperation( - editor, - documentActiveBlockId, - promptIntent, - documentVersion, - documentTransformPlan, - ); - } - return createDocumentTransformOperation( - editor, - session.target.kind === "document" - ? documentActiveBlockId - : activeBlockId, - promptIntent, - documentVersion, - documentTransformPlan, - ); -} - -function resolveLocalOperationContentFormat( - editor: Editor, - operation: AIRequestedOperation, - defaultBlockFormat: AIContentFormat, -): AIContentFormat { - if (operation.kind === "rewrite-selection") { - return operation.target.kind === "scoped-range" - ? operation.target.contentFormat - : "text"; - } - if (operation.kind === "document-transform") { - return defaultBlockFormat; - } - if (operation.kind !== "rewrite-block") { - return "text"; - } - const blockId = - operation.target.kind === "block" ? operation.target.blockId : null; - if (blockId && resolveFullBlockTextSelection(editor, blockId)) { - return "text"; - } - return defaultBlockFormat; -} - -function canUseLocalBlockTextOperation( - editor: Editor, - blockId: string, -): boolean { - const block = editor.getBlock(blockId); - if (!block) { - return false; - } - const schema = editor.schema.resolve(block.type); - if (!schema || !usesInlineTextSelection(schema)) { - return false; - } - return resolveFullBlockTextSelection(editor, blockId) != null; -} - -function canReuseBottomChatSessionOperation( - previousOperation: AIRequestedOperation, - nextOperation: AIRequestedOperation, -): boolean { - const previousResolvedTarget = - resolveResolvedEditTargetFromRequestedOperation(previousOperation); - const nextResolvedTarget = - resolveResolvedEditTargetFromRequestedOperation(nextOperation); - if (previousResolvedTarget && nextResolvedTarget) { - return areResolvedEditTargetsEqual( - previousResolvedTarget, - nextResolvedTarget, - ); - } - if (previousOperation.kind !== nextOperation.kind) { - return false; - } - if (previousOperation.target.kind !== nextOperation.target.kind) { - return false; - } - if ( - previousOperation.target.kind === "selection" || - previousOperation.target.kind === "scoped-range" - ) { - if ( - nextOperation.target.kind !== "selection" && - nextOperation.target.kind !== "scoped-range" - ) { - return false; - } - return ( - previousOperation.provenance?.selectionSignature === - nextOperation.provenance?.selectionSignature && - previousOperation.target.sourceText === - nextOperation.target.sourceText - ); - } - if (previousOperation.target.kind === "block") { - if (nextOperation.target.kind !== "block") { - return false; - } - return ( - previousOperation.target.blockId === nextOperation.target.blockId && - previousOperation.provenance?.blockRevision === - nextOperation.provenance?.blockRevision - ); - } - if (nextOperation.target.kind !== "document") { - return false; - } - return ( - previousOperation.target.activeBlockId === - nextOperation.target.activeBlockId && - areStructuredValuesEqual( - previousOperation.target.blockIds ?? [], - nextOperation.target.blockIds ?? [], - ) && - (previousOperation.target.placement ?? null) === - (nextOperation.target.placement ?? null) && - (previousOperation.target.transform ?? null) === - (nextOperation.target.transform ?? null) - ); -} - -function resolveResolvedEditTargetFromRequestedOperation( - operation: AIRequestedOperation, -): ResolvedEditTarget | null { - if ( - operation.target.kind !== "selection" && - operation.target.kind !== "scoped-range" - ) { - return null; - } - return operation.target; -} - -function areResolvedEditTargetsEqual( - previousTarget: ResolvedEditTarget, - nextTarget: ResolvedEditTarget, -): boolean { - if (previousTarget.kind !== nextTarget.kind) { - return false; - } - if ( - previousTarget.blockId !== nextTarget.blockId || - previousTarget.sourceText !== nextTarget.sourceText || - previousTarget.anchor.blockId !== nextTarget.anchor.blockId || - previousTarget.anchor.offset !== nextTarget.anchor.offset || - previousTarget.focus.blockId !== nextTarget.focus.blockId || - previousTarget.focus.offset !== nextTarget.focus.offset - ) { - return false; - } - if ( - previousTarget.kind === "scoped-range" && - nextTarget.kind === "scoped-range" - ) { - return ( - previousTarget.scope === nextTarget.scope && - previousTarget.contentFormat === nextTarget.contentFormat && - areStructuredValuesEqual( - previousTarget.blockIds, - nextTarget.blockIds, - ) - ); - } - return true; -} - -function isClearDocumentPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - return ( - /\b(remove|delete|clear|erase|wipe)\b/.test(normalizedPrompt) && - /\b(all|entire|whole|everything)\b/.test(normalizedPrompt) && - /\b(document|content|contents|text|story|page)\b/.test(normalizedPrompt) - ); -} - -function isWholeDocumentRewritePrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - return ( - /\b(rewrite|redo|revise|rework|replace)\s+(?:the|this|my)?\s*(?:entire|whole|full|all)?\s*(?:document|content|contents|text|story|page)\b/.test( - normalizedPrompt, - ) || /\bmake (?:it|this) about\b/.test(normalizedPrompt) - ); -} - -function isDocumentResetPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - return /\b(start(?:ing)?\s+(?:over|again|from scratch)|begin\s+again|from scratch|restart)\b/.test( - normalizedPrompt, - ); -} - -function isDocumentFollowUpEditPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - if ( - /\b(continue|append|add|insert|another|more|next)\b/.test( - normalizedPrompt, - ) - ) { - return false; - } - return ( - /\b(change|update|adjust|edit|fix|improve|polish|revise|rework|rename|retitle|make)\b/.test( - normalizedPrompt, - ) && - (/\b(title|heading|story|document|content|contents|text|tone|voice|ending|opening|intro|introduction|theme)\b/.test( - normalizedPrompt, - ) || - /\bmake (?:it|this)\b/.test(normalizedPrompt)) - ); -} - -function buildSessionExecutionPrompt( - session: AISession | null, - prompt: string, -): string { - if (!session) { - return prompt; - } - const previousPrompts = session.promptHistory - .map((item) => item.prompt.trim()) - .filter((item) => item.length > 0) - .slice(-4); - if (previousPrompts.length === 0) { - return prompt; - } - const historyLines = previousPrompts.map( - (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, - ); - const intro = - session.surface === "inline-edit" - ? "You are continuing an existing inline editor edit session." - : "You are continuing an existing editor chat session."; - const applyInstruction = - session.surface === "inline-edit" - ? "Apply the latest request to the current selected document state." - : "Apply the latest request to the current document state."; - return [ - intro, - "Earlier user requests in this same session:", - ...historyLines, - "", - applyInstruction, - "Latest request:", - prompt, - ].join("\n"); -} - -function createRewriteSelectionOperation( - editor: Editor, - selection: TextSelection, - promptIntent: string, - documentVersion: number, - options?: { - sourceText?: string; - }, -): AIRequestedOperation { - const range = selection.toRange(); - return { - kind: "rewrite-selection", - applyPolicy: "selection-replace", - promptIntent, - target: { - kind: "selection", - blockId: range.start.blockId, - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - sourceText: - options?.sourceText ?? resolveSelectionText(editor, selection), - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(range.start.blockId), - selectionSignature: createSelectionSignature(selection), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createRewriteSelectionOperationFromResolvedTarget( - editor: Editor, - target: ResolvedEditTarget, - promptIntent: string, - documentVersion: number, -): AIRequestedOperation { - const selection = recreateTextSelection(editor, { - anchor: target.anchor, - focus: target.focus, - blockRange: resolveSelectionTargetBlockIds(editor, target), - isMultiBlock: - resolveSelectionTargetBlockIds(editor, target).length > 1 || - target.anchor.blockId !== target.focus.blockId, - }); - if (target.kind === "selection") { - return createRewriteSelectionOperation( - editor, - selection, - promptIntent, - documentVersion, - { - sourceText: target.sourceText, - }, - ); - } - return { - kind: "rewrite-selection", - applyPolicy: "selection-replace", - promptIntent, - target: { - kind: "scoped-range", - blockId: target.blockId, - anchor: { ...target.anchor }, - focus: { ...target.focus }, - sourceText: target.sourceText, - blockIds: [...target.blockIds], - contentFormat: target.contentFormat, - scope: target.scope, - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision( - target.blockId ?? selection.anchor.blockId, - ), - selectionSignature: createSelectionSignature(selection), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createRewriteBlockOperation( - editor: Editor, - blockId: string, - promptIntent: string, - documentVersion: number, -): AIRequestedOperation { - const block = editor.getBlock(blockId); - return { - kind: "rewrite-block", - applyPolicy: "block-replace", - promptIntent, - target: { - kind: "block", - blockId, - blockType: block?.type ?? null, - sourceText: block?.textContent() ?? "", - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(blockId), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createContinueBlockOperation( - editor: Editor, - blockId: string, - promptIntent: string, - documentVersion: number, -): AIRequestedOperation { - const block = editor.getBlock(blockId); - return { - kind: "continue-block", - applyPolicy: "block-continue", - promptIntent, - target: { - kind: "block", - blockId, - blockType: block?.type ?? null, - sourceText: block?.textContent() ?? "", - insertionOffset: resolveContinueInsertionOffset(editor, blockId), - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(blockId), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createDocumentTransformOperation( - editor: Editor, - activeBlockId: string | null, - promptIntent: string, - documentVersion: number, - options?: { - blockIds?: readonly string[]; - placement?: - | "append-after-block" - | "replace-empty-block" - | "replace-blocks"; - transform?: "write" | "rewrite" | "remove"; - }, -): AIRequestedOperation { - return { - kind: "document-transform", - applyPolicy: "document-review", - promptIntent, - target: { - kind: "document", - activeBlockId, - blockIds: options?.blockIds, - placement: options?.placement, - transform: options?.transform, - }, - provenance: { - documentVersion, - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function resolvePreviousGeneratedBlockIds(session: AISession): string[] { - const completedTurns = session.turns.filter( - (turn) => turn.status === "complete" || turn.status === "accepted", - ); - const lastTurnWithBlocks = completedTurns - .slice() - .reverse() - .find((turn) => turn.generatedBlockIds.length > 0); - return lastTurnWithBlocks?.generatedBlockIds ?? []; -} - -function shouldReplacePreviousGeneratedBlocks( - session: AISession, - prompt: string, -): boolean { - return ( - session.surface === "bottom-chat" && - session.target.kind === "document" && - (classifyPromptIntent(prompt) === "rewrite" || - isDocumentResetPrompt(prompt) || - isDocumentFollowUpEditPrompt(prompt)) - ); -} - -function resolveReplacementDeleteBlockIds( - editor: Editor, - blockId: string, - replaceBlockIds?: readonly string[], -): string[] { - const requestedIds = - replaceBlockIds && replaceBlockIds.length > 0 - ? replaceBlockIds - : [blockId]; - const deleteBlockIds = requestedIds.filter( - (candidateBlockId, index, allBlockIds) => - allBlockIds.indexOf(candidateBlockId) === index && - editor.getBlock(candidateBlockId) != null, - ); - return deleteBlockIds.length > 0 ? deleteBlockIds : [blockId]; -} - -function createResolvedSelectionEditTarget( - editor: Editor, - selection: TextSelection, -): ResolvedEditTarget { - const range = selection.toRange(); - return { - kind: "selection", - blockId: range.start.blockId, - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - sourceText: resolveSelectionText(editor, selection), - }; -} - -function createResolvedScopedEditTarget( - editor: Editor, - selection: TextSelection, - scope: ModelOperationScopedRangeTarget["scope"], - contentFormat: AIContentFormat, -): ResolvedEditTarget { - const range = selection.toRange(); - return { - kind: "scoped-range", - scope, - blockId: range.start.blockId, - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - blockIds: [...range.blockRange], - sourceText: resolveSelectionText(editor, selection), - contentFormat, - }; -} - -function createResolvedEditProposal( - promptIntent: string, - target: ResolvedEditTarget, -): ResolvedEditProposal { - return { - promptIntent, - target, - }; -} - -function resolveResolvedEditProposal( - editor: Editor, - session: AISession, - prompt: string, - promptIntent: string, - explicitTarget: AICommandExecutionOptions["target"] | undefined, - liveSelection: TextSelection | null, - defaultBlockFormat: AIContentFormat, -): ResolvedEditProposal | null { - if (liveSelection && explicitTarget === "selection") { - return createResolvedEditProposal( - promptIntent, - createResolvedSelectionEditTarget(editor, liveSelection), - ); - } - - const selectionScopedSession = session.target.kind === "selection"; - if ( - liveSelection && - (session.surface === "inline-edit" || - (selectionScopedSession && - (promptIntent === "rewrite" || promptIntent === "local-edit"))) - ) { - return createResolvedEditProposal( - promptIntent, - createResolvedSelectionEditTarget(editor, liveSelection), - ); - } - - if (session.target.kind !== "document" && explicitTarget !== "document") { - return null; - } - if ( - promptIntent === "continue" || - promptIntent === "review" || - promptIntent === "search" || - promptIntent === "structural" - ) { - return null; - } - - const titleSelection = resolveDocumentTitleSelection(editor, prompt); - if (titleSelection) { - return createResolvedEditProposal( - promptIntent, - createResolvedScopedEditTarget( - editor, - titleSelection, - "heading", - defaultBlockFormat, - ), - ); - } - - const paragraphSelection = resolveDocumentParagraphSelection( - editor, - prompt, - ); - if (paragraphSelection) { - return createResolvedEditProposal( - promptIntent, - createResolvedScopedEditTarget( - editor, - paragraphSelection, - "paragraph", - defaultBlockFormat, - ), - ); - } - - const documentBlockIds = editor.documentState.blockOrder.filter( - (blockId) => editor.getBlock(blockId) != null, - ); - const documentHasMeaningfulContent = documentBlockIds.some((blockId) => { - const block = editor.getBlock(blockId); - return (block?.textContent().trim().length ?? 0) > 0; - }); - const shouldRewriteDocumentScope = - !documentHasMeaningfulContent || - promptIntent === "rewrite" || - isClearDocumentPrompt(prompt) || - isWholeDocumentRewritePrompt(prompt) || - isDocumentResetPrompt(prompt) || - isDocumentFollowUpEditPrompt(prompt); - if (!shouldRewriteDocumentScope) { - return null; - } - - const documentSelection = resolveDocumentBlockRangeSelection( - editor, - documentBlockIds, - ); - if (!documentSelection) { - return null; - } - return createResolvedEditProposal( - promptIntent, - createResolvedScopedEditTarget( - editor, - documentSelection, - "document", - defaultBlockFormat, - ), - ); -} - -function resolveSelectionForRequestedOperation( - editor: Editor, - operation: AIRequestedOperation, -): TextSelection | null { - if ( - operation.target.kind !== "selection" && - operation.target.kind !== "scoped-range" - ) { - return null; - } - return recreateTextSelection(editor, { - anchor: operation.target.anchor, - focus: operation.target.focus, - blockRange: resolveSelectionTargetBlockIds(editor, operation.target), - isMultiBlock: - resolveSelectionTargetBlockIds(editor, operation.target).length > - 1 || - operation.target.anchor.blockId !== operation.target.focus.blockId, - }); -} - -function resolveFullBlockTextSelection( - editor: Editor, - blockId: string, -): TextSelection | null { - const block = editor.getBlock(blockId); - if (!block) { - return null; - } - return recreateTextSelection(editor, { - anchor: { blockId, offset: 0 }, - focus: { blockId, offset: block.textContent().length }, - blockRange: [blockId], - isMultiBlock: false, - }); -} - -function resolveDocumentBlockRangeSelection( - editor: Editor, - blockIds: readonly string[], -): TextSelection | null { - const resolvedBlockIds = blockIds.filter( - (blockId, index, allBlockIds) => - allBlockIds.indexOf(blockId) === index && - editor.getBlock(blockId) != null, - ); - const firstBlockId = resolvedBlockIds[0]; - const lastBlockId = resolvedBlockIds[resolvedBlockIds.length - 1]; - if (!firstBlockId || !lastBlockId) { - return null; - } - const lastBlock = editor.getBlock(lastBlockId); - return recreateTextSelection(editor, { - anchor: { blockId: firstBlockId, offset: 0 }, - focus: { - blockId: lastBlockId, - offset: lastBlock?.textContent().length ?? 0, + deactivateClient: async () => { + controller?.cancelActiveGeneration(); + controller?.destroy(); + activeEditor?.internals.setSlot(AI_CONTROLLER_SLOT, null); + activeEditor?.internals.setSlot(AI_INLINE_HISTORY_SLOT, null); + activeEditor?.internals.setSlot(AI_REVIEW_CONTROLLER_SLOT, null); + releaseInlineCompletion?.(); + unsubscribeTrackedOrigins?.(); + unsubscribeTrackedOrigins = null; + unsubscribeBeforeApply?.(); + unsubscribeBeforeApply = null; + controller = null; + inlineCompletion = null; + releaseInlineCompletion = null; + inlineHistory = null; + reviewController = null; + activeEditor = null; }, - blockRange: resolvedBlockIds, - isMultiBlock: resolvedBlockIds.length > 1, - }); -} - -function resolveDocumentTitleSelection( - editor: Editor, - prompt: string, -): TextSelection | null { - if (!/\b(title|heading)\b/i.test(prompt)) { - return null; - } - const headingBlockId = - editor.documentState.blockOrder.find((blockId) => { - const block = editor.getBlock(blockId); - return ( - block?.type === "heading" || block?.type.startsWith("heading-") - ); - }) ?? - editor.firstBlock()?.id ?? - null; - return headingBlockId - ? resolveDocumentBlockRangeSelection(editor, [headingBlockId]) - : null; -} -function resolveDocumentParagraphSelection( - editor: Editor, - prompt: string, -): TextSelection | null { - const paragraphIndex = parseParagraphReference(prompt); - if (paragraphIndex == null) { - return null; - } - const paragraphBlockIds = editor.documentState.blockOrder.filter( - (blockId) => { - const block = editor.getBlock(blockId); - if (!block) { - return false; + observe: (events, editor) => { + if (!controller) { + editor.requestDecorationUpdate(); + return; } - return ( - block.type === "paragraph" || - (block.textContent().trim().length > 0 && - block.type !== "heading" && - !block.type.startsWith("heading-")) - ); + controller.handleDocumentChange(events); }, - ); - const targetParagraphBlockId = - paragraphBlockIds[paragraphIndex - 1] ?? null; - return targetParagraphBlockId - ? resolveDocumentBlockRangeSelection(editor, [targetParagraphBlockId]) - : null; -} - -function parseParagraphReference(prompt: string): number | null { - const match = prompt.match( - /\b(?:(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)|(\d+)(?:st|nd|rd|th))\s+paragraph\b/i, - ); - if (!match) { - return null; - } - const wordOrdinal = match[1]?.toLowerCase(); - if (wordOrdinal) { - return resolveWordOrdinal(wordOrdinal); - } - const numericOrdinal = Number.parseInt(match[2] ?? "", 10); - return Number.isFinite(numericOrdinal) && numericOrdinal > 0 - ? numericOrdinal - : null; -} - -function resolveWordOrdinal(word: string): number | null { - switch (word) { - case "first": - return 1; - case "second": - return 2; - case "third": - return 3; - case "fourth": - return 4; - case "fifth": - return 5; - case "sixth": - return 6; - case "seventh": - return 7; - case "eighth": - return 8; - case "ninth": - return 9; - case "tenth": - return 10; - default: - return null; - } -} - -function resolveBlockIdForRequestedOperation( - operation: AIRequestedOperation, -): string | null { - if (operation.target.kind === "block") { - return operation.target.blockId; - } - if ( - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" - ) { - return operation.target.blockId; - } - return operation.target.activeBlockId; -} - -function resolveRequestedOperationConflict( - editor: Editor, - operation: AIRequestedOperation, - currentSelectionSignature: string | null, -): string | null { - if ( - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" - ) { - const selection = resolveSelectionForRequestedOperation( - editor, - operation, - ); - if (!selection) { - return "The selected range no longer exists."; - } - if (isScopedSelectionTarget(operation.target)) { - if ( - renderSelectionTargetBlockText(editor, operation.target) !== - operation.target.sourceText - ) { - return "The selected text changed before the rewrite completed."; - } - return null; - } - if ( - operation.provenance?.selectionSignature != null && - operation.provenance.selectionSignature !== - currentSelectionSignature - ) { - return "The selected range changed before the rewrite completed."; - } - if ( - resolveSelectionText(editor, selection) !== - operation.target.sourceText - ) { - return "The selected text changed before the rewrite completed."; - } - return null; - } - if (operation.target.kind === "block") { - const block = editor.getBlock(operation.target.blockId); - if (!block) { - return "The target block no longer exists."; - } - if ( - operation.provenance?.blockRevision != null && - editor.getBlockRevision(operation.target.blockId) !== - operation.provenance.blockRevision - ) { - return "The target block changed before the operation completed."; - } - return null; - } - if ( - operation.provenance?.syncedGeneration != null && - editor.documentState.generation !== - operation.provenance.syncedGeneration - ) { - return "The document changed before the operation completed."; - } - return null; -} - -function resolveContinueInsertionOffset( - editor: Editor, - blockId: string, -): number { - const selection = editor.selection; - if ( - selection?.type === "text" && - selection.isCollapsed && - selection.anchor.blockId === blockId - ) { - return selection.anchor.offset; - } - return resolveBlockInsertionOffset(editor, blockId); -} - -function createSelectionSignature(selection: TextSelection): string { - return [ - "text", - selection.anchor.blockId, - selection.anchor.offset, - selection.focus.blockId, - selection.focus.offset, - String(selection.isCollapsed), - ].join(":"); -} -function resolveSessionSelectionTarget( - editor: Editor, - session: AISession, -): TextSelection | null { - const anchorSelection = session.contextualPrompt?.anchor.selectionSnapshot; - if (session.target.kind !== "selection" && !anchorSelection) { - return null; - } - const activeTurnSelection = session.activeTurnId - ? session.turns.find((turn) => turn.id === session.activeTurnId) - ?.selection - : session.turns[session.turns.length - 1]?.selection; - if (activeTurnSelection) { - const restoredSelection = recreateTextSelection( - editor, - activeTurnSelection, - ); - if (!restoredSelection.isCollapsed) { - return restoredSelection; - } - } - const selection = editor.selection; - if ( - selection?.type === "text" && - !selection.isCollapsed && - selectionMatchesSnapshot( - selection, - session.target.kind === "selection" - ? resolveSessionSelectionSnapshot(session.target.selection) - : (anchorSelection ?? null), - ) - ) { - return selection; - } - if (anchorSelection) { - const restoredSelection = recreateTextSelection( - editor, - anchorSelection, - ); - if (!restoredSelection.isCollapsed) { - return restoredSelection; - } - } - if ( - session.target.kind === "selection" && - !session.target.selection.isCollapsed - ) { - return session.target.selection; - } - return null; + decorations: () => { + const decorations = controller?.buildDecorations() ?? []; + const inlineDecorations = + activeEditor?.internals.getSlot( + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + ) == null + ? (inlineCompletion?.buildDecorations() ?? []) + : []; + return createDecorationSet([...decorations, ...inlineDecorations]); + }, + }); } -function resolveLiveInlineSelectionTarget( - editor: Editor, -): Extract | null { - const selection = editor.selection; - if (selection?.type !== "text" || selection.isCollapsed) { - return null; - } - const target = resolveSessionTarget(editor, "selection"); - return target.kind === "selection" ? target : null; +export function getAIController(editor: Editor): AIController | null { + return editor.internals.getSlot(AI_CONTROLLER_SLOT) ?? null; } -function resolvePendingInlineSelectionTarget( +export function getInlineCompletionController( editor: Editor, - operation: AIRequestedOperation | undefined, - suggestionIds: readonly string[], -): Extract | null { - if ( - operation?.kind !== "rewrite-selection" || - operation.target.kind !== "selection" || - operation.target.anchor.blockId !== operation.target.focus.blockId - ) { - return null; - } - const textSuggestions = readAllSuggestions(editor).filter( - (suggestion): suggestion is PersistentTextSuggestion => - suggestion.kind === "text" && - (suggestion.action === "insert" || - suggestion.action === "delete") && - suggestionIds.includes(suggestion.id), - ); - if (textSuggestions.length === 0) { - return null; - } - const blockId = operation.target.anchor.blockId; - const startOffset = Math.min( - operation.target.anchor.offset, - operation.target.focus.offset, - ); - const previewSpanLength = textSuggestions.reduce( - (totalLength, suggestion) => totalLength + suggestion.length, - 0, - ); - const endOffset = startOffset + previewSpanLength; - if (endOffset <= startOffset) { - return null; - } - return { - kind: "selection", - blockId, - selection: recreateTextSelection(editor, { - anchor: { blockId, offset: startOffset }, - focus: { blockId, offset: endOffset }, - blockRange: [blockId], - isMultiBlock: false, - }), - }; +): AIInlineCompletionController | null { + return getInlineCompletionControllerFromCore(editor); } -function resolveAcceptedInlineSelectionTarget( +export function getAIInlineCompletionController( editor: Editor, - operation: AIRequestedOperation | undefined, - suggestionIds: readonly string[], -): Extract | null { - if ( - operation?.kind !== "rewrite-selection" || - operation.target.kind !== "selection" || - operation.target.anchor.blockId !== operation.target.focus.blockId - ) { - return null; - } - const insertSuggestions = readAllSuggestions(editor).filter( - (suggestion): suggestion is PersistentTextSuggestion => - suggestion.kind === "text" && - suggestion.action === "insert" && - suggestionIds.includes(suggestion.id), - ); - if (insertSuggestions.length === 0) { - return null; - } - const blockId = operation.target.anchor.blockId; - const startOffset = Math.min( - operation.target.anchor.offset, - operation.target.focus.offset, - ); - const insertedLength = insertSuggestions.reduce( - (totalLength, suggestion) => totalLength + suggestion.length, - 0, - ); - const endOffset = startOffset + insertedLength; - if (endOffset <= startOffset) { - return null; - } - return { - kind: "selection", - blockId, - selection: recreateTextSelection(editor, { - anchor: { blockId, offset: startOffset }, - focus: { blockId, offset: endOffset }, - blockRange: [blockId], - isMultiBlock: false, - }), - }; -} - -function shouldCloseInlineSessionPrompt(session: AISession): boolean { - return ( - session.surface === "inline-edit" && session.contextualPrompt != null - ); -} - -function closeInlineSessionPrompt( - session: AISession, -): AISession["contextualPrompt"] | undefined { - if (!shouldCloseInlineSessionPrompt(session) || !session.contextualPrompt) { - return session.contextualPrompt; - } - - return { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: false, - isSubmitting: false, - }, - }; -} - -function createDefaultSessionFastApplyMetrics(): AISessionMetrics["fastApply"] { - return { - attemptCount: 0, - nativeFastApplyCount: 0, - scopedReplacementCount: 0, - plainMarkdownCount: 0, - failedCount: 0, - }; -} - -function accumulateSessionFastApplyMetrics( - current: AISessionMetrics["fastApply"] | undefined, - fastApply: FastApplyDebugState | undefined, -): AISessionMetrics["fastApply"] { - const next = { - ...(current ?? createDefaultSessionFastApplyMetrics()), - }; - if (!fastApply?.attempted) { - return next; - } - next.attemptCount += 1; - switch (fastApply.executionPath) { - case "native-fast-apply": - next.nativeFastApplyCount += 1; - return next; - case "scoped-replacement": - next.scopedReplacementCount += 1; - return next; - case "plain-markdown": - next.plainMarkdownCount += 1; - return next; - default: - next.failedCount += 1; - return next; - } -} - -function selectionMatchesSnapshot( - selection: TextSelection, - snapshot: AISessionSelectionSnapshot | null, -): boolean { - if (!snapshot) { - return false; - } - - return ( - selection.anchor.blockId === snapshot.anchor.blockId && - selection.anchor.offset === snapshot.anchor.offset && - selection.focus.blockId === snapshot.focus.blockId && - selection.focus.offset === snapshot.focus.offset && - selection.isMultiBlock === snapshot.isMultiBlock && - selection.blockRange.length === snapshot.blockRange.length && - selection.blockRange.every( - (blockId, index) => blockId === snapshot.blockRange[index], - ) - ); -} - -function resolveSessionSelectionSnapshots( - session: AISession, -): readonly AISessionSelectionSnapshot[] { - const snapshots: AISessionSelectionSnapshot[] = []; - const activeTurn = - session.activeTurnId != null - ? (session.turns.find((turn) => turn.id === session.activeTurnId) ?? - null) - : (session.turns[session.turns.length - 1] ?? null); - if (activeTurn?.selection) { - snapshots.push(activeTurn.selection); - } - if (session.contextualPrompt?.anchor.selectionSnapshot) { - snapshots.push(session.contextualPrompt.anchor.selectionSnapshot); - } - if (session.target.kind === "selection") { - snapshots.push( - resolveSessionSelectionSnapshot(session.target.selection), - ); - } - return snapshots; -} - -function sessionTargetMatches( - session: AISession, - target: AISessionTarget, -): boolean { - if (session.target.kind !== target.kind) { - return false; - } - if (target.kind !== "selection") { - return areStructuredValuesEqual(session.target, target); - } - return sessionSelectionMatches(session, target.selection); -} - -function sessionSelectionMatches( - session: AISession, - selection: TextSelection, -): boolean { - return resolveSessionSelectionSnapshots(session).some((snapshot) => - selectionMatchesSnapshot(selection, snapshot), - ); +): AIInlineCompletionController | null { + return getInlineCompletionController(editor); } -function resolveSessionBlockId( +export function getAIInlineHistoryController( editor: Editor, - session: AISession, -): string | null { - if (session.target.kind === "block") { - return session.target.blockId; - } - if (session.target.kind === "selection") { - return session.target.blockId; - } - return ( - resolveActiveBlockId(editor.selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null - ); -} - -function resolveBlockInsertionOffset(editor: Editor, blockId: string): number { - const selection = editor.selection; - const block = editor.getBlock(blockId); - const fallbackOffset = - block && isVisuallyEmptyInlineText(block.textContent()) - ? 0 - : (block?.textContent().length ?? 0); - if (selection?.type !== "text") { - return fallbackOffset; - } - const range = selection.toRange(); - if (selection.isCollapsed) { - return selection.anchor.blockId === blockId - ? selection.anchor.offset - : fallbackOffset; - } - if (range.start.blockId === blockId && range.end.blockId === blockId) { - return range.end.offset; - } - if (range.end.blockId === blockId) { - return range.end.offset; - } - if (range.start.blockId === blockId) { - return range.start.offset; - } - return fallbackOffset; -} - -function appendUniqueString( - values: readonly string[], - value: string, -): string[] { - return values.includes(value) ? [...values] : [...values, value]; -} - -function areSuggestionsEqual( - previous: readonly PersistentSuggestion[], - next: readonly PersistentSuggestion[], -): boolean { - if (previous.length !== next.length) { - return false; - } - - for (let index = 0; index < previous.length; index += 1) { - const previousSuggestion = previous[index]; - const nextSuggestion = next[index]; - if ( - previousSuggestion.id !== nextSuggestion.id || - previousSuggestion.kind !== nextSuggestion.kind || - previousSuggestion.blockId !== nextSuggestion.blockId || - previousSuggestion.action !== nextSuggestion.action || - previousSuggestion.author !== nextSuggestion.author || - previousSuggestion.authorType !== nextSuggestion.authorType || - previousSuggestion.createdAt !== nextSuggestion.createdAt || - previousSuggestion.model !== nextSuggestion.model || - previousSuggestion.sessionId !== nextSuggestion.sessionId - ) { - return false; - } - if ( - previousSuggestion.kind === "text" && - nextSuggestion.kind === "text" && - (previousSuggestion.offset !== nextSuggestion.offset || - previousSuggestion.length !== nextSuggestion.length) - ) { - return false; - } - if ( - previousSuggestion.kind === "block" && - nextSuggestion.kind === "block" && - JSON.stringify(previousSuggestion.previousState) !== - JSON.stringify(nextSuggestion.previousState) - ) { - return false; - } - } - - return true; -} - -function areAIControllerStatesEqual( - previous: AIControllerState, - next: AIControllerState, -): boolean { - if ( - previous.status !== next.status || - previous.activeSessionId !== next.activeSessionId || - previous.suggestMode !== next.suggestMode || - previous.commandMenuOpen !== next.commandMenuOpen || - previous.lastRoute !== next.lastRoute - ) { - return false; - } - - if ( - !areGenerationsEqual(previous.activeGeneration, next.activeGeneration) - ) { - return false; - } - - if ( - !areEphemeralSuggestionsEqual( - previous.ephemeralSuggestion, - next.ephemeralSuggestion, - ) - ) { - return false; - } - - return areSessionsEqual(previous.sessions, next.sessions); -} - -function areGenerationsEqual( - previous: AIControllerState["activeGeneration"], - next: AIControllerState["activeGeneration"], -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - - if ( - previous.id !== next.id || - previous.zoneId !== next.zoneId || - previous.blockId !== next.blockId || - previous.target !== next.target || - previous.sessionId !== next.sessionId || - previous.surface !== next.surface || - previous.prompt !== next.prompt || - previous.status !== next.status || - previous.tokenCount !== next.tokenCount || - previous.undoGroupId !== next.undoGroupId || - previous.text !== next.text || - previous.commandId !== next.commandId || - previous.contentFormat !== next.contentFormat || - previous.route !== next.route || - previous.mutationMode !== next.mutationMode || - previous.planState !== next.planState || - previous.targetKind !== next.targetKind || - !areStructuredValuesEqual( - previous.structuredPreview, - next.structuredPreview, - ) || - !areStructuredValuesEqual(previous.reviewItems, next.reviewItems) || - !areStructuredValuesEqual(previous.plan, next.plan) || - !areStructuredValuesEqual(previous.debug, next.debug) - ) { - return false; - } - - if (!areStringArraysEqual(previous.suggestionIds, next.suggestionIds)) { - return false; - } - - if (previous.steps.length !== next.steps.length) { - return false; - } - - for (let index = 0; index < previous.steps.length; index += 1) { - const previousStep = previous.steps[index]; - const nextStep = next.steps[index]; - if ( - previousStep.index !== nextStep.index || - previousStep.type !== nextStep.type || - previousStep.toolName !== nextStep.toolName || - previousStep.toolCallId !== nextStep.toolCallId || - previousStep.status !== nextStep.status || - previousStep.input !== nextStep.input || - previousStep.output !== nextStep.output - ) { - return false; - } - } - - return true; -} - -function areSessionsEqual( - previous: readonly AISession[], - next: readonly AISession[], -): boolean { - if (previous.length !== next.length) { - return false; - } - for (let index = 0; index < previous.length; index += 1) { - const previousSession = previous[index]; - const nextSession = next[index]; - if ( - !previousSession || - !nextSession || - previousSession.id !== nextSession.id || - previousSession.surface !== nextSession.surface || - previousSession.status !== nextSession.status || - previousSession.createdAt !== nextSession.createdAt || - previousSession.updatedAt !== nextSession.updatedAt || - previousSession.activeTurnId !== nextSession.activeTurnId || - !areStructuredValuesEqual( - previousSession.target, - nextSession.target, - ) || - !areStructuredValuesEqual( - previousSession.anchor, - nextSession.anchor, - ) || - !areStructuredValuesEqual( - previousSession.contextualPrompt, - nextSession.contextualPrompt, - ) || - !areStructuredValuesEqual( - previousSession.turns, - nextSession.turns, - ) || - !areStructuredValuesEqual( - previousSession.promptHistory, - nextSession.promptHistory, - ) || - !areStringArraysEqual( - previousSession.generationIds, - nextSession.generationIds, - ) || - !areStringArraysEqual( - previousSession.pendingSuggestionIds, - nextSession.pendingSuggestionIds, - ) || - !areStringArraysEqual( - previousSession.pendingReviewItemIds, - nextSession.pendingReviewItemIds, - ) || - !areStructuredValuesEqual( - previousSession.metrics, - nextSession.metrics, - ) - ) { - return false; - } - } - return true; -} - -function areInlineHistorySnapshotsEqual( - previous: AIInlineHistorySnapshot, - next: AIInlineHistorySnapshot, -): boolean { - return ( - previous.activeSessionId === next.activeSessionId && - previous.documentVersion === next.documentVersion && - previous.kind === next.kind && - areSessionsEqual(previous.sessions, next.sessions) - ); -} - -function didInlineHistoryCheckpointChange( - previousState: AIControllerState, - nextState: AIControllerState, -): boolean { - return !areStructuredValuesEqual( - buildInlineHistoryCheckpoint(previousState), - buildInlineHistoryCheckpoint(nextState), - ); -} - -function buildInlineHistoryCheckpoint(state: AIControllerState): { - activeSessionId: string | null; - sessions: Array<{ - id: string; - isOpen: boolean; - target: AISessionSelectionSnapshot | null; - latestSettledTurn: { - id: string; - prompt: string; - selection: AISessionSelectionSnapshot | null; - } | null; - settledTurnCount: number; - }>; -} { - const inlineSessions = state.sessions.filter( - (session) => session.surface === "inline-edit", - ); - return { - activeSessionId: state.activeSessionId ?? null, - sessions: inlineSessions.map((session) => { - const settledTurns = session.turns.filter( - (turn) => turn.status !== "streaming", - ); - const latestSettledTurn = - settledTurns[settledTurns.length - 1] ?? null; - return { - id: session.id, - isOpen: session.contextualPrompt?.composer.isOpen ?? false, - target: - session.contextualPrompt?.anchor.selectionSnapshot ?? - (session.target.kind === "selection" - ? resolveSessionSelectionSnapshot( - session.target.selection, - ) - : null), - latestSettledTurn: latestSettledTurn - ? { - id: latestSettledTurn.id, - prompt: latestSettledTurn.prompt, - selection: latestSettledTurn.selection ?? null, - } - : null, - settledTurnCount: settledTurns.length, - }; - }), - }; -} - -function countSettledInlineTurns( - snapshot: AIInlineHistorySnapshot, - sessionId?: string | null, -): number { - if (sessionId) { - const session = snapshot.sessions.find( - (item) => item.id === sessionId && item.surface === "inline-edit", - ); - if (!session) { - return 0; - } - return session.turns.filter((turn) => turn.status !== "streaming") - .length; - } - return snapshot.sessions - .filter((session) => session.surface === "inline-edit") - .reduce( - (count, session) => - count + - session.turns.filter((turn) => turn.status !== "streaming") - .length, - 0, - ); -} - -function hasStreamingInlineTurns( - snapshot: AIInlineHistorySnapshot, - sessionId?: string | null, -): boolean { - if (sessionId) { - const session = snapshot.sessions.find( - (item) => item.id === sessionId && item.surface === "inline-edit", - ); - return ( - session?.turns.some((turn) => turn.status === "streaming") ?? false - ); - } - return snapshot.sessions - .filter((session) => session.surface === "inline-edit") - .some((session) => - session.turns.some((turn) => turn.status === "streaming"), - ); -} - -function resolveInlineShortcutHistoryState( - snapshot: AIInlineHistorySnapshot, - sessionId: string | null, -): AIInlineShortcutHistoryState | null { - const session = sessionId - ? (snapshot.sessions.find( - (item) => - item.id === sessionId && item.surface === "inline-edit", - ) ?? null) - : null; - if (!session) { - return { - sessionId: null, - phase: "none", - turnCount: 0, - turnId: null, - }; - } - const durableTurns = session.turns.filter( - (turn) => turn.status !== "streaming" && turn.status !== "cancelled", - ); - if (durableTurns.length === 0) { - return { - sessionId: null, - phase: "none", - turnCount: 0, - turnId: null, - }; - } - const latestTurn = durableTurns[durableTurns.length - 1] ?? null; - if (!latestTurn) { - return null; - } - if (latestTurn.status === "review") { - return { - sessionId, - phase: "review", - turnCount: durableTurns.length, - turnId: latestTurn.id, - }; - } - if (latestTurn.status === "accepted" || latestTurn.status === "rejected") { - return { - sessionId, - phase: "resolved", - turnCount: durableTurns.length, - turnId: latestTurn.id, - resolution: latestTurn.status, - }; - } - return null; -} - -function areInlineShortcutHistoryStatesEqual( - left: AIInlineShortcutHistoryState, - right: AIInlineShortcutHistoryState, -): boolean { - return ( - left.sessionId === right.sessionId && - left.phase === right.phase && - left.turnCount === right.turnCount && - left.turnId === right.turnId && - left.resolution === right.resolution - ); -} - -function shouldReplaceInlineShortcutWaypointRepresentative( - state: AIInlineShortcutHistoryState, - currentSnapshot: AIInlineHistorySnapshot | null, - nextSnapshot: AIInlineHistorySnapshot, -): boolean { - if (!currentSnapshot) { - return true; - } - const currentSession = state.sessionId - ? (currentSnapshot.sessions.find( - (session) => - session.id === state.sessionId && - session.surface === "inline-edit", - ) ?? null) - : null; - const nextSession = state.sessionId - ? (nextSnapshot.sessions.find( - (session) => - session.id === state.sessionId && - session.surface === "inline-edit", - ) ?? null) - : null; - if (state.phase === "review") { - const currentOpen = - currentSession?.contextualPrompt?.composer.isOpen === true; - const nextOpen = - nextSession?.contextualPrompt?.composer.isOpen === true; - if (currentOpen !== nextOpen) { - return nextOpen; - } - } - if (state.phase === "resolved") { - const currentOpen = - currentSession?.contextualPrompt?.composer.isOpen === true; - const nextOpen = - nextSession?.contextualPrompt?.composer.isOpen === true; - if (currentOpen !== nextOpen) { - return !nextOpen; - } - } - return true; -} - -function areEphemeralSuggestionsEqual( - previous: AIControllerState["ephemeralSuggestion"], - next: AIControllerState["ephemeralSuggestion"], -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - +): AIInlineHistoryController | null { return ( - previous.id === next.id && - previous.blockId === next.blockId && - previous.offset === next.offset && - previous.text === next.text && - previous.type === next.type && - previous.blockType === next.blockType && - previous.props === next.props - ); -} - -function areStringArraysEqual( - previous: readonly string[] | undefined, - next: readonly string[] | undefined, -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - if (previous.length !== next.length) { - return false; - } - - for (let index = 0; index < previous.length; index += 1) { - if (previous[index] !== next[index]) { - return false; - } - } - - return true; -} - -function areStructuredValuesEqual(previous: unknown, next: unknown): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - - try { - return JSON.stringify(previous) === JSON.stringify(next); - } catch { - return false; - } -} - -function buildSelectionReplacementOps( - editor: Editor, - selection: TextSelection, - insertedText: string, -): DocumentOp[] { - const range = selection.toRange(); - if (range.start.blockId === range.end.blockId) { - return [ - { - type: "replace-text", - blockId: range.start.blockId, - offset: range.start.offset, - length: range.end.offset - range.start.offset, - text: insertedText, - }, - ]; - } - const startId = range.start.blockId; - const endId = range.end.blockId; - const startText = editor.getBlock(startId)?.textContent() ?? ""; - const middleIds = range.blockRange.slice(1, -1); - const suffixDeltas = sliceInlineDeltasFromOffset( - editor.getBlock(endId)?.textDeltas() ?? [], - range.end.offset, + editor.internals.getSlot( + AI_INLINE_HISTORY_SLOT, + ) ?? null ); - const ops: DocumentOp[] = []; - - if (range.start.offset < startText.length) { - ops.push({ - type: "delete-text", - blockId: startId, - offset: range.start.offset, - length: startText.length - range.start.offset, - }); - } - - if (range.end.offset > 0) { - ops.push({ - type: "delete-text", - blockId: endId, - offset: 0, - length: range.end.offset, - }); - } - - for (const blockId of middleIds) { - ops.push({ - type: "delete-block", - blockId, - }); - } - - let insertionOffset = range.start.offset; - if (insertedText.length > 0) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: insertedText, - }); - insertionOffset += insertedText.length; - } - - for (const delta of suffixDeltas) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: delta.insert, - marks: delta.attributes, - }); - insertionOffset += delta.insert.length; - } - - ops.push({ - type: "delete-block", - blockId: endId, - }); - return ops; } -function sliceInlineDeltasFromOffset( - deltas: readonly { insert: string; attributes?: Record }[], - startOffset: number, -): Array<{ insert: string; attributes?: Record }> { - const sliced: Array<{ - insert: string; - attributes?: Record; - }> = []; - let offset = 0; - for (const delta of deltas) { - const length = delta.insert.length; - if (startOffset >= offset + length) { - offset += length; - continue; - } - const localStart = Math.max(0, startOffset - offset); - const text = delta.insert.slice(localStart); - if (text.length > 0) { - sliced.push( - delta.attributes - ? { insert: text, attributes: delta.attributes } - : { insert: text }, - ); - } - offset += length; - } - return sliced; -} - -function resolveSelectionText( +export function getAIReviewController( editor: Editor, - selection: TextSelection, -): string { - const range = selection.toRange(); - const blockIds = range.blockRange; - const parts = blockIds.map((blockId, index) => { - const block = editor.getBlock(blockId); - if (!block) return ""; - - let rawOffset = 0; - let resolved = ""; - const startOffset = index === 0 ? range.start.offset : 0; - const endOffset = - index === blockIds.length - 1 - ? range.end.offset - : Number.POSITIVE_INFINITY; - - for (const delta of block.textDeltas()) { - const length = delta.insert.length; - const rawStart = rawOffset; - const rawEnd = rawOffset + length; - rawOffset = rawEnd; - - if (endOffset <= rawStart || startOffset >= rawEnd) { - continue; - } - - const sliceStart = Math.max(0, startOffset - rawStart); - const sliceEnd = Math.min(length, endOffset - rawStart); - if (sliceEnd <= sliceStart) { - continue; - } - - const suggestion = delta.attributes?.suggestion as - | { action?: string } - | undefined; - if (suggestion?.action === "delete") { - continue; - } - - resolved += delta.insert.slice(sliceStart, sliceEnd); - } - - return resolved; - }); - - return parts.join("\n"); -} - -function shouldReplaceEmptyMarkdownTarget( - block: ReturnType, -): boolean { - if (!block) { - return false; - } - +): AIReviewController | null { return ( - block.type === "paragraph" && - isVisuallyEmptyInlineText(block.textContent({ resolved: true })) + editor.internals.getSlot( + AI_REVIEW_CONTROLLER_SLOT, + ) ?? null ); } - -function shouldTrimLeadingBlankBlockGenerationText( - block: ReturnType, -): boolean { - if (!block) { - return false; - } - return isVisuallyEmptyInlineText(block.textContent({ resolved: true })); -} - -function trimLeadingBlankBlockGenerationText(text: string): string { - return text.replace(/^(?:[ \t]*\r?\n)+/, ""); -} - -function isVisuallyEmptyInlineText(text: string): boolean { - return text.replace(/\u200B/g, "").trim().length === 0; -} diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts new file mode 100644 index 0000000..38aafe2 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts @@ -0,0 +1,467 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart1 = { +destroy(this: any): void { + this._unsubscribeInlineCompletion?.(); + this._unsubscribeInlineCompletion = null; + this._unsubscribeHistoryApplied?.(); + this._unsubscribeHistoryApplied = null; + this._unsubscribeUndoHistoryMetadata?.(); + this._unsubscribeUndoHistoryMetadata = null; + }, + +getState(this: any): AIControllerState { + return this._state; + }, + +subscribe(this: any, listener: () => void): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + }, + +getSessions(this: any): readonly AISession[] { + return this._state.sessions; + }, + +getActiveSession(this: any): AISession | null { + const activeSessionId = this._state.activeSessionId; + if (!activeSessionId) { + return null; + } + return ( + this._state.sessions.find( + (session) => session.id === activeSessionId, + ) ?? null + ); + }, + +subscribeSessions(this: any, listener: () => void): () => void { + this._sessionListeners.add(listener); + return () => this._sessionListeners.delete(listener); + }, + +getStreamEvents(this: any): readonly AIStreamEvent[] { + return this._streamEvents; + }, + +subscribeStreamEvents(this: any, listener: () => void): () => void { + this._streamEventListeners.add(listener); + return () => this._streamEventListeners.delete(listener); + }, + +getCommands(this: any): readonly AICommandBinding[] { + return this._registry.list(this.getCommandContext()); + }, + +getCommandContext(this: any): AICommandContext { + const selection = this._editor.selection; + const blockId = resolveActiveBlockId(selection); + return { + editor: this._editor, + selection, + selectedText: + selection?.type === "text" + ? resolveSelectionText(this._editor, selection) + : "", + blockType: blockId + ? (this._editor.getBlock(blockId)?.type ?? null) + : null, + blockId, + }; + }, + +startSession(this: any, input: { + surface: AISurface; + target?: "auto" | "selection" | "block" | "document"; + }): AISession { + const now = Date.now(); + const target = resolveSessionTarget(this._editor, input.target); + const session: AISession = { + id: crypto.randomUUID(), + surface: input.surface, + status: "idle", + target, + contextualPrompt: + input.surface === "inline-edit" + ? resolveContextualPromptState(target) + : undefined, + turns: [], + activeTurnId: undefined, + promptHistory: [], + generationIds: [], + pendingSuggestionIds: [], + pendingReviewItemIds: [], + createdAt: now, + updatedAt: now, + metrics: { + streamEventCount: 0, + patchCount: 0, + fastApply: createDefaultSessionFastApplyMetrics(), + }, + anchor: resolveSessionAnchor(this._editor.selection), + }; + this._setState({ + sessions: [...this._state.sessions, session], + activeSessionId: session.id, + }); + return session; + }, + +openContextualPrompt(this: any, input?: { + surface?: Extract; + target?: "auto" | "selection" | "block" | "document"; + }): AISession | null { + const surface = input?.surface ?? "inline-edit"; + const target = resolveSessionTarget( + this._editor, + input?.target ?? "selection", + ); + if (surface === "inline-edit" && target.kind !== "selection") { + return null; + } + const activeSession = this._state.sessions.find( + (session) => + session.id === this._state.activeSessionId && + session.surface === surface && + session.status !== "cancelled", + ); + if ( + activeSession && + activeSession.status !== "complete" && + sessionTargetMatches(activeSession, target) + ) { + this._updateSession(activeSession.id, { + target, + anchor: resolveSessionAnchor(this._editor.selection), + contextualPrompt: { + ...(activeSession.contextualPrompt ?? + resolveContextualPromptState(target)), + anchor: resolveContextualPromptAnchor(target), + composer: { + ...(activeSession.contextualPrompt?.composer ?? { + draftPrompt: "", + isSubmitting: false, + canSubmitFollowUp: true, + openReason: "user", + }), + isOpen: true, + openReason: "user", + }, + }, + }); + return this.getActiveSession(); + } + if (activeSession?.surface === "inline-edit") { + this._setInlineSessionComposerOpen(activeSession.id, false); + } + const nextSession = this.startSession({ + surface, + target: input?.target ?? "selection", + }); + return nextSession.contextualPrompt?.anchor.kind === "text-range" + ? nextSession + : null; + }, + +updateContextualPromptDraft(this: any, sessionId: string, draftPrompt: string): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session?.contextualPrompt) { + return; + } + this._updateSession(sessionId, { + contextualPrompt: { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + draftPrompt, + }, + }, + }); + }, + +setContextualPromptAnchorRect(this: any, + sessionId: string, + rect: AIContextualPromptRect | null, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session?.contextualPrompt) { + return; + } + this._updateSession(sessionId, { + contextualPrompt: { + ...session.contextualPrompt, + anchor: { + ...session.contextualPrompt.anchor, + lastResolvedRect: rect, + }, + }, + }); + }, + +resolveSessionTurn(this: any, + sessionId: string, + turnId: string, + resolution: AISessionResolution, + ): boolean { + return this._resolveSessionTurn(sessionId, turnId, resolution); + }, + +acceptSessionTurn(this: any, sessionId: string, turnId: string): boolean { + return this.resolveSessionTurn(sessionId, turnId, "accept"); + }, + +rejectSessionTurn(this: any, sessionId: string, turnId: string): boolean { + return this.resolveSessionTurn(sessionId, turnId, "reject"); + }, + +runSessionPrompt(this: any, + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): Promise { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return Promise.reject( + new Error(`Unknown AI session "${sessionId}"`), + ); + } + this._recordInlinePromptSubmissionCheckpoint(sessionId, prompt); + + const operation = + options?.operation ?? + resolveRequestedOperationForSession( + this._editor, + session, + prompt, + options, + this._documentVersion, + ); + if (operation.kind === "rewrite-selection") { + const selection = resolveSelectionForRequestedOperation( + this._editor, + operation, + ); + if (!selection) { + return Promise.reject( + new Error( + "Cannot run a session prompt without a valid text selection", + ), + ); + } + return this._runSelectionGeneration( + prompt, + selection, + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + }, + ); + } + if (operation.kind === "document-transform") { + const targetBlockIds = + operation.target.kind === "document" && + (operation.target.blockIds?.length ?? 0) > 0 + ? [...(operation.target.blockIds ?? [])] + : undefined; + const replacePreviousGeneratedBlocks = + shouldReplacePreviousGeneratedBlocks(session, prompt); + return this._runDocumentGeneration( + prompt, + options?.blockId ?? + (operation.target.kind === "document" + ? operation.target.activeBlockId + : null), + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + replaceBlockIds: + targetBlockIds ?? + (replacePreviousGeneratedBlocks + ? resolvePreviousGeneratedBlockIds(session) + : undefined), + }, + ); + } + const blockId = + options?.blockId ?? + resolveBlockIdForRequestedOperation(operation) ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!blockId) { + return Promise.reject( + new Error( + "Cannot run an AI session prompt without a target block", + ), + ); + } + return this._runBlockGeneration( + prompt, + blockId, + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + }, + ); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts new file mode 100644 index 0000000..bfcb297 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts @@ -0,0 +1,477 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart10 = { +_resolvePlanValidationTargetKind(this: any, blockId: string): AITargetKind { + const blockType = this._editor.getBlock(blockId)?.type ?? null; + if (blockType === "database") { + return "database"; + } + if (blockType === "table") { + return "table"; + } + return "block"; + }, + +_verifyMarkdownFastApplyResult(this: any, + blockIds: readonly string[], + markdown: string, + ): { valid: boolean; reason?: string } { + if (markdown.trim().length === 0) { + return { valid: false, reason: "empty-merged-markdown" }; + } + const startBlockId = blockIds[0]; + const verificationResult = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: markdown, + position: startBlockId ? { before: startBlockId } : undefined, + surface: "ai-markdown-fast-apply-verify", + }); + if (verificationResult.blocks.length === 0) { + return { + valid: false, + reason: "markdown-parse-produced-no-blocks", + }; + } + return { valid: true }; + }, + +_verifyFlowPatchPlanResult(this: any, + plan: { + edits: Array<{ + locator: { blockId?: string; blockIds?: string[] }; + }>; + }, + ops: readonly DocumentOp[], + scopeBlockIds: readonly string[], + ): { + valid: boolean; + reason?: string; + untouchedBlockMutationCount: number; + } { + const targetedBlockIds = new Set( + plan.edits.flatMap((edit) => [ + ...(edit.locator.blockId ? [edit.locator.blockId] : []), + ...(edit.locator.blockIds ?? []), + ]), + ); + const scopeSet = new Set(scopeBlockIds); + const mutatedExistingBlockIds = new Set(); + const outOfScopeMutations = new Set(); + const createdBlockIds = new Set(); + + for (const op of ops) { + if (op.type === "insert-block") { + createdBlockIds.add(op.blockId); + } + for (const blockId of this._readBlockIdsFromOp(op)) { + if (scopeSet.has(blockId)) { + mutatedExistingBlockIds.add(blockId); + } else if ( + !createdBlockIds.has(blockId) && + op.type !== "insert-block" + ) { + outOfScopeMutations.add(blockId); + } + } + } + + if (outOfScopeMutations.size > 0) { + return { + valid: false, + reason: `flow-patch-mutated-outside-scope:${[...outOfScopeMutations].join(",")}`, + untouchedBlockMutationCount: 0, + }; + } + + const untouchedBlockMutationCount = [...mutatedExistingBlockIds].filter( + (blockId) => !targetedBlockIds.has(blockId), + ).length; + return { + valid: untouchedBlockMutationCount === 0, + reason: + untouchedBlockMutationCount > 0 + ? "flow-patch-mutated-untargeted-blocks" + : undefined, + untouchedBlockMutationCount, + }; + }, + +_buildMarkdownScopedReplacementOps(this: any, + blockIds: readonly string[], + text: string, + ): DocumentOp[] { + const startBlockId = blockIds[0]; + if (!startBlockId) { + return []; + } + const { ops } = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: text, + position: { before: startBlockId }, + surface: "ai-markdown-fast-apply", + }); + return [ + ...ops, + ...blockIds.map( + (currentBlockId) => + ({ + type: "delete-block", + blockId: currentBlockId, + }) satisfies DocumentOp, + ), + ]; + }, + +_summarizeFastApplyFallbackOps(this: any, + kind: "scoped-replacement" | "plain-markdown", + ops: readonly DocumentOp[], + targetBlockCount?: number, + ): { + kind: "scoped-replacement" | "plain-markdown"; + opsCount: number; + insertedBlockCount: number; + deletedBlockCount: number; + targetBlockCount?: number; + } { + let insertedBlockCount = 0; + let deletedBlockCount = 0; + for (const op of ops) { + if (op.type === "insert-block") { + insertedBlockCount += 1; + } else if (op.type === "delete-block") { + deletedBlockCount += 1; + } + } + return { + kind, + opsCount: ops.length, + insertedBlockCount, + deletedBlockCount, + targetBlockCount, + }; + }, + +_readBlockIdsFromOp(this: any, op: DocumentOp): string[] { + const blockIds = new Set(); + if ("blockId" in op && typeof op.blockId === "string") { + blockIds.add(op.blockId); + } + if ("targetBlockId" in op && typeof op.targetBlockId === "string") { + blockIds.add(op.targetBlockId); + } + if ("sourceBlockId" in op && typeof op.sourceBlockId === "string") { + blockIds.add(op.sourceBlockId); + } + return [...blockIds]; + }, + +_recordFastApplyDebug(this: any, + overrides: Partial< + NonNullable["fastApply"]> + >, + ): void { + const activeGeneration = this._state.activeGeneration; + if (!activeGeneration?.debug) { + return; + } + const currentFastApply = activeGeneration.debug.fastApply ?? { + attempted: false, + succeeded: false, + }; + this._resolveActiveGeneration({ + debug: { + ...activeGeneration.debug, + fastApply: { + ...currentFastApply, + ...overrides, + }, + }, + }); + }, + +_applySuggestedMarkdownPlaceholderReplacement(this: any, + blockId: string, + text: string, + sessionId?: string, + replaceTargetBlock?: boolean, + replaceBlockIds?: readonly string[], + ): DocumentOp[] | null { + const targetBlock = this._editor.getBlock(blockId); + if ( + !replaceTargetBlock && + !shouldReplaceEmptyMarkdownTarget(targetBlock) + ) { + return null; + } + + const { ops } = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: text, + position: { before: blockId }, + surface: "ai-markdown", + }); + if (ops.length === 0) { + return null; + } + + const deleteBlockIds = resolveReplacementDeleteBlockIds( + this._editor, + blockId, + replaceBlockIds, + ); + const replacementOps = [ + ...ops, + ...deleteBlockIds.map((nextBlockId) => ({ + type: "delete-block" as const, + blockId: nextBlockId, + })), + ] satisfies DocumentOp[]; + this._applySuggestedAIOps(replacementOps, sessionId); + return replacementOps; + }, + +_refreshStreamingMarkdownBlockPreview(this: any, + blockId: string, + text: string, + mutationMode: NonNullable, + sessionId: string | undefined, + baselineSuggestionIds: ReadonlySet, + previewSuggestionIds: readonly string[], + previousNormalizedText: string, + replaceTargetBlock?: boolean, + replaceBlockIds?: readonly string[], + ): { suggestionIds: string[]; normalizedText: string } { + const normalizedText = normalizeFlowMarkdownOutput(text); + if (normalizedText === previousNormalizedText) { + return { + suggestionIds: [...previewSuggestionIds], + normalizedText, + }; + } + + this._rejectPreviewSuggestions(previewSuggestionIds); + + if ( + normalizedText.trim().length === 0 && + !replaceTargetBlock && + (replaceBlockIds?.length ?? 0) === 0 + ) { + return { + suggestionIds: [], + normalizedText, + }; + } + + this._commitBufferedBlockGeneration( + blockId, + normalizedText, + mutationMode, + "markdown", + sessionId, + { replaceTargetBlock, replaceBlockIds }, + ); + + return { + suggestionIds: this.getSuggestions() + .map((item) => item.id) + .filter( + (suggestionId) => !baselineSuggestionIds.has(suggestionId), + ), + normalizedText, + }; + }, + +_commitStructuredPlan(this: any, + ops: DocumentOp[], + reviewSafe: boolean, + mutationMode: NonNullable, + adapterId: NonNullable, + blockClass: NonNullable, + transportKind: NonNullable, + ): AIMutationReceipt { + if (ops.length === 0) { + return buildMutationReceipt({ + status: "noop", + ops, + adapterId, + blockClass, + transportKind, + }); + } + + if (mutationMode === "direct-stream") { + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + return buildMutationReceipt({ + status: "applied", + ops, + adapterId, + blockClass, + transportKind, + }); + } + + if (reviewSafe) { + this._applySuggestedAIOps(ops); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId, + blockClass, + transportKind, + }); + } + return buildMutationReceipt({ + status: "staged_review", + ops, + adapterId, + blockClass, + transportKind, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts new file mode 100644 index 0000000..227b3a7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts @@ -0,0 +1,454 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart11 = { +_buildFallbackMutationReceipt(this: any, input: { + currentText: string; + suggestionIds: readonly string[]; + reviewItems: readonly StructuralReviewItem[]; + planExecutionIssueCount: number; + adapterId: NonNullable; + blockClass: NonNullable; + transportKind: NonNullable; + }): AIMutationReceipt { + if (input.planExecutionIssueCount > 0) { + return buildMutationReceipt({ + status: "invalid", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + issues: ["The generated mutation plan could not be executed."], + }); + } + if (input.reviewItems.length > 0) { + return buildMutationReceipt({ + status: "staged_review", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + }); + } + if (input.suggestionIds.length > 0) { + return buildMutationReceipt({ + status: "staged_suggestions", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + }); + } + return buildMutationReceipt({ + status: input.currentText.trim().length > 0 ? "applied" : "noop", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + }); + }, + +async _buildWorkingSet(this: any, + toolRuntime: ToolRuntime, + route: ReturnType, + target: GenerationTarget, + blockId: string, + prompt: string, + ): Promise { + const selectionSignature = this._createSelectionSignature( + this._editor.selection, + ); + if (target.type === "selection") { + const trackedBlockIds = [ + ...new Set(target.selection.toRange().blockRange), + ]; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "selection", + routeConfidence: route.confidence, + context: { + selection: target.selection, + selectedText: resolveSelectionText( + this._editor, + target.selection, + ), + }, + trackedBlockIds, + blockRevisions: this._captureBlockRevisions(trackedBlockIds), + selectionSignature, + }; + } + + if (route.useCursorContext) { + const retrievedSpan = + await this._resolveMarkdownFastApplyRetrievedSpan( + toolRuntime, + route, + blockId, + prompt, + ); + if ( + route.applyStrategy === "markdown-fast-apply" && + retrievedSpan + ) { + const context = (await toolRuntime.executeTool( + "get_context", + { + format: "markdown", + includeSelection: true, + includeSuggestions: this._state.suggestMode, + range: retrievedSpan.range, + }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "cursor-context", + context: { + ...context, + retrievedSpan, + }, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: retrievedSpan.blockIds.length, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(retrievedSpan.blockIds)], + blockRevisions: this._captureBlockRevisions( + retrievedSpan.blockIds, + ), + selectionSignature, + }; + } + const context = (await toolRuntime.executeTool( + "get_cursor_context", + { includeSuggestions: this._state.suggestMode }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + const trackedBlockIds = [ + blockId, + ...(context.surroundingBlocks ?? []).map((block) => block.id), + ]; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "cursor-context", + context, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: + context.surroundingBlocks?.length ?? 0, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(trackedBlockIds)], + blockRevisions: this._captureBlockRevisions(trackedBlockIds), + selectionSignature, + }; + } + + if (route.useDocumentSummary) { + const retrievedSpan = + await this._resolveMarkdownFastApplyRetrievedSpan( + toolRuntime, + route, + blockId, + prompt, + ); + if ( + route.applyStrategy === "markdown-fast-apply" && + retrievedSpan + ) { + const context = (await toolRuntime.executeTool( + "get_context", + { + format: "markdown", + includeSelection: true, + includeSuggestions: this._state.suggestMode, + range: retrievedSpan.range, + }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "document-summary", + context: { + ...context, + retrievedSpan, + }, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: retrievedSpan.blockIds.length, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(retrievedSpan.blockIds)], + blockRevisions: this._captureBlockRevisions( + retrievedSpan.blockIds, + ), + selectionSignature, + }; + } + const context = (await toolRuntime.executeTool( + "get_context", + { + format: "markdown", + includeSelection: true, + includeSuggestions: this._state.suggestMode, + range: { + startBlockId: blockId, + endBlockId: blockId, + }, + }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + const trackedBlockIds = [ + blockId, + ...(context.surroundingBlocks ?? []).map((block) => block.id), + ]; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "document-summary", + context, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: + context.surroundingBlocks?.length ?? 0, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(trackedBlockIds)], + blockRevisions: this._captureBlockRevisions(trackedBlockIds), + selectionSignature, + }; + } + + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "document-summary", + context: null, + routeConfidence: route.confidence, + trackedBlockIds: [blockId], + blockRevisions: this._captureBlockRevisions([blockId]), + selectionSignature, + }; + }, + +_refineRouteWithWorkingSet(this: any, + route: ReturnType, + workingSet: AIWorkingSetEnvelope | null, + ): ReturnType { + if (!workingSet?.context || typeof workingSet.context !== "object") { + return route; + } + const context = workingSet.context as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + return refineRouteWithNavigator(route, { + surroundingBlockCount: context.surroundingBlocks?.length ?? 0, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts new file mode 100644 index 0000000..40340e4 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts @@ -0,0 +1,467 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart12 = { +_validateWorkingSet(this: any, + route: ReturnType, + target: GenerationTarget, + workingSet: AIWorkingSetEnvelope | null, + ): { valid: boolean; canRefresh: boolean; reason?: string } { + if (!workingSet) { + return { valid: true, canRefresh: false }; + } + + const selectionSignature = this._createSelectionSignature( + this._editor.selection, + ); + const selectionChanged = + workingSet.selectionSignature !== selectionSignature; + const revisionChanged = + workingSet.documentVersion !== this._documentVersion || + workingSet.trackedBlockIds.some( + (blockId) => + this._editor.getBlockRevision(blockId) !== + workingSet.blockRevisions[blockId], + ); + + if (!selectionChanged && !revisionChanged) { + return { valid: true, canRefresh: false }; + } + + if ( + route.lane === "selection-rewrite" || + route.lane === "cursor-context" + ) { + return { + valid: false, + canRefresh: false, + reason: selectionChanged + ? "selection-provenance-changed" + : "local-context-changed", + }; + } + + return { + valid: false, + canRefresh: target.type === "block", + reason: revisionChanged + ? "document-revision-mismatch" + : "selection-changed", + }; + }, + +_resolveMarkdownFastApplyWindow(this: any, + route: ReturnType, + blockId: string, + ): { + range: { startBlockId: string; endBlockId: string }; + blockIds: string[]; + } | null { + const blocks = Array.from(this._editor.blocks()); + const blockIndex = blocks.findIndex((block) => block.id === blockId); + if (blockIndex === -1) { + return null; + } + + const radius = + route.targetKind === "table" + ? 0 + : route.intent === "continue" + ? 0 + : route.intent === "rewrite" || + route.intent === "local-edit" + ? 1 + : 0; + const startIndex = Math.max(0, blockIndex - radius); + const endIndex = Math.min(blocks.length - 1, blockIndex + radius); + const blockIds = blocks + .slice(startIndex, endIndex + 1) + .map((block) => block.id); + return { + range: { + startBlockId: blockIds[0] ?? blockId, + endBlockId: blockIds[blockIds.length - 1] ?? blockId, + }, + blockIds, + }; + }, + +async _resolveMarkdownFastApplyRetrievedSpan(this: any, + toolRuntime: ToolRuntime, + route: ReturnType, + blockId: string, + prompt: string, + ): Promise { + if (route.applyStrategy !== "markdown-fast-apply") { + return null; + } + + try { + const retrieved = (await toolRuntime.executeTool( + "retrieve_document_spans", + { + query: prompt, + maxResults: 1, + includeSuggestions: this._state.suggestMode, + activeBlockId: blockId, + targetBlockId: blockId, + }, + {} as never, + )) as { + spans?: AIWorkingSetRetrievedSpan[]; + }; + const retrievedSpan = retrieved.spans?.[0] ?? null; + if (retrievedSpan?.blockIds?.length) { + return retrievedSpan; + } + } catch { + // Older test fixtures or stale builds may not register the retriever yet. + } + + const markdownWindow = this._resolveMarkdownFastApplyWindow( + route, + blockId, + ); + if (!markdownWindow) { + return null; + } + return { + id: `span:${markdownWindow.blockIds.join(":")}`, + blockIds: markdownWindow.blockIds, + range: markdownWindow.range, + blockTypes: [], + headingPath: [], + preview: "", + markdown: "", + score: 0, + rationale: "window-fallback", + neighbors: { + beforeBlockId: null, + afterBlockId: null, + }, + }; + }, + +_applySuggestedAIOps(this: any, + ops: DocumentOp[], + sessionId?: string, + options?: { undoGroupId?: string }, + ): void { + this._suggestedOperationRunner.apply(ops, sessionId, options); + }, + +_captureBlockRevisions(this: any, blockIds: string[]): Record { + return Object.fromEntries( + blockIds.map((trackedBlockId) => [ + trackedBlockId, + this._editor.getBlockRevision(trackedBlockId), + ]), + ); + }, + +_resolveContentFormat(this: any, + target: GenerationState["target"], + surface?: AISurface, + ): AIContentFormat { + if (target === "selection") { + return this._contentFormat.selectionRewrite; + } + return this._contentFormat.blockGeneration; + }, + +_buildTextBlockGenerationOps(this: any, + blockId: string, + text: string, + insertionOffset?: number, + ): DocumentOp[] { + const targetBlock = this._editor.getBlock(blockId); + const normalizedText = shouldTrimLeadingBlankBlockGenerationText( + targetBlock, + ) + ? trimLeadingBlankBlockGenerationText(text) + : text; + if (normalizedText.length === 0) { + return []; + } + return [ + { + type: "insert-text", + blockId, + offset: + insertionOffset ?? targetBlock?.textContent().length ?? 0, + text: normalizedText, + }, + ]; + }, + +_buildMarkdownBlockGenerationOps(this: any, + blockId: string, + text: string, + replaceTargetBlock?: boolean, + replaceBlockIds?: readonly string[], + ): DocumentOp[] { + const targetBlock = this._editor.getBlock(blockId); + if (!targetBlock) { + return []; + } + + const { ops } = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: text, + position: { after: blockId }, + surface: "ai-markdown", + }); + if ( + !replaceTargetBlock && + !shouldReplaceEmptyMarkdownTarget(targetBlock) + ) { + return ops; + } + + const deleteBlockIds = resolveReplacementDeleteBlockIds( + this._editor, + blockId, + replaceBlockIds, + ); + return [ + ...ops, + ...deleteBlockIds.map((nextBlockId) => ({ + type: "delete-block" as const, + blockId: nextBlockId, + })), + ]; + }, + +_createSelectionSignature(this: any, + selection: SelectionState, + ): string | null { + if (!selection) { + return null; + } + if (selection.type === "text") { + return [ + "text", + selection.anchor.blockId, + selection.anchor.offset, + selection.focus.blockId, + selection.focus.offset, + String(selection.isCollapsed), + ].join(":"); + } + if (selection.type === "block") { + return `block:${selection.blockIds.join(",")}`; + } + if (selection.type === "cell") { + return [ + "cell", + selection.blockId, + selection.anchor.row, + selection.anchor.col, + selection.head.row, + selection.head.col, + ].join(":"); + } + return selection.type; + }, + +_setState(this: any, partial: Partial): void { + const previousState = this._state; + const nextState = { ...this._state, ...partial }; + if (areAIControllerStatesEqual(previousState, nextState)) { + return; + } + this._state = nextState; + if ( + !this._isRestoringInlineHistory && + !this._pendingInlineHistoryRestore + ) { + this._recordInlineHistorySnapshot(previousState, nextState); + } + this._editor.requestDecorationUpdate(); + this._emit(); + }, + +_resolveActiveGeneration(this: any, + overrides: Partial, + ): void { + const activeGeneration = this._state.activeGeneration; + if (!activeGeneration) { + return; + } + + this._setState({ + activeGeneration: { + ...activeGeneration, + ...overrides, + plan: + overrides.planState === "none" || + overrides.planState === "rejected" + ? null + : (overrides.plan ?? activeGeneration.plan), + reviewItems: + overrides.planState === "none" || + overrides.planState === "rejected" + ? [] + : (overrides.reviewItems ?? + activeGeneration.reviewItems ?? + []), + structuredPreview: + overrides.planState === "none" || + overrides.planState === "rejected" + ? null + : (overrides.structuredPreview ?? + activeGeneration.structuredPreview ?? + null), + suggestionIds: + overrides.suggestionIds ?? + activeGeneration.suggestionIds ?? + [], + }, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts new file mode 100644 index 0000000..af881c3 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts @@ -0,0 +1,484 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart13 = { +_resolveSessionTurn(this: any, + sessionId: string, + turnId: string, + resolution: AISessionResolution, + options?: { finalizeSession?: boolean }, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + const turn = session?.turns.find((item) => item.id === turnId); + if (!session || !turn) { + return false; + } + const isBottomChatDocumentTurn = + session.surface === "bottom-chat" && + (turn.target === "document" || + turn.operation?.kind === "document-transform" || + (turn.operation?.kind === "rewrite-selection" && + turn.operation.target.kind === "scoped-range" && + (turn.operation.target.scope === "document" || + turn.operation.target.contentFormat === "markdown"))); + const turnUndoGroupId = isBottomChatDocumentTurn + ? turn.undoGroupId + : undefined; + const turnSuggestionResolutionOrigin = + turnUndoGroupId != null ? AI_SESSION_SUGGESTION_ORIGIN : undefined; + const undoHistoryBeforeSnapshot = this._undoHistoryMetadata + ? this._createInlineTurnUndoBeforeSnapshot(sessionId, turnId) + : null; + const refreshedInlineSelectionTarget = + session.surface === "inline-edit" && resolution === "accept" + ? (resolveAcceptedInlineSelectionTarget( + this._editor, + turn.operation, + turn.suggestionIds, + ) ?? resolveLiveInlineSelectionTarget(this._editor)) + : null; + const resolveSuggestionsForTurn = + resolution === "accept" + ? (suggestionIds: readonly string[]) => + acceptSuggestions(this._editor, suggestionIds, { + origin: turnSuggestionResolutionOrigin, + undoGroupId: turnUndoGroupId, + }) + : (suggestionIds: readonly string[]) => + rejectSuggestions(this._editor, suggestionIds, { + origin: turnSuggestionResolutionOrigin, + undoGroupId: turnUndoGroupId, + }); + const resolveReviewItems = + resolution === "accept" + ? (reviewItemIds: readonly string[]) => + this.acceptReviewItems(reviewItemIds) + : (reviewItemIds: readonly string[]) => + this.rejectReviewItems(reviewItemIds); + let resolved = false; + resolved = resolveSuggestionsForTurn(turn.suggestionIds) || resolved; + if ( + this._state.activeGeneration?.sessionId === sessionId && + this._state.activeGeneration.turnId === turnId && + this._state.activeGeneration.planState === "validated" && + turn.reviewItemIds.length > 0 + ) { + resolved = resolveReviewItems(turn.reviewItemIds) || resolved; + } + if (!resolved) { + return false; + } + this._updateSessionTurn(sessionId, turnId, { + status: resolution === "accept" ? "accepted" : "rejected", + suggestionIds: [], + reviewItemIds: [], + structuredPreview: null, + anchor: refreshedInlineSelectionTarget + ? resolveSessionAnchor(refreshedInlineSelectionTarget.selection) + : undefined, + selection: refreshedInlineSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + }); + if (refreshedInlineSelectionTarget) { + this._updateSession(sessionId, { + target: refreshedInlineSelectionTarget, + anchor: resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ), + contextualPrompt: session.contextualPrompt + ? { + ...session.contextualPrompt, + anchor: resolveContextualPromptAnchor( + refreshedInlineSelectionTarget, + ), + } + : undefined, + }); + } + if (options?.finalizeSession === false) { + if (undoHistoryBeforeSnapshot) { + this._undoHistoryMetadata?.setCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + { + before: undoHistoryBeforeSnapshot, + after: createInlineHistorySnapshot( + this._editor, + this._state.sessions, + this._state.activeSessionId ?? null, + this._documentVersion, + { kind: "document-coupled" }, + ), + }, + ); + } + return true; + } + const nextSession = + this._state.sessions.find((item) => item.id === sessionId) ?? + session; + this._updateSession(sessionId, { + status: "complete", + contextualPrompt: closeInlineSessionPrompt(nextSession), + }); + if (undoHistoryBeforeSnapshot) { + this._undoHistoryMetadata?.setCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + { + before: undoHistoryBeforeSnapshot, + after: createInlineHistorySnapshot( + this._editor, + this._state.sessions, + this._state.activeSessionId ?? null, + this._documentVersion, + { kind: "document-coupled" }, + ), + }, + ); + } + return true; + }, + +_createInlineTurnUndoBeforeSnapshot(this: any, + sessionId: string, + turnId: string, + ): AIInlineHistorySnapshot { + const session = + this._state.sessions.find((item) => item.id === sessionId) ?? null; + if (session?.surface === "inline-edit") { + const reviewSnapshot = + this._findInlineHistorySnapshotForResolvedTurn(session, "undo"); + if (reviewSnapshot) { + const restoredSessions = reviewSnapshot.sessions.map( + (snapshotSession) => { + if ( + snapshotSession.id !== sessionId || + snapshotSession.surface !== "inline-edit" || + !snapshotSession.contextualPrompt + ) { + return snapshotSession; + } + const snapshotTurn = + snapshotSession.turns.find( + (turn) => turn.id === turnId, + ) ?? null; + if (!snapshotTurn) { + return snapshotSession; + } + return { + ...snapshotSession, + contextualPrompt: { + ...snapshotSession.contextualPrompt, + composer: { + ...snapshotSession.contextualPrompt + .composer, + draftPrompt: + snapshotSession.contextualPrompt + .composer.draftPrompt || + snapshotTurn.prompt, + }, + }, + }; + }, + ); + return createInlineHistorySnapshot( + this._editor, + restoredSessions, + sessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + } + } + const historySessions = this._state.sessions.map((session) => { + if ( + session.id !== sessionId || + session.surface !== "inline-edit" || + !session.contextualPrompt + ) { + return session; + } + const targetTurn = + session.turns.find((turn) => turn.id === turnId) ?? null; + if (targetTurn?.status !== "review") { + return session; + } + return { + ...session, + contextualPrompt: { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: true, + isSubmitting: false, + }, + }, + }; + }); + const nextActiveSessionId = historySessions.some( + (session) => + session.id === sessionId && + session.surface === "inline-edit" && + session.contextualPrompt?.composer.isOpen, + ) + ? sessionId + : (this._state.activeSessionId ?? null); + return createInlineHistorySnapshot( + this._editor, + historySessions, + nextActiveSessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + }, + +_updateSession(this: any, + sessionId: string, + overrides: Partial, + ): void { + const nextSessions = this._state.sessions.map((session) => + session.id !== sessionId + ? session + : { + ...session, + ...overrides, + contextualPrompt: + (overrides.contextualPrompt ?? + session.contextualPrompt) + ? { + ...(session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + )), + ...(overrides.contextualPrompt ?? {}), + anchor: { + ...( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).anchor, + ...(overrides.contextualPrompt + ?.anchor ?? {}), + }, + composer: { + ...( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).composer, + ...(overrides.contextualPrompt + ?.composer ?? {}), + isSubmitting: + overrides.contextualPrompt + ?.composer?.isSubmitting ?? + (overrides.status === + "streaming" + ? true + : overrides.status + ? false + : ( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).composer + .isSubmitting), + }, + } + : undefined, + updatedAt: Date.now(), + metrics: { + ...session.metrics, + ...(overrides.metrics ?? {}), + }, + }, + ); + if (nextSessions === this._state.sessions) { + return; + } + this._setState({ + sessions: nextSessions, + activeSessionId: + this._state.activeSessionId === sessionId || + this._state.activeSessionId == null + ? sessionId + : this._state.activeSessionId, + }); + }, + +_recordSessionFastApplyMetrics(this: any, + sessionId: string, + fastApply: FastApplyDebugState | undefined, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return; + } + this._updateSession(sessionId, { + metrics: { + ...session.metrics, + fastApply: accumulateSessionFastApplyMetrics( + session.metrics.fastApply, + fastApply, + ), + }, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts new file mode 100644 index 0000000..1b0d630 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts @@ -0,0 +1,455 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart14 = { +_updateSessionTurn(this: any, + sessionId: string, + turnId: string, + overrides: Partial, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return; + } + const nextTurns = session.turns.map((turn) => + turn.id !== turnId + ? turn + : { + ...turn, + ...overrides, + }, + ); + if (areStructuredValuesEqual(session.turns, nextTurns)) { + return; + } + const pendingSuggestionIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), + ]; + const pendingReviewItemIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), + ]; + this._updateSession(sessionId, { + turns: nextTurns, + pendingSuggestionIds, + pendingReviewItemIds, + }); + }, + +_syncSessionsFromDocument(this: any): boolean { + if (this._state.sessions.length === 0) { + return false; + } + const nextSessions = this._state.sessions.map((session) => { + const nextTurns = session.turns.map((turn) => { + const suggestionIds = turn.suggestionIds.filter( + (sessionSuggestionId) => + this._suggestions.some( + (suggestion) => + suggestion.id === sessionSuggestionId, + ), + ); + const activeGenerationMatchesTurn = + this._state.activeGeneration?.sessionId === session.id && + this._state.activeGeneration.turnId === turn.id; + const activeGenerationForTurn = activeGenerationMatchesTurn + ? this._state.activeGeneration + : null; + const reviewItemIds = activeGenerationForTurn + ? (activeGenerationForTurn.reviewItems ?? []) + .map((item) => item.id) + .filter((id) => turn.reviewItemIds.includes(id)) + : []; + const structuredPreview = activeGenerationForTurn + ? (activeGenerationForTurn.structuredPreview ?? + turn.structuredPreview ?? + null) + : turn.reviewItemIds.length > 0 + ? (turn.structuredPreview ?? null) + : null; + return { + ...turn, + suggestionIds, + reviewItemIds, + structuredPreview, + }; + }); + const pendingSuggestionIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), + ]; + const pendingReviewItemIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), + ]; + const nextStatus = + pendingSuggestionIds.length === 0 && + pendingReviewItemIds.length === 0 && + session.status === "streaming" + ? "complete" + : session.status; + return { + ...session, + status: nextStatus, + turns: nextTurns, + pendingSuggestionIds, + pendingReviewItemIds, + }; + }); + if (areSessionsEqual(this._state.sessions, nextSessions)) { + return false; + } + this._setState({ + sessions: nextSessions, + }); + return true; + }, + +_setStreamEvents(this: any, nextEvents: readonly AIStreamEvent[]): void { + this._streamEvents = nextEvents; + this._emitStreamEvents(); + }, + +_appendStreamEvent(this: any, event: AIStreamEvent): void { + const lastEvent = this._streamEvents[this._streamEvents.length - 1]; + if ( + lastEvent?.type === "status" && + event.type === "status" && + lastEvent.generationId === event.generationId && + lastEvent.status === event.status + ) { + return; + } + const nextEvents = + this._streamEvents.length >= MAX_STREAM_EVENTS + ? [...this._streamEvents.slice(-(MAX_STREAM_EVENTS - 1)), event] + : [...this._streamEvents, event]; + this._setStreamEvents(nextEvents); + }, + +_emit(this: any): void { + for (const listener of this._listeners) { + listener(); + } + for (const listener of this._sessionListeners) { + listener(); + } + }, + +_emitStreamEvents(this: any): void { + for (const listener of this._streamEventListeners) { + listener(); + } + }, + +_syncSuggestionsFromDocument(this: any): boolean { + const nextSuggestions = readAllSuggestions(this._editor); + if (areSuggestionsEqual(this._suggestions, nextSuggestions)) { + return false; + } + this._suggestions = nextSuggestions; + return true; + }, + +_recordInlineHistorySnapshot(this: any, + previousState: AIControllerState, + nextState: AIControllerState, + ): void { + if (!didInlineHistoryCheckpointChange(previousState, nextState)) { + return; + } + if ( + previousState.sessions === nextState.sessions && + previousState.activeSessionId === nextState.activeSessionId + ) { + return; + } + const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + if (nextHistory.length === 0) { + const baselineSnapshot = createInlineHistorySnapshot( + this._editor, + previousState.sessions, + previousState.activeSessionId ?? null, + this._documentVersion, + ); + nextHistory.push(baselineSnapshot); + } + const previousSnapshot = + nextHistory[nextHistory.length - 1] ?? currentSnapshot ?? null; + const snapshot = createInlineHistorySnapshot( + this._editor, + nextState.sessions, + nextState.activeSessionId ?? null, + this._documentVersion, + { + kind: + previousSnapshot?.documentVersion === this._documentVersion + ? "ui-local" + : "document-coupled", + }, + ); + if ( + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) + ) { + return; + } + const currentUndoMetadata = + this._undoHistoryMetadata?.getCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + ) ?? null; + const shouldPersistUndoSnapshot = + previousSnapshot != null && + (snapshot.kind === "document-coupled" || + currentUndoMetadata?.after?.documentVersion === + this._documentVersion); + if (shouldPersistUndoSnapshot && previousSnapshot) { + this._undoHistoryMetadata?.setCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + { + before: currentUndoMetadata?.before ?? previousSnapshot, + after: snapshot, + }, + ); + } + nextHistory.push(snapshot); + this._inlineHistory = nextHistory; + this._inlineHistoryIndex = nextHistory.length - 1; + }, + +_recordInlinePromptSubmissionCheckpoint(this: any, + sessionId: string, + prompt: string, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if ( + !session || + session.surface !== "inline-edit" || + !session.contextualPrompt + ) { + return; + } + const checkpointState: AIControllerState = { + ...this._state, + activeSessionId: sessionId, + sessions: this._state.sessions.map((item) => + item.id !== sessionId + ? item + : { + ...item, + contextualPrompt: { + ...item.contextualPrompt!, + composer: { + ...item.contextualPrompt!.composer, + draftPrompt: prompt, + isOpen: true, + isSubmitting: false, + }, + }, + }, + ), + }; + const snapshot = createInlineHistorySnapshot( + this._editor, + checkpointState.sessions, + checkpointState.activeSessionId ?? null, + this._documentVersion, + { kind: "ui-local" }, + ); + const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; + if ( + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) + ) { + return; + } + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + nextHistory.push(snapshot); + this._inlineHistory = nextHistory; + this._inlineHistoryIndex = nextHistory.length - 1; + }, + +_resolveInlineHistoryTargetIndex(this: any, + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): number { + const step = direction === "undo" ? -1 : 1; + if (!options?.shortcutOnly) { + return this._inlineHistoryIndex + step; + } + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; + const scopedSessionId = this._resolveShortcutInlineHistorySessionId( + currentSnapshot, + direction, + ); + const waypoints = + this._buildInlineShortcutHistoryWaypoints(scopedSessionId); + if (waypoints.length === 0) { + return -1; + } + const currentWaypointIndex = + this._resolveCurrentInlineShortcutWaypointIndex( + waypoints, + scopedSessionId, + ); + if (currentWaypointIndex < 0) { + return -1; + } + const targetWaypoint = waypoints[currentWaypointIndex + step]; + return targetWaypoint?.representativeIndex ?? -1; + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts new file mode 100644 index 0000000..7a08521 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts @@ -0,0 +1,464 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart15 = { +_resolveShortcutInlineHistorySessionId(this: any, + currentSnapshot: AIInlineHistorySnapshot | null, + direction: AIInlineHistoryDirection, + ): string | null { + const activeSession = this.getActiveSession(); + if (activeSession?.surface === "inline-edit") { + return activeSession.id; + } + const selection = this._editor.selection; + if ( + currentSnapshot && + selection?.type === "text" && + !selection.isCollapsed + ) { + const matchingSession = [...currentSnapshot.sessions] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + sessionSelectionMatches(session, selection), + ); + if (matchingSession) { + return matchingSession.id; + } + } + if ( + currentSnapshot?.activeSessionId && + currentSnapshot.sessions.some( + (session) => + session.id === currentSnapshot.activeSessionId && + session.surface === "inline-edit", + ) + ) { + return currentSnapshot.activeSessionId; + } + const currentInlineSession = + [...(currentSnapshot?.sessions ?? [])] + .reverse() + .find((session) => session.surface === "inline-edit") ?? null; + if (currentInlineSession) { + return currentInlineSession.id; + } + const step = direction === "undo" ? -1 : 1; + let searchIndex = this._inlineHistoryIndex + step; + while (searchIndex >= 0 && searchIndex < this._inlineHistory.length) { + const searchSnapshot = this._inlineHistory[searchIndex]; + const matchingSelectionSession = + selection?.type === "text" && !selection.isCollapsed + ? ([...(searchSnapshot?.sessions ?? [])] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + sessionSelectionMatches(session, selection), + ) ?? null) + : null; + if (matchingSelectionSession) { + return matchingSelectionSession.id; + } + const searchInlineSession = + [...(searchSnapshot?.sessions ?? [])] + .reverse() + .find((session) => session.surface === "inline-edit") ?? + null; + if (searchInlineSession) { + return searchInlineSession.id; + } + searchIndex += step; + } + return null; + }, + +_buildInlineShortcutHistoryWaypoints(this: any, + sessionId: string | null, + ): AIInlineShortcutHistoryWaypoint[] { + const waypoints: AIInlineShortcutHistoryWaypoint[] = []; + for (let index = 0; index < this._inlineHistory.length; index += 1) { + const snapshot = this._inlineHistory[index]; + if (!snapshot || snapshot.kind === "ui-local") { + continue; + } + const state = resolveInlineShortcutHistoryState( + snapshot, + sessionId, + ); + if (!state) { + continue; + } + const previousWaypoint = waypoints[waypoints.length - 1] ?? null; + if ( + previousWaypoint && + areInlineShortcutHistoryStatesEqual( + previousWaypoint.state, + state, + ) + ) { + previousWaypoint.endIndex = index; + if ( + shouldReplaceInlineShortcutWaypointRepresentative( + previousWaypoint.state, + this._inlineHistory[ + previousWaypoint.representativeIndex + ] ?? null, + snapshot, + ) + ) { + previousWaypoint.representativeIndex = index; + } + continue; + } + waypoints.push({ + startIndex: index, + endIndex: index, + representativeIndex: index, + state, + }); + } + return waypoints; + }, + +_resolveCurrentInlineShortcutWaypointIndex(this: any, + waypoints: readonly AIInlineShortcutHistoryWaypoint[], + sessionId: string | null, + ): number { + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; + const currentState = currentSnapshot + ? resolveInlineShortcutHistoryState(currentSnapshot, sessionId) + : null; + if (currentState) { + const currentIndex = waypoints.findIndex( + (waypoint) => + this._inlineHistoryIndex >= waypoint.startIndex && + this._inlineHistoryIndex <= waypoint.endIndex && + areInlineShortcutHistoryStatesEqual( + waypoint.state, + currentState, + ), + ); + if (currentIndex >= 0) { + return currentIndex; + } + const matchingIndex = waypoints.findIndex((waypoint) => + areInlineShortcutHistoryStatesEqual( + waypoint.state, + currentState, + ), + ); + if (matchingIndex >= 0) { + return matchingIndex; + } + } + for (let index = waypoints.length - 1; index >= 0; index -= 1) { + if ( + waypoints[index]!.representativeIndex <= + this._inlineHistoryIndex + ) { + return index; + } + } + return waypoints.length > 0 ? 0 : -1; + }, + +_canHandleInlineHistoryShortcut(this: any, + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): boolean { + const targetIndex = this._resolveInlineHistoryTargetIndex( + direction, + options, + ); + const targetSnapshot = this._inlineHistory[targetIndex]; + if (!targetSnapshot) { + return false; + } + if (targetSnapshot.kind !== "ui-local") { + return true; + } + return direction === "undo" + ? !this._editor.undoManager.canUndo() + : !this._editor.undoManager.canRedo(); + }, + +_navigateInlineHistory(this: any, + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): boolean { + const targetIndex = this._resolveInlineHistoryTargetIndex( + direction, + options, + ); + const targetSnapshot = this._inlineHistory[targetIndex]; + if (!targetSnapshot) { + return false; + } + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; + const shortcutSessionId = options?.shortcutOnly + ? this._resolveShortcutInlineHistorySessionId( + currentSnapshot, + direction, + ) + : null; + if (targetSnapshot.kind === "ui-local") { + this._applyInlineHistorySnapshot(targetSnapshot, { + historyTraversal: true, + }); + this._inlineHistoryIndex = targetIndex; + return true; + } + if ( + currentSnapshot && + currentSnapshot.documentVersion !== targetSnapshot.documentVersion + ) { + const targetState = resolveInlineShortcutHistoryState( + targetSnapshot, + shortcutSessionId ?? + targetSnapshot.sessionId ?? + targetSnapshot.activeSessionId ?? + null, + ); + this._pendingInlineHistoryRestore = { + direction, + targetSnapshotId: targetSnapshot.id, + targetDocumentVersion: targetSnapshot.documentVersion, + shortcutOnly: options?.shortcutOnly === true, + sessionId: shortcutSessionId, + targetState, + }; + const restored = + direction === "undo" + ? this._editor.undoManager.undo() + : this._editor.undoManager.redo(); + if (!restored) { + this._pendingInlineHistoryRestore = null; + } + return restored; + } + const resolvedTargetSnapshot = options?.shortcutOnly + ? this._resolveShortcutInlineHistoryTraversalSnapshot( + targetSnapshot, + shortcutSessionId, + ) + : targetSnapshot; + this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { + historyTraversal: true, + }); + this._inlineHistoryIndex = targetIndex; + return true; + }, + +_applyInlineHistorySnapshot(this: any, + snapshot: AIInlineHistorySnapshot, + options?: { historyTraversal?: boolean }, + ): void { + this._isRestoringInlineHistory = true; + try { + const restoredSessions = cloneInlineHistorySessions( + this._editor, + snapshot.sessions, + ).map((session) => { + if ( + !options?.historyTraversal || + !session.contextualPrompt?.composer.isOpen + ) { + return session; + } + return { + ...session, + contextualPrompt: { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + openReason: "history" as const, + }, + }, + }; + }); + this._setState({ + status: "idle", + activeGeneration: null, + sessions: restoredSessions, + activeSessionId: snapshot.activeSessionId, + }); + } finally { + this._isRestoringInlineHistory = false; + } + }, + +_restoreInlineHistorySnapshotFromUndo(this: any, + snapshot: AIInlineHistorySnapshot, + ): void { + const targetIndex = this._inlineHistory.findIndex( + (item) => item.id === snapshot.id, + ); + if (targetIndex >= 0) { + this._inlineHistoryIndex = targetIndex; + this._applyInlineHistorySnapshot( + this._inlineHistory[targetIndex]!, + { + historyTraversal: true, + }, + ); + return; + } + this._applyInlineHistorySnapshot(snapshot, { historyTraversal: true }); + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + nextHistory.push(snapshot); + this._inlineHistory = nextHistory; + this._inlineHistoryIndex = nextHistory.length - 1; + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts new file mode 100644 index 0000000..ec9ae25 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts @@ -0,0 +1,451 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart16 = { +_findInlineHistorySnapshotForResolvedTurn(this: any, + session: AISession, + direction: AIInlineHistoryDirection, + ): AIInlineHistorySnapshot | null { + const latestTurnId = + session.turns[session.turns.length - 1]?.id ?? null; + if (!latestTurnId) { + return null; + } + for ( + let index = this._inlineHistory.length - 1; + index >= 0; + index -= 1 + ) { + const snapshot = this._inlineHistory[index]; + const snapshotSession = + snapshot?.sessions.find( + (item) => + item.id === session.id && + item.surface === "inline-edit", + ) ?? null; + if (!snapshotSession) { + continue; + } + const snapshotTurn = + snapshotSession.turns.find( + (turn) => turn.id === latestTurnId, + ) ?? null; + if (!snapshotTurn) { + continue; + } + if ( + direction === "undo" && + snapshotSession.contextualPrompt?.composer.isOpen && + snapshotTurn.status === "review" + ) { + return snapshot; + } + if ( + direction === "redo" && + !snapshotSession.contextualPrompt?.composer.isOpen && + (snapshotTurn.status === "accepted" || + snapshotTurn.status === "rejected") + ) { + return snapshot; + } + } + return null; + }, + +_resolveInlineHistoryTraversalSnapshot(this: any, + targetSnapshot: AIInlineHistorySnapshot, + ): AIInlineHistorySnapshot { + if (targetSnapshot.kind === "ui-local") { + return targetSnapshot; + } + const scopedSessionId = + targetSnapshot.sessionId ?? targetSnapshot.activeSessionId; + const targetState = resolveInlineShortcutHistoryState( + targetSnapshot, + scopedSessionId, + ); + if (!targetState) { + return targetSnapshot; + } + let resolvedSnapshot = targetSnapshot; + for (const snapshot of this._inlineHistory) { + if (snapshot.documentVersion !== targetSnapshot.documentVersion) { + continue; + } + const snapshotState = resolveInlineShortcutHistoryState( + snapshot, + scopedSessionId, + ); + if ( + !snapshotState || + !areInlineShortcutHistoryStatesEqual(snapshotState, targetState) + ) { + continue; + } + if ( + shouldReplaceInlineShortcutWaypointRepresentative( + targetState, + resolvedSnapshot, + snapshot, + ) + ) { + resolvedSnapshot = snapshot; + } + } + return resolvedSnapshot; + }, + +_resolveShortcutInlineHistoryTraversalSnapshot(this: any, + targetSnapshot: AIInlineHistorySnapshot, + fallbackSessionId?: string | null, + ): AIInlineHistorySnapshot { + const scopedSessionId = + targetSnapshot.sessionId ?? + targetSnapshot.activeSessionId ?? + fallbackSessionId ?? + null; + const targetState = resolveInlineShortcutHistoryState( + targetSnapshot, + scopedSessionId, + ); + if (targetState?.phase !== "none" || !scopedSessionId) { + return this._resolveInlineHistoryTraversalSnapshot(targetSnapshot); + } + return createInlineHistorySnapshot( + this._editor, + targetSnapshot.sessions.filter( + (session) => session.id !== scopedSessionId, + ), + targetSnapshot.activeSessionId === scopedSessionId + ? null + : targetSnapshot.activeSessionId, + targetSnapshot.documentVersion, + { kind: targetSnapshot.kind }, + ); + }, + +_scheduleQueuedInlineHistoryShortcutFlush(this: any): void { + if ( + this._queuedInlineHistoryShortcutFlushScheduled || + this._queuedInlineHistoryShortcutDirections.length === 0 + ) { + return; + } + this._queuedInlineHistoryShortcutFlushScheduled = true; + queueMicrotask(() => { + this._queuedInlineHistoryShortcutFlushScheduled = false; + if (this._pendingInlineHistoryRestore) { + this._scheduleQueuedInlineHistoryShortcutFlush(); + return; + } + const nextDirection = + this._queuedInlineHistoryShortcutDirections.shift() ?? null; + if (!nextDirection) { + return; + } + this._navigateInlineHistory(nextDirection, { shortcutOnly: true }); + if (this._queuedInlineHistoryShortcutDirections.length > 0) { + this._scheduleQueuedInlineHistoryShortcutFlush(); + } + }); + }, + +_resolvePendingInlineHistoryRestoreTargetIndex(this: any, + request: AIInlineHistoryRestoreRequest, + ): number { + const exactTargetIndex = this._inlineHistory.findIndex( + (snapshot) => snapshot.id === request.targetSnapshotId, + ); + if (exactTargetIndex >= 0) { + return exactTargetIndex; + } + if (!request.targetState) { + return -1; + } + let resolvedTargetIndex = -1; + const scopedSessionId = + request.sessionId ?? request.targetState.sessionId; + for (let index = 0; index < this._inlineHistory.length; index += 1) { + const snapshot = this._inlineHistory[index]; + if (!snapshot || snapshot.kind === "ui-local") { + continue; + } + if (snapshot.documentVersion !== request.targetDocumentVersion) { + continue; + } + const snapshotState = resolveInlineShortcutHistoryState( + snapshot, + scopedSessionId ?? null, + ); + if ( + !snapshotState || + !areInlineShortcutHistoryStatesEqual( + snapshotState, + request.targetState, + ) + ) { + continue; + } + if ( + resolvedTargetIndex < 0 || + shouldReplaceInlineShortcutWaypointRepresentative( + request.targetState, + this._inlineHistory[resolvedTargetIndex] ?? null, + snapshot, + ) + ) { + resolvedTargetIndex = index; + } + } + return resolvedTargetIndex; + }, + +_handleHistoryApplied(this: any, event: HistoryAppliedEvent): void { + if ( + this._pendingInlineHistoryRestore && + this._pendingInlineHistoryRestore.direction === event.kind + ) { + const targetIndex = + this._resolvePendingInlineHistoryRestoreTargetIndex( + this._pendingInlineHistoryRestore, + ); + if (targetIndex >= 0) { + this._inlineHistoryIndex = targetIndex; + const targetSnapshot = this._inlineHistory[targetIndex]!; + const resolvedTargetSnapshot = this._pendingInlineHistoryRestore + .shortcutOnly + ? this._resolveShortcutInlineHistoryTraversalSnapshot( + targetSnapshot, + this._pendingInlineHistoryRestore.sessionId ?? null, + ) + : this._resolveInlineHistoryTraversalSnapshot( + targetSnapshot, + ); + this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { + historyTraversal: true, + }); + } + this._pendingInlineHistoryRestore = null; + this._scheduleQueuedInlineHistoryShortcutFlush(); + return; + } + if (this._handledUndoHistoryRequestId === event.requestId) { + this._handledUndoHistoryRequestId = null; + return; + } + const selection = event.selection; + if (selection?.type !== "text" || selection.isCollapsed) { + return; + } + const matchingSession = [...this._state.sessions] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + session.status !== "cancelled" && + sessionSelectionMatches(session, selection), + ); + if (!matchingSession) { + return; + } + this._setInlineSessionComposerOpen(matchingSession.id, true, { + openReason: "history", + }); + }, + +_setInlineSessionComposerOpen(this: any, + sessionId: string, + isOpen: boolean, + options?: { openReason?: "user" | "history" }, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if ( + !session || + session.surface !== "inline-edit" || + !session.contextualPrompt + ) { + return; + } + const nextActiveSessionId = isOpen + ? sessionId + : this._state.activeSessionId === sessionId + ? null + : this._state.activeSessionId; + if ( + session.contextualPrompt.composer.isOpen === isOpen && + nextActiveSessionId === this._state.activeSessionId + ) { + return; + } + const nextSessions = this._state.sessions.map((item) => + item.id !== sessionId + ? item + : { + ...item, + contextualPrompt: { + ...item.contextualPrompt!, + composer: { + ...item.contextualPrompt!.composer, + isOpen, + openReason: isOpen + ? (options?.openReason ?? "user") + : item.contextualPrompt!.composer + .openReason, + }, + }, + updatedAt: Date.now(), + }, + ); + this._setState({ + sessions: nextSessions, + activeSessionId: nextActiveSessionId, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts new file mode 100644 index 0000000..36f2497 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts @@ -0,0 +1,416 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart2 = { +canReuseSessionPrompt(this: any, + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return false; + } + if (session.surface !== "bottom-chat" || !session.operation) { + return true; + } + const nextOperation = + options?.operation ?? + resolveRequestedOperationForSession( + this._editor, + session, + prompt, + options, + this._documentVersion, + ); + return canReuseBottomChatSessionOperation( + session.operation, + nextOperation, + ); + }, + +resolveSession(this: any, + sessionId: string, + resolution: AISessionResolution, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return false; + } + let resolved = false; + for (const turn of session.turns) { + resolved = + this._resolveSessionTurn(sessionId, turn.id, resolution, { + finalizeSession: false, + }) || resolved; + } + if (resolved) { + const nextSession = + this._state.sessions.find((item) => item.id === sessionId) ?? + session; + this._updateSession(sessionId, { + status: "complete", + pendingSuggestionIds: [], + pendingReviewItemIds: [], + contextualPrompt: closeInlineSessionPrompt(nextSession), + }); + } + return resolved; + }, + +acceptSession(this: any, sessionId: string): boolean { + return this.resolveSession(sessionId, "accept"); + }, + +rejectSession(this: any, sessionId: string): boolean { + return this.resolveSession(sessionId, "reject"); + }, + +cancelSession(this: any, sessionId: string): void { + if (this._state.activeGeneration?.sessionId === sessionId) { + this.cancelActiveGeneration(); + } + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + this._updateSession(sessionId, { + status: "cancelled", + contextualPrompt: session?.contextualPrompt + ? { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: false, + isSubmitting: false, + }, + } + : undefined, + }); + }, + +suspendInlineSession(this: any, sessionId: string): void { + this._setInlineSessionComposerOpen(sessionId, false); + }, + +resumeInlineSession(this: any, sessionId: string): void { + this._setInlineSessionComposerOpen(sessionId, true, { + openReason: "user", + }); + }, + +canUndoInlineHistory(this: any): boolean { + return this._inlineHistoryIndex > 0; + }, + +canRedoInlineHistory(this: any): boolean { + return ( + this._inlineHistoryIndex >= 0 && + this._inlineHistoryIndex < this._inlineHistory.length - 1 + ); + }, + +undoInlineHistory(this: any): boolean { + return this._navigateInlineHistory("undo"); + }, + +redoInlineHistory(this: any): boolean { + return this._navigateInlineHistory("redo"); + }, + +canHandleInlineHistoryShortcut(this: any, + direction: AIInlineHistoryDirection, + ): boolean { + if (this._pendingInlineHistoryRestore) { + return true; + } + return this._canHandleInlineHistoryShortcut(direction, { + shortcutOnly: true, + }); + }, + +handleInlineHistoryShortcut(this: any, direction: AIInlineHistoryDirection): boolean { + if (this._pendingInlineHistoryRestore) { + this._queuedInlineHistoryShortcutDirections.push(direction); + return true; + } + return this._navigateInlineHistory(direction, { shortcutOnly: true }); + }, + +async runCommand(this: any, + commandId: string, + options?: AICommandExecutionOptions, + ): Promise { + const ctx = this.getCommandContext(); + const command = this._registry.resolve(commandId); + if (!command) { + throw new Error(`Unknown AI command "${commandId}"`); + } + if (command.guard && !command.guard(ctx)) { + throw new Error( + `AI command "${command.label}" is not available in this context`, + ); + } + + const prompt = this._registry.resolvePrompt(command, ctx); + this._lastPrompt = prompt; + this._lastCommandId = command.id; + + if ( + command.target === "selection" && + ctx.selection?.type === "text" && + !ctx.selection.isCollapsed + ) { + return this._runSelectionGeneration( + prompt, + ctx.selection, + command.id, + options?.maxSteps, + ); + } + + const targetBlockId = + options?.blockId ?? + ctx.blockId ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!targetBlockId) { + throw new Error("Cannot run AI command without a target block"); + } + return this._runBlockGeneration( + prompt, + targetBlockId, + command.id, + options?.maxSteps, + ); + }, + +async runPrompt(this: any, + prompt: string, + options?: AICommandExecutionOptions, + ): Promise { + this._lastPrompt = prompt; + this._lastCommandId = null; + const promptTarget = resolvePromptTarget( + this._editor.selection, + options?.target, + ); + if (promptTarget === "selection") { + const selection = this._editor.selection; + if (selection?.type !== "text" || selection.isCollapsed) { + throw new Error( + "Cannot run a selection prompt without selected text", + ); + } + return this._runSelectionGeneration( + prompt, + selection, + undefined, + options?.maxSteps, + ); + } + if (promptTarget === "document") { + return this._runDocumentGeneration( + prompt, + options?.blockId, + undefined, + options?.maxSteps, + ); + } + const blockId = + options?.blockId ?? + resolveActiveBlockId(this._editor.selection) ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!blockId) { + throw new Error("Cannot run AI prompt without a target block"); + } + return this._runBlockGeneration( + prompt, + blockId, + undefined, + options?.maxSteps, + ); + }, + +async retryActiveGeneration(this: any): Promise { + const prompt = this._lastPrompt; + if (!prompt) return null; + this.rejectActiveGeneration(); + const active = this._state.activeGeneration; + const blockId = + active?.blockId ?? + resolveActiveBlockId(this._editor.selection) ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!blockId) return null; + if (active?.sessionId) { + const activeSession = this._state.sessions.find( + (session) => session.id === active.sessionId, + ); + const retryTarget = + activeSession?.target.kind === "document" + ? "document" + : (active?.target ?? "block"); + return this.runSessionPrompt(active.sessionId, prompt, { + blockId: retryTarget === "document" ? null : blockId, + target: retryTarget, + }); + } + if (this._lastCommandId) { + return this.runCommand(this._lastCommandId, { blockId }); + } + return this.runPrompt(prompt, { + blockId, + target: active?.target ?? "block", + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts new file mode 100644 index 0000000..d8b09be --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts @@ -0,0 +1,477 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart3 = { +acceptActiveGeneration(this: any): boolean { + const generation = this._state.activeGeneration; + if (!generation) { + return false; + } + + if (generation.suggestionIds && generation.suggestionIds.length > 0) { + const existingSession = + generation.sessionId != null + ? (this._state.sessions.find( + (session) => session.id === generation.sessionId, + ) ?? null) + : null; + const existingTurn = + generation.turnId != null + ? (existingSession?.turns.find( + (turn) => turn.id === generation.turnId, + ) ?? null) + : null; + const refreshSuggestionIds = existingTurn?.suggestionIds.length + ? existingTurn.suggestionIds + : generation.suggestionIds; + const refreshedInlineSelectionTarget = + generation.surface === "inline-edit" + ? (resolveAcceptedInlineSelectionTarget( + this._editor, + existingTurn?.operation ?? + generation.operation ?? + undefined, + refreshSuggestionIds, + ) ?? resolveLiveInlineSelectionTarget(this._editor)) + : null; + const accepted = acceptSuggestions( + this._editor, + generation.suggestionIds, + ); + if (accepted) { + this._resolveActiveGeneration({ + suggestionIds: [], + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "accepted", + suggestionIds: [], + structuredPreview: null, + anchor: refreshedInlineSelectionTarget + ? resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + selection: refreshedInlineSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingSuggestionIds: [], + ...(refreshedInlineSelectionTarget + ? { + target: refreshedInlineSelectionTarget, + anchor: resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ), + contextualPrompt: + existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: resolveContextualPromptAnchor( + refreshedInlineSelectionTarget, + ), + } + : undefined, + } + : {}), + }); + } + } + return accepted; + } + + if (generation.planState !== "validated" || !generation.plan) { + return false; + } + + const execution = buildDocumentMutationPlanExecution( + this._editor, + generation.plan, + ); + if (execution.issues.length > 0) { + this._resolveActiveGeneration({ + planState: "rejected", + }); + return false; + } + + this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); + this._resolveActiveGeneration({ + planState: "none", + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "accepted", + reviewItemIds: [], + structuredPreview: null, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingReviewItemIds: [], + }); + } + return true; + }, + +rejectActiveGeneration(this: any): boolean { + const generation = this._state.activeGeneration; + if (!generation) return false; + + if (generation.suggestionIds && generation.suggestionIds.length > 0) { + const rejected = rejectSuggestions( + this._editor, + generation.suggestionIds, + ); + if (rejected) { + this._resolveActiveGeneration({ + suggestionIds: [], + planState: "rejected", + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "rejected", + suggestionIds: [], + structuredPreview: null, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingSuggestionIds: [], + }); + } + } + return rejected; + } + + if (generation.planState === "validated" && generation.plan) { + this._resolveActiveGeneration({ + status: "cancelled", + planState: "rejected", + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "rejected", + reviewItemIds: [], + structuredPreview: null, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingReviewItemIds: [], + }); + } + return true; + } + + if (generation.status === "streaming") { + this.cancelActiveGeneration(); + } + + return this._editor.undoManager.undo(); + }, + +acceptReviewItem(this: any, id: string): boolean { + return this.acceptReviewItems([id]); + }, + +rejectReviewItem(this: any, id: string): boolean { + return this.rejectReviewItems([id]); + }, + +acceptReviewItems(this: any, ids: readonly string[]): boolean { + return this._applyReviewItems(ids, "accept"); + }, + +rejectReviewItems(this: any, ids: readonly string[]): boolean { + return this._applyReviewItems(ids, "reject"); + }, + +_applyReviewItems(this: any, + ids: readonly string[], + action: "accept" | "reject", + ): boolean { + const generation = this._state.activeGeneration; + if ( + !generation || + generation.planState !== "validated" || + !generation.plan || + !generation.reviewItems + ) { + return false; + } + + const reviewItems = resolveOrderedReviewItems( + generation.reviewItems, + ids, + ); + if (reviewItems.length === 0) { + return false; + } + + if (action === "accept") { + const selectedPlans = reviewItems.map((reviewItem) => + selectStructuralReviewItemPlan(generation.plan!, reviewItem), + ); + if (selectedPlans.some((plan) => !plan)) { + return false; + } + const resolvedSelectedPlans = selectedPlans.filter( + (plan): plan is NonNullable<(typeof selectedPlans)[number]> => + plan != null, + ); + + const selectedPlan = + resolvedSelectedPlans.length === 1 + ? resolvedSelectedPlans[0]! + : { + kind: "review_bundle" as const, + label: "Bulk review selection", + reason: "Apply selected review items together.", + plans: resolvedSelectedPlans, + }; + const execution = buildDocumentMutationPlanExecution( + this._editor, + selectedPlan, + ); + if (execution.issues.length > 0) { + return false; + } + + this._editor.apply(execution.ops, { + origin: "ai", + undoGroup: true, + }); + } + + let nextPlan: GenerationState["plan"] = generation.plan; + for (const reviewItem of sortReviewItemsForRemoval(reviewItems)) { + if (!nextPlan) { + break; + } + nextPlan = removeStructuralReviewItemPlan(nextPlan, reviewItem); + } + const nextReviewItems = nextPlan + ? buildStructuralReviewItems(this._editor, nextPlan) + : []; + this._resolveActiveGeneration({ + status: + nextPlan || action === "accept" + ? generation.status + : "cancelled", + planState: nextPlan + ? "validated" + : action === "accept" + ? "none" + : "rejected", + plan: nextPlan, + reviewItems: nextReviewItems, + structuredPreview: nextPlan + ? buildGenerationStructuredPreviewState(this._editor, { + planState: "validated", + plan: nextPlan, + }) + : null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: nextPlan + ? "review" + : action === "accept" + ? "accepted" + : "rejected", + reviewItemIds: nextReviewItems.map((item) => item.id), + }, + ); + } + this._updateSession(generation.sessionId, { + status: + nextPlan || action === "accept" + ? generation.status === "streaming" + ? "streaming" + : "complete" + : "complete", + pendingReviewItemIds: nextReviewItems.map((item) => item.id), + }); + } + return true; + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts new file mode 100644 index 0000000..7043321 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts @@ -0,0 +1,409 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart4 = { +cancelActiveGeneration(this: any): void { + this._abortController?.abort(); + this._abortController = null; + if (this._state.activeGeneration) { + this._setState({ + status: "idle", + activeGeneration: { + ...this._state.activeGeneration, + status: "cancelled", + structuredPreview: null, + }, + }); + if (this._state.activeGeneration.sessionId) { + if (this._state.activeGeneration.turnId) { + this._updateSessionTurn( + this._state.activeGeneration.sessionId, + this._state.activeGeneration.turnId, + { status: "cancelled" }, + ); + } + this._updateSession(this._state.activeGeneration.sessionId, { + status: "cancelled", + }); + } + } + this._inlineCompletion.dismissSuggestion(); + }, + +openCommandMenu(this: any): void { + this._setState({ commandMenuOpen: true }); + }, + +closeCommandMenu(this: any): void { + this._setState({ commandMenuOpen: false }); + }, + +setSuggestMode(this: any, enabled: boolean): void { + this._setState({ suggestMode: enabled }); + }, + +showEphemeralSuggestion(this: any, + suggestion: Parameters< + AIInlineCompletionController["showSuggestion"] + >[0], + ): void { + this._inlineCompletion.showSuggestion(suggestion); + }, + +dismissEphemeralSuggestion(this: any): void { + this._inlineCompletion.dismissSuggestion(); + }, + +acceptEphemeralSuggestion(this: any): void { + this._inlineCompletion.acceptSuggestion(); + }, + +getSuggestions(this: any) { + return this._suggestions; + }, + +handleDocumentChange(this: any, + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void { + if (events.length > 0) { + this._documentVersion += 1; + } + const previousState = this._state; + const suggestionsChanged = this._syncSuggestionsFromDocument(); + const sessionsChanged = this._syncSessionsFromDocument(); + this.handleExternalCommit(events); + if (this._state === previousState) { + this._editor.requestDecorationUpdate(); + if (suggestionsChanged || sessionsChanged) { + this._emit(); + } + } + }, + +_syncSuggestionResolutionState(this: any): void { + const suggestionsChanged = this._syncSuggestionsFromDocument(); + const sessionsChanged = this._syncSessionsFromDocument(); + if (!suggestionsChanged && !sessionsChanged) { + return; + } + this._editor.requestDecorationUpdate(); + this._emit(); + }, + +acceptSuggestion(this: any, id: string): boolean { + const accepted = acceptSuggestion(this._editor, id); + if (accepted) { + this._syncSuggestionResolutionState(); + } + return accepted; + }, + +rejectSuggestion(this: any, id: string): boolean { + const rejected = rejectSuggestion(this._editor, id); + if (rejected) { + this._syncSuggestionResolutionState(); + } + return rejected; + }, + +_rejectPreviewSuggestions(this: any, suggestionIds: readonly string[]): void { + if (suggestionIds.length === 0) { + return; + } + const rejected = rejectSuggestions(this._editor, suggestionIds, { + origin: AI_SESSION_SUGGESTION_ORIGIN, + undoGroupId: this._state.activeGeneration?.undoGroupId, + }); + if (rejected) { + this._syncSuggestionResolutionState(); + } + }, + +acceptAllSuggestions(this: any): void { + acceptAllSuggestions(this._editor); + this._syncSuggestionResolutionState(); + }, + +rejectAllSuggestions(this: any): void { + rejectAllSuggestions(this._editor); + this._syncSuggestionResolutionState(); + }, + +buildDecorations(this: any): Decoration[] { + const decorations = [ + ...buildTrackChangesDecorations(this._editor), + ...buildAffectedRangeDecorations( + this._editor, + this._state.sessions, + this._state.activeSessionId, + ), + ...buildGenerationZoneDecorations(this._state.activeGeneration), + ]; + return decorations; + }, + +handleExternalCommit(this: any, + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void { + const active = this._state.activeGeneration; + if (!active || active.status !== "streaming") return; + if ( + active.route === "tool-loop" || + active.route === "context-first" || + active.route === "review" + ) { + return; + } + const touched = events.some((event) => { + const originType = getOpOriginType(event.origin); + return ( + originType !== "ai" && + originType !== AI_SESSION_SUGGESTION_ORIGIN && + originType !== "system" && + originType !== "extension" && + event.affectedBlocks.includes(active.blockId) + ); + }); + if (!touched) return; + this.cancelActiveGeneration(); + }, + +async _runBlockGeneration(this: any, + prompt: string, + blockId: string, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + const block = this._editor.getBlock(blockId); + if (!block) { + throw new Error(`Block "${blockId}" not found`); + } + + const target: GenerationTarget = { + type: "block", + blockId, + offset: resolveBlockInsertionOffset(this._editor, blockId), + }; + return this._executeGeneration( + prompt, + target, + commandId, + maxSteps, + context, + ); + }, + +async _runDocumentGeneration(this: any, + prompt: string, + preferredBlockId?: string | null, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + const documentTarget = + context?.operation?.target.kind === "document" + ? context.operation.target + : null; + const replaceBlockIds = + documentTarget?.blockIds && documentTarget.blockIds.length > 0 + ? [...documentTarget.blockIds] + : context?.replaceBlockIds; + const insertionAnchor = resolveDocumentInsertionAnchor(this._editor, { + preferredBlockId: + documentTarget?.activeBlockId ?? + documentTarget?.blockIds?.[0] ?? + preferredBlockId ?? + resolveActiveBlockId(this._editor.selection) ?? + null, + }); + if (!insertionAnchor) { + throw new Error( + "Cannot run an AI document prompt without an insertion anchor", + ); + } + + return this._runBlockGeneration( + prompt, + insertionAnchor.blockId, + commandId, + maxSteps, + { + ...context, + replaceTargetBlock: + documentTarget?.placement === "replace-blocks" || + documentTarget?.placement === "replace-empty-block" || + insertionAnchor.strategy === "replace-empty-block" || + (replaceBlockIds?.length ?? 0) > 0, + replaceBlockIds, + }, + ); + }, + +async _runSelectionGeneration(this: any, + prompt: string, + selection: TextSelection, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + return this._executeGeneration( + prompt, + { type: "selection", selection }, + commandId, + maxSteps, + context, + ); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts new file mode 100644 index 0000000..99c14aa --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts @@ -0,0 +1,19 @@ +// @ts-nocheck +import { executeLocalOperation } from "./localOperationExecution"; +import type { AIRequestedOperation, GenerationState } from "../types"; +import type { GenerationExecutionContext, GenerationTarget } from "./extensionHelpers"; + +export const aiControllerMethodsPart5 = { + async _executeLocalOperation(this: any, input: { + prompt: string; + target: GenerationTarget; + blockId: string; + commandId?: string; + context?: GenerationExecutionContext; + abortController: AbortController; + baselineSuggestionIds: Set; + operation: AIRequestedOperation; + }): Promise { + return executeLocalOperation(this, input); + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts new file mode 100644 index 0000000..b86875a --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts @@ -0,0 +1,23 @@ +// @ts-nocheck +import { executeGeneration } from "./generationExecution"; +import type { GenerationState } from "../types"; +import type { GenerationExecutionContext, GenerationTarget } from "./extensionHelpers"; + +export const aiControllerMethodsPart6 = { + async _executeGeneration( + this: any, + prompt: string, + target: GenerationTarget, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + return executeGeneration(this, { + prompt, + target, + commandId, + maxSteps, + context, + }); + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts new file mode 100644 index 0000000..5cd65f9 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts @@ -0,0 +1,446 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart7 = { +_commitRequestedOperationResult(this: any, + operation: AIRequestedOperation, + text: string, + sessionId: string | undefined, + options: { + contentFormat: AIContentFormat; + applyStrategy?: AIApplyStrategy; + }, + ): AIMutationReceipt { + const conflictReason = resolveRequestedOperationConflict( + this._editor, + operation, + this._createSelectionSignature(this._editor.selection), + ); + if (conflictReason) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [conflictReason], + }); + } + + if (operation.kind === "rewrite-selection") { + const selection = resolveSelectionForRequestedOperation( + this._editor, + operation, + ); + if (!selection) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "The requested selection rewrite target is no longer available.", + ], + }); + } + const markdownBlockIds = + options.contentFormat === "markdown" && + operation.target.kind === "scoped-range" && + operation.target.blockIds.length > 0 + ? operation.target.blockIds + : null; + if (markdownBlockIds) { + return this._commitBufferedBlockGeneration( + markdownBlockIds[0], + text, + "persistent-suggestions", + "markdown", + sessionId, + { + applyStrategy: options.applyStrategy, + replaceTargetBlock: true, + replaceBlockIds: markdownBlockIds, + }, + ); + } + return this._commitSelectionRewrite( + selection, + text, + "persistent-suggestions", + sessionId, + ); + } + + if (operation.kind === "rewrite-block") { + const target = + operation.target.kind === "block" ? operation.target : null; + if (!target) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: ["The requested block rewrite target is invalid."], + }); + } + const selection = resolveFullBlockTextSelection( + this._editor, + target.blockId, + ); + if (selection && options.contentFormat === "text") { + return this._commitSelectionRewrite( + selection, + text, + "persistent-suggestions", + sessionId, + ); + } + return this._commitBufferedBlockGeneration( + target.blockId, + text, + "persistent-suggestions", + options.contentFormat, + sessionId, + { + applyStrategy: options.applyStrategy, + replaceTargetBlock: true, + }, + ); + } + + if (operation.kind === "document-transform") { + const target = + operation.target.kind === "document" ? operation.target : null; + if (!target) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "The requested document transform target is invalid.", + ], + }); + } + const replaceBlockIds = target.blockIds?.filter( + (blockId) => this._editor.getBlock(blockId) != null, + ); + if (target.transform === "remove") { + const deleteBlockIds = + replaceBlockIds && replaceBlockIds.length > 0 + ? replaceBlockIds + : this._editor.documentState.blockOrder.filter( + (blockId) => + this._editor.getBlock(blockId) != null, + ); + const ops = deleteBlockIds.map((blockId) => ({ + type: "delete-block" as const, + blockId, + })); + if (ops.length === 0) { + return buildMutationReceipt({ + status: "noop", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._applySuggestedAIOps(ops, sessionId); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + const targetBlockId = + target.activeBlockId ?? + replaceBlockIds?.[0] ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id ?? + null; + if (!targetBlockId) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "The requested document transform target is no longer available.", + ], + }); + } + return this._commitBufferedBlockGeneration( + targetBlockId, + text, + "persistent-suggestions", + options.contentFormat, + sessionId, + { + applyStrategy: options.applyStrategy, + replaceTargetBlock: + target.placement === "replace-blocks" || + target.placement === "replace-empty-block" || + (replaceBlockIds?.length ?? 0) > 0, + replaceBlockIds, + }, + ); + } + + const target = + operation.target.kind === "block" ? operation.target : null; + if (!target) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: ["The requested continuation target is invalid."], + }); + } + return this._commitBufferedBlockGeneration( + target.blockId, + text, + "persistent-suggestions", + "text", + sessionId, + { + insertionOffset: target.insertionOffset, + }, + ); + }, + +_commitSelectionRewrite(this: any, + selection: TextSelection, + text: string, + mutationMode: NonNullable, + sessionId?: string, + ): AIMutationReceipt { + const selectedText = resolveSelectionText(this._editor, selection); + const ops = buildSelectionReplacementOps(this._editor, selection, text); + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + contextChars: selectedText.length, + diffChars: text.length, + }); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._editor.selectTextRange(selection.anchor, selection.focus); + this._editor.deleteSelection({ origin: "ai" }); + const nextSelection = this._editor.selection; + if (nextSelection?.type !== "text") { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: selectedText.length, + diffChars: text.length, + fallbackReason: "selection-lost", + }); + return buildMutationReceipt({ + status: "invalid", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: ["Selection rewrite lost the active text selection."], + }); + } + const caret = nextSelection.anchor; + if (text.length > 0) { + this._editor.apply( + [ + { + type: "insert-text", + blockId: caret.blockId, + offset: caret.offset, + text, + }, + ], + { origin: "ai" }, + ); + } + this._editor.selectTextRange( + { + blockId: caret.blockId, + offset: caret.offset + text.length, + }, + { + blockId: caret.blockId, + offset: caret.offset + text.length, + }, + ); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + contextChars: selectedText.length, + diffChars: text.length, + }); + return buildMutationReceipt({ + status: "applied", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts new file mode 100644 index 0000000..82d83aa --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts @@ -0,0 +1,370 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart8 = { +_commitBufferedBlockGeneration(this: any, + blockId: string, + text: string, + mutationMode: NonNullable, + contentFormat: AIContentFormat, + sessionId?: string, + options?: { + applyStrategy?: AIApplyStrategy; + insertionOffset?: number; + workingSet?: AIWorkingSetEnvelope | null; + replaceTargetBlock?: boolean; + replaceBlockIds?: readonly string[]; + }, + ): AIMutationReceipt { + let fastApplyFallbackMode: "plain-markdown" | null = null; + if ( + contentFormat === "markdown" && + options?.applyStrategy === "markdown-fast-apply" && + (options?.replaceBlockIds?.length ?? 0) === 0 + ) { + const fastApplyReceipt = this._commitBufferedMarkdownFastApply( + blockId, + text, + mutationMode, + sessionId, + options.workingSet ?? null, + ); + if (fastApplyReceipt) { + return fastApplyReceipt; + } + if (!text.trim().startsWith(`<${MARKDOWN_FAST_APPLY_ROOT_TAG}>`)) { + // Backward compatibility: tolerate plain markdown when the model + // does not honor the fast-apply contract. + fastApplyFallbackMode = "plain-markdown"; + } else { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "Fast apply contract could not be compiled safely.", + ], + }); + } + } + + const normalizedText = + contentFormat === "markdown" + ? normalizeFlowMarkdownOutput(text) + : text; + const scopedReplaceBlockIds = + contentFormat === "markdown" + ? (options?.replaceBlockIds?.filter( + (candidateBlockId, index, allBlockIds) => + allBlockIds.indexOf(candidateBlockId) === index && + this._editor.getBlock(candidateBlockId) != null, + ) ?? []) + : []; + if (contentFormat === "markdown" && scopedReplaceBlockIds.length > 0) { + if (normalizedText.trim().length > 0) { + const verification = this._verifyMarkdownFastApplyResult( + scopedReplaceBlockIds, + normalizedText, + ); + if (!verification.valid) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "Scoped markdown replacement could not be verified safely.", + ], + }); + } + } + const ops = this._buildMarkdownScopedReplacementOps( + scopedReplaceBlockIds, + normalizedText, + ); + const scopedReplacementFallback = + this._summarizeFastApplyFallbackOps( + "scoped-replacement", + ops, + scopedReplaceBlockIds.length, + ); + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + this._recordFastApplyDebug({ + executionPath: "scoped-replacement", + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: ops.length > 0 ? "staged_suggestions" : "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + this._recordFastApplyDebug({ + executionPath: "scoped-replacement", + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: ops.length > 0 ? "applied" : "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + if ( + contentFormat === "markdown" && + (mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review") && + this._applySuggestedMarkdownPlaceholderReplacement( + blockId, + normalizedText, + sessionId, + options?.replaceTargetBlock, + options?.replaceBlockIds, + ) + ) { + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + [], + ), + }); + } + return buildMutationReceipt({ + status: "staged_suggestions", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + const ops = + contentFormat === "markdown" + ? this._buildMarkdownBlockGenerationOps( + blockId, + normalizedText, + options?.replaceTargetBlock, + options?.replaceBlockIds, + ) + : this._buildTextBlockGenerationOps( + blockId, + normalizedText, + options?.insertionOffset, + ); + if (ops.length === 0) { + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + ops, + ), + }); + } + return buildMutationReceipt({ + status: "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + ops, + ), + }); + } + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + ops, + ), + }); + } + return buildMutationReceipt({ + status: "applied", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts new file mode 100644 index 0000000..1502c18 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts @@ -0,0 +1,480 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart9 = { +_commitBufferedMarkdownFastApply(this: any, + blockId: string, + text: string, + mutationMode: NonNullable, + sessionId: string | undefined, + workingSet: AIWorkingSetEnvelope | null, + ): AIMutationReceipt | null { + const fastApplyScope = this._resolveMarkdownFastApplyScope( + blockId, + workingSet, + ); + if (!fastApplyScope) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + fallbackReason: "missing-scope", + }); + return null; + } + + const patchPlan = parseMarkdownPatchPlanContract(text); + if (patchPlan) { + const validation = validateDocumentMutationPlanShape( + patchPlan, + this._buildPlanValidationContext( + blockId, + fastApplyScope.blockIds, + ), + ); + if (!validation.valid) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + fallbackReason: "invalid-patch-plan", + verificationFailureReason: validation.issues[0]?.message, + }); + return null; + } + + const execution = buildDocumentMutationPlanExecution( + this._editor, + patchPlan, + ); + if (execution.issues.length > 0) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + fallbackReason: "patch-plan-execution", + verificationFailureReason: execution.issues[0]?.message, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return null; + } + + const verification = this._verifyFlowPatchPlanResult( + patchPlan, + execution.ops, + fastApplyScope.blockIds, + ); + if (!verification.valid) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + fallbackReason: "verification-failed", + verificationFailureReason: verification.reason, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return null; + } + + if (execution.ops.length === 0) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + confidence: patchPlan.confidence?.score, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return buildMutationReceipt({ + status: "noop", + ops: execution.ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(execution.ops, sessionId); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + confidence: patchPlan.confidence?.score, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return buildMutationReceipt({ + status: "staged_suggestions", + ops: execution.ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + this._editor.apply(execution.ops, { + origin: "ai", + undoGroup: true, + }); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + confidence: patchPlan.confidence?.score, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return buildMutationReceipt({ + status: "applied", + ops: execution.ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + const contract = parseMarkdownFastApplyContract(text); + if (!contract) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + fallbackReason: "unparseable-contract", + }); + return null; + } + + const merged = applyMarkdownFastApply({ + originalMarkdown: fastApplyScope.markdown, + contract, + }); + if (!merged.success || !merged.mergedMarkdown) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + confidence: merged.confidence, + fallbackReason: merged.fallbackReason ?? "merge-failed", + verificationFailureReason: merged.issues[0], + }); + return null; + } + + const verification = this._verifyMarkdownFastApplyResult( + fastApplyScope.blockIds, + merged.mergedMarkdown, + ); + if (!verification.valid) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + fallbackReason: "verification-failed", + verificationFailureReason: verification.reason, + untouchedBlockMutationCount: 0, + }); + return null; + } + + const ops = this._buildMarkdownScopedReplacementOps( + fastApplyScope.blockIds, + merged.mergedMarkdown, + ); + const scopedReplacementFallback = this._summarizeFastApplyFallbackOps( + "scoped-replacement", + ops, + fastApplyScope.blockIds.length, + ); + if (ops.length === 0) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + untouchedBlockMutationCount: 0, + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + untouchedBlockMutationCount: 0, + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + untouchedBlockMutationCount: 0, + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: "applied", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + }, + +_resolveMarkdownFastApplyScope(this: any, + blockId: string, + workingSet: AIWorkingSetEnvelope | null, + ): { markdown: string; blockIds: string[] } | null { + const context = + workingSet?.context && typeof workingSet.context === "object" + ? (workingSet.context as { + markdown?: string | null; + retrievedSpan?: AIWorkingSetRetrievedSpan | null; + markdownWindow?: { + blockIds?: string[]; + } | null; + }) + : null; + const markdown = context?.markdown?.trim() ?? ""; + const blockIds = context?.retrievedSpan?.blockIds?.length + ? context.retrievedSpan.blockIds + : context?.markdownWindow?.blockIds?.length + ? context.markdownWindow.blockIds + : [blockId]; + if (markdown.length === 0 || blockIds.length === 0) { + return null; + } + return { + markdown, + blockIds: [...new Set(blockIds)], + }; + }, + +_buildPlanValidationContext(this: any, + blockId: string, + scopeBlockIds: readonly string[], + ): Parameters[1] { + const knownBlockTypes = this._editor.schema + .allBlocks() + .filter((schema) => + shouldExposeBlockInTooling( + this._editor.documentProfile, + schema, + ), + ) + .map((schema) => schema.type); + const editableTargetBlockIds = scopeBlockIds.filter((targetBlockId) => { + const block = this._editor.getBlock(targetBlockId); + if (!block) { + return false; + } + const schema = this._editor.schema.resolve(block.type); + return shouldExposeBlockInTooling( + this._editor.documentProfile, + schema, + ); + }); + + return { + documentProfile: this._editor.documentProfile, + targetKind: this._resolvePlanValidationTargetKind(blockId), + knownBlockTypes, + allowedTargetBlockIds: [...scopeBlockIds], + editableTargetBlockIds, + }; + } +}; diff --git a/packages/extensions/ai/src/extensionParts/controllerDeps.ts b/packages/extensions/ai/src/extensionParts/controllerDeps.ts new file mode 100644 index 0000000..6439786 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllerDeps.ts @@ -0,0 +1,11 @@ +export { getDocumentToolRuntime } from "@pen/document-ops"; +export { runAgenticLoop } from "../agentic/loop"; +export { getBlockAdapter } from "../runtime/blockAdapters"; +export { buildPlannerPrompt, parseStructuredPlanPreview, parseStructuredPlanResult, resolveExecutionMode } from "../runtime/structuredPlanner"; +export { buildGenerationStructuredPreviewState, buildStructuredPreviewPatchOperations } from "../runtime/structuredPreview"; +export { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +export { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +export { buildStructuralReviewItems } from "../runtime/reviewArtifacts"; +export { routeAIRequest } from "../runtime/router"; +export * from "./extensionHelpers"; +export { buildMutationReceipt } from "../runtime/mutationReceipt"; diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpers.ts b/packages/extensions/ai/src/extensionParts/extensionHelpers.ts new file mode 100644 index 0000000..1afc6c5 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpers.ts @@ -0,0 +1,9 @@ +export * from "./extensionHelpersPart1"; +export * from "./extensionHelpersPart2"; +export * from "./extensionHelpersPart3"; +export * from "./extensionHelpersPart4"; +export * from "./extensionHelpersPart5"; +export * from "./extensionHelpersPart6"; +export * from "./extensionHelpersPart7"; +export * from "./extensionHelpersPart8"; +export * from "./extensionHelpersPart9"; diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts new file mode 100644 index 0000000..2c02878 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts @@ -0,0 +1,477 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export type GenerationTarget = + | { + type: "block"; + blockId: string; + offset: number; + } + | { + type: "selection"; + selection: TextSelection; + }; + +export interface GenerationExecutionContext { + sessionId?: string; + surface?: AISurface; + targetType?: GenerationTarget["type"]; + operation?: AIRequestedOperation | null; + replaceTargetBlock?: boolean; + replaceBlockIds?: string[]; +} + +export function resolveGenerationRequestMode( + context?: GenerationExecutionContext, +): string | undefined { + if (context?.operation?.kind === "rewrite-selection") { + if (context.surface === "inline-edit") { + return "inline-edit"; + } + if (context.surface === "bottom-chat") { + return "selection-fast"; + } + } + if (context?.targetType === "selection") { + if (context.surface === "inline-edit") { + return "inline-edit"; + } + if (context.surface === "bottom-chat") { + return "selection-fast"; + } + } + if (context?.surface === "inline-edit") { + return "inline-edit"; + } + if (context?.surface === "bottom-chat") { + return "bottom-chat"; + } + return undefined; +} + +export function isLocalRequestedOperation( + operation: AIRequestedOperation | null | undefined, +): operation is AIRequestedOperation { + return ( + operation?.kind === "rewrite-selection" || + operation?.kind === "rewrite-block" || + operation?.kind === "continue-block" || + (operation?.kind === "document-transform" && + operation.target.kind === "document" && + (operation.target.transform === "rewrite" || + operation.target.transform === "remove" || + operation.target.placement === "replace-blocks")) + ); +} + +export const EMPTY_TOOL_RUNTIME: ToolRuntime = { + registerTool(_def: ToolDefinition): void {}, + unregisterTool(_name: string): void {}, + listTools(): readonly ToolDefinition[] { + return []; + }, + getTool(): ToolDefinition | null { + return null; + }, + async executeTool(name: string): Promise { + throw new Error(`Unknown tool: "${name}"`); + }, +}; + +export const MAX_STREAM_EVENTS = 200; + +export const AI_UNDO_HISTORY_METADATA_KEY = "ai:inline-session-history"; + +export interface AIInlineHistoryRestoreRequest { + direction: AIInlineHistoryDirection; + targetSnapshotId: string; + targetDocumentVersion: number; + shortcutOnly?: boolean; + sessionId?: string | null; + targetState?: AIInlineShortcutHistoryState | null; +} + +export type AIInlineShortcutHistoryPhase = "none" | "review" | "resolved"; + +export interface AIInlineShortcutHistoryState { + sessionId: string | null; + phase: AIInlineShortcutHistoryPhase; + turnCount: number; + turnId: string | null; + resolution?: "accepted" | "rejected"; +} + +export interface AIInlineShortcutHistoryWaypoint { + startIndex: number; + endIndex: number; + representativeIndex: number; + state: AIInlineShortcutHistoryState; +} + +export function resolveOrderedReviewItems( + reviewItems: readonly StructuralReviewItem[], + ids: readonly string[], +): StructuralReviewItem[] { + const remainingIds = new Set(ids); + const orderedReviewItems: StructuralReviewItem[] = []; + for (const reviewItem of reviewItems) { + if (!remainingIds.has(reviewItem.id)) { + continue; + } + orderedReviewItems.push(reviewItem); + remainingIds.delete(reviewItem.id); + } + return orderedReviewItems; +} + +export function sortReviewItemsForRemoval( + reviewItems: readonly StructuralReviewItem[], +): StructuralReviewItem[] { + return [...reviewItems].sort(compareReviewItemRemovalOrder); +} + +export function compareReviewItemRemovalOrder( + left: StructuralReviewItem, + right: StructuralReviewItem, +): number { + const maxPathLength = Math.max( + left.bundlePath.length, + right.bundlePath.length, + ); + for (let index = 0; index < maxPathLength; index += 1) { + const leftPart = left.bundlePath[index] ?? -1; + const rightPart = right.bundlePath[index] ?? -1; + if (leftPart !== rightPart) { + return rightPart - leftPart; + } + } + + const leftStepIndex = left.stepIndex ?? -1; + const rightStepIndex = right.stepIndex ?? -1; + return rightStepIndex - leftStepIndex; +} + +export function resolveActiveBlockId(selection: SelectionState): string | null { + if (!selection) return null; + if (selection.type === "text") return selection.focus.blockId; + if (selection.type === "block") return selection.blockIds[0] ?? null; + if (selection.type === "cell") return selection.blockId; + return null; +} + +export function readModelId(model: ModelAdapter | undefined): string | undefined { + if (!model || typeof model !== "object") return undefined; + const candidate = model as ModelAdapter & { + name?: string; + modelId?: string; + }; + return candidate.modelId ?? candidate.name; +} + +export function supportsStructuredIntent(model: ModelAdapter | undefined): boolean { + return model?.capabilities?.structuredIntent === true; +} + +export type AIStreamEventInput = + | { + type: "generation-start"; + prompt: string; + target: GenerationState["target"]; + } + | { + type: "status"; + status: AIControllerState["status"]; + } + | { + type: "text-delta"; + delta: string; + text: string; + } + | { + type: "operation"; + operation: AIRequestedOperation; + phase: "preview" | "final" | "conflict"; + text?: string; + reason?: string; + } + | { + type: "app-partial"; + data: unknown; + final: boolean; + } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + } + | { + type: "tool-output"; + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + } + | { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + } + | { + type: "structured-preview"; + preview: GenerationStructuredPreviewState; + patches: readonly { + op: "add" | "remove" | "replace"; + path: string; + value?: unknown; + }[]; + } + | { + type: "generation-finish"; + status: GenerationState["status"]; + text: string; + }; + +export function createAIStreamEvent( + generation: Pick< + GenerationState, + "id" | "zoneId" | "blockId" | "sessionId" + >, + event: AIStreamEventInput, +): AIStreamEvent { + return { + ...event, + generationId: generation.id, + sessionId: generation.sessionId, + zoneId: generation.zoneId, + blockId: generation.blockId, + timestamp: Date.now(), + }; +} + +export function resolvePromptTarget( + selection: SelectionState, + target: "auto" | "selection" | "block" | "document" | undefined, +): "selection" | "block" | "document" { + if (target === "selection") { + return "selection"; + } + if (target === "block") { + return "block"; + } + if (target === "document") { + return "document"; + } + return selection?.type === "text" && !selection.isCollapsed + ? "selection" + : "block"; +} + +export function resolveSessionTarget( + editor: Editor, + target: "auto" | "selection" | "block" | "document" | undefined, +): AISessionTarget { + if (target === "document") { + return { kind: "document" }; + } + const selection = editor.selection; + if ( + (target === "selection" || target === "auto") && + selection?.type === "text" && + !selection.isCollapsed + ) { + const range = selection.toRange(); + const selectionSnapshot = resolveSessionSelectionSnapshot(selection); + return { + kind: "selection", + selection: recreateTextSelection(editor, selectionSnapshot), + blockId: range.start.blockId, + }; + } + const blockId = + target === "block" || target === "auto" + ? (resolveActiveBlockId(selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null) + : null; + return blockId ? { kind: "block", blockId } : { kind: "document" }; +} + +export function resolveSessionAnchor( + selection: SelectionState | TextSelection, +): AISession["anchor"] | undefined { + if (selection?.type !== "text") { + return undefined; + } + const range = selection.toRange(); + return { + blockId: range.start.blockId, + from: range.start.offset, + to: range.end.offset, + }; +} + +export function resolveSessionSelectionSnapshot( + selection: TextSelection, +): AISessionSelectionSnapshot { + return { + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + blockRange: [...selection.blockRange], + isMultiBlock: selection.isMultiBlock, + }; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts new file mode 100644 index 0000000..ab7b049 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts @@ -0,0 +1,431 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveContextualPromptAnchor( + target: AISessionTarget, +): NonNullable["anchor"] { + if (target.kind === "selection") { + const range = target.selection.toRange(); + return { + kind: "text-range", + selectionSnapshot: resolveSessionSelectionSnapshot( + target.selection, + ), + focusBlockId: range.start.blockId, + status: "valid", + lastResolvedRect: null, + }; + } + if (target.kind === "block") { + return { + kind: "block", + focusBlockId: target.blockId, + status: "valid", + lastResolvedRect: null, + }; + } + return { + kind: "document", + focusBlockId: null, + status: "valid", + lastResolvedRect: null, + }; +} + +export function resolveContextualPromptState( + target: AISessionTarget, +): NonNullable { + return { + anchor: resolveContextualPromptAnchor(target), + composer: { + draftPrompt: "", + isOpen: true, + isSubmitting: false, + canSubmitFollowUp: true, + openReason: "user", + }, + }; +} + +export function createInlineHistorySnapshot( + editor: Editor, + sessions: readonly AISession[], + activeSessionId: string | null, + documentVersion: number, + options?: { + kind?: AIInlineHistorySnapshot["kind"]; + }, +): AIInlineHistorySnapshot { + return { + id: crypto.randomUUID(), + sessionId: activeSessionId, + sessions: cloneInlineHistorySessions(editor, sessions), + activeSessionId, + documentVersion, + kind: options?.kind ?? "document-coupled", + }; +} + +export function cloneSessionTarget( + editor: Editor, + target: AISessionTarget, +): AISessionTarget { + if (target.kind !== "selection") { + return { ...target }; + } + return { + kind: "selection", + blockId: target.blockId, + selection: recreateTextSelection( + editor, + resolveSessionSelectionSnapshot(target.selection), + ), + }; +} + +export function cloneInlineHistorySessions( + editor: Editor, + sessions: readonly AISession[], +): AISession[] { + return sessions.map((session) => ({ + ...session, + target: cloneSessionTarget(editor, session.target), + contextualPrompt: session.contextualPrompt + ? { + ...session.contextualPrompt, + anchor: { + ...session.contextualPrompt.anchor, + selectionSnapshot: session.contextualPrompt.anchor + .selectionSnapshot + ? { + ...session.contextualPrompt.anchor + .selectionSnapshot, + anchor: { + ...session.contextualPrompt.anchor + .selectionSnapshot.anchor, + }, + focus: { + ...session.contextualPrompt.anchor + .selectionSnapshot.focus, + }, + blockRange: [ + ...session.contextualPrompt.anchor + .selectionSnapshot.blockRange, + ], + } + : undefined, + }, + composer: { + ...session.contextualPrompt.composer, + }, + } + : undefined, + turns: session.turns.map((turn) => ({ + ...turn, + suggestionIds: [...turn.suggestionIds], + reviewItemIds: [...turn.reviewItemIds], + anchor: turn.anchor ? { ...turn.anchor } : undefined, + selection: turn.selection + ? { + ...turn.selection, + anchor: { ...turn.selection.anchor }, + focus: { ...turn.selection.focus }, + blockRange: [...turn.selection.blockRange], + } + : undefined, + })), + promptHistory: session.promptHistory.map((prompt) => ({ ...prompt })), + generationIds: [...session.generationIds], + pendingSuggestionIds: [...session.pendingSuggestionIds], + pendingReviewItemIds: [...session.pendingReviewItemIds], + metrics: { + ...session.metrics, + fastApply: { ...session.metrics.fastApply }, + }, + anchor: session.anchor ? { ...session.anchor } : undefined, + })); +} + +export function recreateTextSelection( + editor: Editor, + snapshot: AISessionSelectionSnapshot, +): TextSelection { + const blockRange = resolveSelectionSnapshotBlockRange(editor, snapshot); + const isCollapsed = + snapshot.anchor.blockId === snapshot.focus.blockId && + snapshot.anchor.offset === snapshot.focus.offset; + const documentRange = { + start: resolveSelectionSnapshotRangeStart(snapshot, blockRange), + end: resolveSelectionSnapshotRangeEnd(snapshot, blockRange), + get isMultiBlock() { + return blockRange.length > 1; + }, + get blockRange() { + return [...blockRange]; + }, + contains(point: { blockId: string; offset: number }): boolean { + if (!blockRange.includes(point.blockId)) { + return false; + } + const isSingleBlock = blockRange.length === 1; + if (isSingleBlock) { + return ( + point.offset >= this.start.offset && + point.offset <= this.end.offset + ); + } + if (point.blockId === this.start.blockId) { + return point.offset >= this.start.offset; + } + if (point.blockId === this.end.blockId) { + return point.offset <= this.end.offset; + } + return true; + }, + overlaps(other: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + contains: (point: { blockId: string; offset: number }) => boolean; + }): boolean { + return ( + this.contains(other.start) || + this.contains(other.end) || + other.contains(this.start) + ); + }, + equals(other: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + }): boolean { + return ( + this.start.blockId === other.start.blockId && + this.start.offset === other.start.offset && + this.end.blockId === other.end.blockId && + this.end.offset === other.end.offset + ); + }, + toTextSelection() { + return recreateTextSelection(editor, snapshot); + }, + }; + return { + type: "text", + anchor: { ...snapshot.anchor }, + focus: { ...snapshot.focus }, + get isCollapsed() { + return isCollapsed; + }, + get isMultiBlock() { + return blockRange.length > 1; + }, + get blockRange() { + return [...blockRange]; + }, + toRange() { + return documentRange; + }, + }; +} + +export function resolveSelectionSnapshotBlockRange( + editor: Editor, + snapshot: AISessionSelectionSnapshot, +): string[] { + if (snapshot.blockRange.length > 0) { + return [...snapshot.blockRange]; + } + const blockOrder = editor.documentState.blockOrder; + const anchorIndex = blockOrder.indexOf(snapshot.anchor.blockId); + const focusIndex = blockOrder.indexOf(snapshot.focus.blockId); + if (anchorIndex === -1 || focusIndex === -1) { + return [snapshot.anchor.blockId]; + } + const startIndex = Math.min(anchorIndex, focusIndex); + const endIndex = Math.max(anchorIndex, focusIndex); + return blockOrder.slice(startIndex, endIndex + 1); +} + +export function resolveSelectionSnapshotRangeStart( + snapshot: AISessionSelectionSnapshot, + blockRange: readonly string[], +): { blockId: string; offset: number } { + if (blockRange.length <= 1) { + return { + blockId: snapshot.anchor.blockId, + offset: Math.min(snapshot.anchor.offset, snapshot.focus.offset), + }; + } + const firstBlockId = blockRange[0] ?? snapshot.anchor.blockId; + return snapshot.anchor.blockId === firstBlockId + ? { ...snapshot.anchor } + : { ...snapshot.focus }; +} + +export function resolveSelectionSnapshotRangeEnd( + snapshot: AISessionSelectionSnapshot, + blockRange: readonly string[], +): { blockId: string; offset: number } { + if (blockRange.length <= 1) { + return { + blockId: snapshot.anchor.blockId, + offset: Math.max(snapshot.anchor.offset, snapshot.focus.offset), + }; + } + const lastBlockId = + blockRange[blockRange.length - 1] ?? snapshot.focus.blockId; + return snapshot.anchor.blockId === lastBlockId + ? { ...snapshot.anchor } + : { ...snapshot.focus }; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts new file mode 100644 index 0000000..0d70ac4 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts @@ -0,0 +1,465 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveRequestedOperationForSession( + editor: Editor, + session: AISession, + prompt: string, + options: AICommandExecutionOptions | undefined, + documentVersion: number, +): AIRequestedOperation { + const explicitTarget = options?.target; + const promptIntent = classifyPromptIntent(prompt); + const capturedSelection = resolveSessionSelectionTarget(editor, session); + const liveSelection = + session.surface === "inline-edit" + ? capturedSelection + : editor.selection?.type === "text" && !editor.selection.isCollapsed + ? editor.selection + : capturedSelection; + const activeBlockId = + options?.blockId ?? + resolveSessionBlockId(editor, session) ?? + resolveActiveBlockId(editor.selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null; + const documentActiveBlockId = + options?.blockId ?? + resolveActiveBlockId(editor.selection) ?? + session.anchor?.blockId ?? + null; + const resolvedEditProposal = resolveResolvedEditProposal( + editor, + session, + prompt, + promptIntent, + explicitTarget, + liveSelection, + "markdown", + ); + const clearDocument = + session.target.kind === "document" && isClearDocumentPrompt(prompt); + const documentBlockIds = editor.documentState.blockOrder.filter( + (blockId) => editor.getBlock(blockId) != null, + ); + const documentTransformPlan = clearDocument + ? { + blockIds: documentBlockIds, + placement: "replace-blocks" as const, + transform: "remove" as const, + } + : undefined; + + if (resolvedEditProposal) { + return createRewriteSelectionOperationFromResolvedTarget( + editor, + resolvedEditProposal.target, + resolvedEditProposal.promptIntent, + documentVersion, + ); + } + if (promptIntent === "continue" && activeBlockId) { + if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { + return createDocumentTransformOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + { + blockIds: [activeBlockId], + placement: "append-after-block", + transform: "write", + }, + ); + } + return createContinueBlockOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + ); + } + if ( + activeBlockId && + (promptIntent === "rewrite" || + (promptIntent === "local-edit" && + (editor.getBlock(activeBlockId)?.textContent().length ?? 0) > + 0) || + explicitTarget === "block") + ) { + if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { + return createDocumentTransformOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + { + blockIds: [activeBlockId], + placement: "replace-blocks", + transform: "rewrite", + }, + ); + } + return createRewriteBlockOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + ); + } + if (explicitTarget === "document") { + return createDocumentTransformOperation( + editor, + documentActiveBlockId, + promptIntent, + documentVersion, + documentTransformPlan, + ); + } + return createDocumentTransformOperation( + editor, + session.target.kind === "document" + ? documentActiveBlockId + : activeBlockId, + promptIntent, + documentVersion, + documentTransformPlan, + ); +} + +export function resolveLocalOperationContentFormat( + editor: Editor, + operation: AIRequestedOperation, + defaultBlockFormat: AIContentFormat, +): AIContentFormat { + if (operation.kind === "rewrite-selection") { + return operation.target.kind === "scoped-range" + ? operation.target.contentFormat + : "text"; + } + if (operation.kind === "document-transform") { + return defaultBlockFormat; + } + if (operation.kind !== "rewrite-block") { + return "text"; + } + const blockId = + operation.target.kind === "block" ? operation.target.blockId : null; + if (blockId && resolveFullBlockTextSelection(editor, blockId)) { + return "text"; + } + return defaultBlockFormat; +} + +export function canUseLocalBlockTextOperation( + editor: Editor, + blockId: string, +): boolean { + const block = editor.getBlock(blockId); + if (!block) { + return false; + } + const schema = editor.schema.resolve(block.type); + if (!schema || !usesInlineTextSelection(schema)) { + return false; + } + return resolveFullBlockTextSelection(editor, blockId) != null; +} + +export function canReuseBottomChatSessionOperation( + previousOperation: AIRequestedOperation, + nextOperation: AIRequestedOperation, +): boolean { + const previousResolvedTarget = + resolveResolvedEditTargetFromRequestedOperation(previousOperation); + const nextResolvedTarget = + resolveResolvedEditTargetFromRequestedOperation(nextOperation); + if (previousResolvedTarget && nextResolvedTarget) { + return areResolvedEditTargetsEqual( + previousResolvedTarget, + nextResolvedTarget, + ); + } + if (previousOperation.kind !== nextOperation.kind) { + return false; + } + if (previousOperation.target.kind !== nextOperation.target.kind) { + return false; + } + if ( + previousOperation.target.kind === "selection" || + previousOperation.target.kind === "scoped-range" + ) { + if ( + nextOperation.target.kind !== "selection" && + nextOperation.target.kind !== "scoped-range" + ) { + return false; + } + return ( + previousOperation.provenance?.selectionSignature === + nextOperation.provenance?.selectionSignature && + previousOperation.target.sourceText === + nextOperation.target.sourceText + ); + } + if (previousOperation.target.kind === "block") { + if (nextOperation.target.kind !== "block") { + return false; + } + return ( + previousOperation.target.blockId === nextOperation.target.blockId && + previousOperation.provenance?.blockRevision === + nextOperation.provenance?.blockRevision + ); + } + if (nextOperation.target.kind !== "document") { + return false; + } + return ( + previousOperation.target.activeBlockId === + nextOperation.target.activeBlockId && + areStructuredValuesEqual( + previousOperation.target.blockIds ?? [], + nextOperation.target.blockIds ?? [], + ) && + (previousOperation.target.placement ?? null) === + (nextOperation.target.placement ?? null) && + (previousOperation.target.transform ?? null) === + (nextOperation.target.transform ?? null) + ); +} + +export function resolveResolvedEditTargetFromRequestedOperation( + operation: AIRequestedOperation, +): ResolvedEditTarget | null { + if ( + operation.target.kind !== "selection" && + operation.target.kind !== "scoped-range" + ) { + return null; + } + return operation.target; +} + +export function areResolvedEditTargetsEqual( + previousTarget: ResolvedEditTarget, + nextTarget: ResolvedEditTarget, +): boolean { + if (previousTarget.kind !== nextTarget.kind) { + return false; + } + if ( + previousTarget.blockId !== nextTarget.blockId || + previousTarget.sourceText !== nextTarget.sourceText || + previousTarget.anchor.blockId !== nextTarget.anchor.blockId || + previousTarget.anchor.offset !== nextTarget.anchor.offset || + previousTarget.focus.blockId !== nextTarget.focus.blockId || + previousTarget.focus.offset !== nextTarget.focus.offset + ) { + return false; + } + if ( + previousTarget.kind === "scoped-range" && + nextTarget.kind === "scoped-range" + ) { + return ( + previousTarget.scope === nextTarget.scope && + previousTarget.contentFormat === nextTarget.contentFormat && + areStructuredValuesEqual( + previousTarget.blockIds, + nextTarget.blockIds, + ) + ); + } + return true; +} + +export function buildSessionExecutionPrompt( + session: AISession | null, + prompt: string, +): string { + if (!session) { + return prompt; + } + const previousPrompts = session.promptHistory + .map((item) => item.prompt.trim()) + .filter((item) => item.length > 0) + .slice(-4); + if (previousPrompts.length === 0) { + return prompt; + } + const historyLines = previousPrompts.map( + (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, + ); + const intro = + session.surface === "inline-edit" + ? "You are continuing an existing inline editor edit session." + : "You are continuing an existing editor chat session."; + const applyInstruction = + session.surface === "inline-edit" + ? "Apply the latest request to the current selected document state." + : "Apply the latest request to the current document state."; + return [ + intro, + "Earlier user requests in this same session:", + ...historyLines, + "", + applyInstruction, + "Latest request:", + prompt, + ].join("\n"); +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts new file mode 100644 index 0000000..7e118f9 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts @@ -0,0 +1,402 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function createRewriteSelectionOperation( + editor: Editor, + selection: TextSelection, + promptIntent: string, + documentVersion: number, + options?: { + sourceText?: string; + }, +): AIRequestedOperation { + const range = selection.toRange(); + return { + kind: "rewrite-selection", + applyPolicy: "selection-replace", + promptIntent, + target: { + kind: "selection", + blockId: range.start.blockId, + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + sourceText: + options?.sourceText ?? resolveSelectionText(editor, selection), + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision(range.start.blockId), + selectionSignature: createSelectionSignature(selection), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createRewriteSelectionOperationFromResolvedTarget( + editor: Editor, + target: ResolvedEditTarget, + promptIntent: string, + documentVersion: number, +): AIRequestedOperation { + const selection = recreateTextSelection(editor, { + anchor: target.anchor, + focus: target.focus, + blockRange: resolveSelectionTargetBlockIds(editor, target), + isMultiBlock: + resolveSelectionTargetBlockIds(editor, target).length > 1 || + target.anchor.blockId !== target.focus.blockId, + }); + if (target.kind === "selection") { + return createRewriteSelectionOperation( + editor, + selection, + promptIntent, + documentVersion, + { + sourceText: target.sourceText, + }, + ); + } + return { + kind: "rewrite-selection", + applyPolicy: "selection-replace", + promptIntent, + target: { + kind: "scoped-range", + blockId: target.blockId, + anchor: { ...target.anchor }, + focus: { ...target.focus }, + sourceText: target.sourceText, + blockIds: [...target.blockIds], + contentFormat: target.contentFormat, + scope: target.scope, + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision( + target.blockId ?? selection.anchor.blockId, + ), + selectionSignature: createSelectionSignature(selection), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createRewriteBlockOperation( + editor: Editor, + blockId: string, + promptIntent: string, + documentVersion: number, +): AIRequestedOperation { + const block = editor.getBlock(blockId); + return { + kind: "rewrite-block", + applyPolicy: "block-replace", + promptIntent, + target: { + kind: "block", + blockId, + blockType: block?.type ?? null, + sourceText: block?.textContent() ?? "", + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision(blockId), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createContinueBlockOperation( + editor: Editor, + blockId: string, + promptIntent: string, + documentVersion: number, +): AIRequestedOperation { + const block = editor.getBlock(blockId); + return { + kind: "continue-block", + applyPolicy: "block-continue", + promptIntent, + target: { + kind: "block", + blockId, + blockType: block?.type ?? null, + sourceText: block?.textContent() ?? "", + insertionOffset: resolveContinueInsertionOffset(editor, blockId), + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision(blockId), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createDocumentTransformOperation( + editor: Editor, + activeBlockId: string | null, + promptIntent: string, + documentVersion: number, + options?: { + blockIds?: readonly string[]; + placement?: + | "append-after-block" + | "replace-empty-block" + | "replace-blocks"; + transform?: "write" | "rewrite" | "remove"; + }, +): AIRequestedOperation { + return { + kind: "document-transform", + applyPolicy: "document-review", + promptIntent, + target: { + kind: "document", + activeBlockId, + blockIds: options?.blockIds, + placement: options?.placement, + transform: options?.transform, + }, + provenance: { + documentVersion, + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function resolvePreviousGeneratedBlockIds(session: AISession): string[] { + const completedTurns = session.turns.filter( + (turn) => turn.status === "complete" || turn.status === "accepted", + ); + const lastTurnWithBlocks = completedTurns + .slice() + .reverse() + .find((turn) => turn.generatedBlockIds.length > 0); + return lastTurnWithBlocks?.generatedBlockIds ?? []; +} + +export function shouldReplacePreviousGeneratedBlocks( + session: AISession, + prompt: string, +): boolean { + return ( + session.surface === "bottom-chat" && + session.target.kind === "document" && + (classifyPromptIntent(prompt) === "rewrite" || + isDocumentResetPrompt(prompt) || + isDocumentFollowUpEditPrompt(prompt)) + ); +} + +export function resolveReplacementDeleteBlockIds( + editor: Editor, + blockId: string, + replaceBlockIds?: readonly string[], +): string[] { + const requestedIds = + replaceBlockIds && replaceBlockIds.length > 0 + ? replaceBlockIds + : [blockId]; + const deleteBlockIds = requestedIds.filter( + (candidateBlockId, index, allBlockIds) => + allBlockIds.indexOf(candidateBlockId) === index && + editor.getBlock(candidateBlockId) != null, + ); + return deleteBlockIds.length > 0 ? deleteBlockIds : [blockId]; +} + +export function createResolvedSelectionEditTarget( + editor: Editor, + selection: TextSelection, +): ResolvedEditTarget { + const range = selection.toRange(); + return { + kind: "selection", + blockId: range.start.blockId, + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + sourceText: resolveSelectionText(editor, selection), + }; +} + +export function createResolvedScopedEditTarget( + editor: Editor, + selection: TextSelection, + scope: ModelOperationScopedRangeTarget["scope"], + contentFormat: AIContentFormat, +): ResolvedEditTarget { + const range = selection.toRange(); + return { + kind: "scoped-range", + scope, + blockId: range.start.blockId, + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + blockIds: [...range.blockRange], + sourceText: resolveSelectionText(editor, selection), + contentFormat, + }; +} + +export function createResolvedEditProposal( + promptIntent: string, + target: ResolvedEditTarget, +): ResolvedEditProposal { + return { + promptIntent, + target, + }; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts new file mode 100644 index 0000000..985a68e --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts @@ -0,0 +1,432 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveResolvedEditProposal( + editor: Editor, + session: AISession, + prompt: string, + promptIntent: string, + explicitTarget: AICommandExecutionOptions["target"] | undefined, + liveSelection: TextSelection | null, + defaultBlockFormat: AIContentFormat, +): ResolvedEditProposal | null { + if (liveSelection && explicitTarget === "selection") { + return createResolvedEditProposal( + promptIntent, + createResolvedSelectionEditTarget(editor, liveSelection), + ); + } + + const selectionScopedSession = session.target.kind === "selection"; + if ( + liveSelection && + (session.surface === "inline-edit" || + (selectionScopedSession && + (promptIntent === "rewrite" || promptIntent === "local-edit"))) + ) { + return createResolvedEditProposal( + promptIntent, + createResolvedSelectionEditTarget(editor, liveSelection), + ); + } + + if (session.target.kind !== "document" && explicitTarget !== "document") { + return null; + } + if ( + promptIntent === "continue" || + promptIntent === "review" || + promptIntent === "search" || + promptIntent === "structural" + ) { + return null; + } + + const titleSelection = resolveDocumentTitleSelection(editor, prompt); + if (titleSelection) { + return createResolvedEditProposal( + promptIntent, + createResolvedScopedEditTarget( + editor, + titleSelection, + "heading", + defaultBlockFormat, + ), + ); + } + + const paragraphSelection = resolveDocumentParagraphSelection( + editor, + prompt, + ); + if (paragraphSelection) { + return createResolvedEditProposal( + promptIntent, + createResolvedScopedEditTarget( + editor, + paragraphSelection, + "paragraph", + defaultBlockFormat, + ), + ); + } + + const documentBlockIds = editor.documentState.blockOrder.filter( + (blockId) => editor.getBlock(blockId) != null, + ); + const documentHasMeaningfulContent = documentBlockIds.some((blockId) => { + const block = editor.getBlock(blockId); + return (block?.textContent().trim().length ?? 0) > 0; + }); + const shouldRewriteDocumentScope = + !documentHasMeaningfulContent || + promptIntent === "rewrite" || + isClearDocumentPrompt(prompt) || + isWholeDocumentRewritePrompt(prompt) || + isDocumentResetPrompt(prompt) || + isDocumentFollowUpEditPrompt(prompt); + if (!shouldRewriteDocumentScope) { + return null; + } + + const documentSelection = resolveDocumentBlockRangeSelection( + editor, + documentBlockIds, + ); + if (!documentSelection) { + return null; + } + return createResolvedEditProposal( + promptIntent, + createResolvedScopedEditTarget( + editor, + documentSelection, + "document", + defaultBlockFormat, + ), + ); +} + +export function resolveSelectionForRequestedOperation( + editor: Editor, + operation: AIRequestedOperation, +): TextSelection | null { + if ( + operation.target.kind !== "selection" && + operation.target.kind !== "scoped-range" + ) { + return null; + } + return recreateTextSelection(editor, { + anchor: operation.target.anchor, + focus: operation.target.focus, + blockRange: resolveSelectionTargetBlockIds(editor, operation.target), + isMultiBlock: + resolveSelectionTargetBlockIds(editor, operation.target).length > + 1 || + operation.target.anchor.blockId !== operation.target.focus.blockId, + }); +} + +export function resolveFullBlockTextSelection( + editor: Editor, + blockId: string, +): TextSelection | null { + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + return recreateTextSelection(editor, { + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: block.textContent().length }, + blockRange: [blockId], + isMultiBlock: false, + }); +} + +export function resolveDocumentBlockRangeSelection( + editor: Editor, + blockIds: readonly string[], +): TextSelection | null { + const resolvedBlockIds = blockIds.filter( + (blockId, index, allBlockIds) => + allBlockIds.indexOf(blockId) === index && + editor.getBlock(blockId) != null, + ); + const firstBlockId = resolvedBlockIds[0]; + const lastBlockId = resolvedBlockIds[resolvedBlockIds.length - 1]; + if (!firstBlockId || !lastBlockId) { + return null; + } + const lastBlock = editor.getBlock(lastBlockId); + return recreateTextSelection(editor, { + anchor: { blockId: firstBlockId, offset: 0 }, + focus: { + blockId: lastBlockId, + offset: lastBlock?.textContent().length ?? 0, + }, + blockRange: resolvedBlockIds, + isMultiBlock: resolvedBlockIds.length > 1, + }); +} + +export function resolveDocumentTitleSelection( + editor: Editor, + prompt: string, +): TextSelection | null { + if (!/\b(title|heading)\b/i.test(prompt)) { + return null; + } + const headingBlockId = + editor.documentState.blockOrder.find((blockId) => { + const block = editor.getBlock(blockId); + return ( + block?.type === "heading" || block?.type.startsWith("heading-") + ); + }) ?? + editor.firstBlock()?.id ?? + null; + return headingBlockId + ? resolveDocumentBlockRangeSelection(editor, [headingBlockId]) + : null; +} + +export function resolveDocumentParagraphSelection( + editor: Editor, + prompt: string, +): TextSelection | null { + const paragraphIndex = parseParagraphReference(prompt); + if (paragraphIndex == null) { + return null; + } + const paragraphBlockIds = editor.documentState.blockOrder.filter( + (blockId) => { + const block = editor.getBlock(blockId); + if (!block) { + return false; + } + return ( + block.type === "paragraph" || + (block.textContent().trim().length > 0 && + block.type !== "heading" && + !block.type.startsWith("heading-")) + ); + }, + ); + const targetParagraphBlockId = + paragraphBlockIds[paragraphIndex - 1] ?? null; + return targetParagraphBlockId + ? resolveDocumentBlockRangeSelection(editor, [targetParagraphBlockId]) + : null; +} + +export function parseParagraphReference(prompt: string): number | null { + const match = prompt.match( + /\b(?:(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)|(\d+)(?:st|nd|rd|th))\s+paragraph\b/i, + ); + if (!match) { + return null; + } + const wordOrdinal = match[1]?.toLowerCase(); + if (wordOrdinal) { + return resolveWordOrdinal(wordOrdinal); + } + const numericOrdinal = Number.parseInt(match[2] ?? "", 10); + return Number.isFinite(numericOrdinal) && numericOrdinal > 0 + ? numericOrdinal + : null; +} + +export function resolveWordOrdinal(word: string): number | null { + switch (word) { + case "first": + return 1; + case "second": + return 2; + case "third": + return 3; + case "fourth": + return 4; + case "fifth": + return 5; + case "sixth": + return 6; + case "seventh": + return 7; + case "eighth": + return 8; + case "ninth": + return 9; + case "tenth": + return 10; + default: + return null; + } +} + +export function resolveBlockIdForRequestedOperation( + operation: AIRequestedOperation, +): string | null { + if (operation.target.kind === "block") { + return operation.target.blockId; + } + if ( + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" + ) { + return operation.target.blockId; + } + return operation.target.activeBlockId; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts new file mode 100644 index 0000000..06552e7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts @@ -0,0 +1,481 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveRequestedOperationConflict( + editor: Editor, + operation: AIRequestedOperation, + currentSelectionSignature: string | null, +): string | null { + if ( + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" + ) { + const selection = resolveSelectionForRequestedOperation( + editor, + operation, + ); + if (!selection) { + return "The selected range no longer exists."; + } + if (isScopedSelectionTarget(operation.target)) { + if ( + renderSelectionTargetBlockText(editor, operation.target) !== + operation.target.sourceText + ) { + return "The selected text changed before the rewrite completed."; + } + return null; + } + if ( + operation.provenance?.selectionSignature != null && + operation.provenance.selectionSignature !== + currentSelectionSignature + ) { + return "The selected range changed before the rewrite completed."; + } + if ( + resolveSelectionText(editor, selection) !== + operation.target.sourceText + ) { + return "The selected text changed before the rewrite completed."; + } + return null; + } + if (operation.target.kind === "block") { + const block = editor.getBlock(operation.target.blockId); + if (!block) { + return "The target block no longer exists."; + } + if ( + operation.provenance?.blockRevision != null && + editor.getBlockRevision(operation.target.blockId) !== + operation.provenance.blockRevision + ) { + return "The target block changed before the operation completed."; + } + return null; + } + if ( + operation.provenance?.syncedGeneration != null && + editor.documentState.generation !== + operation.provenance.syncedGeneration + ) { + return "The document changed before the operation completed."; + } + return null; +} + +export function resolveContinueInsertionOffset( + editor: Editor, + blockId: string, +): number { + const selection = editor.selection; + if ( + selection?.type === "text" && + selection.isCollapsed && + selection.anchor.blockId === blockId + ) { + return selection.anchor.offset; + } + return resolveBlockInsertionOffset(editor, blockId); +} + +export function createSelectionSignature(selection: TextSelection): string { + return [ + "text", + selection.anchor.blockId, + selection.anchor.offset, + selection.focus.blockId, + selection.focus.offset, + String(selection.isCollapsed), + ].join(":"); +} + +export function resolveSessionSelectionTarget( + editor: Editor, + session: AISession, +): TextSelection | null { + const anchorSelection = session.contextualPrompt?.anchor.selectionSnapshot; + if (session.target.kind !== "selection" && !anchorSelection) { + return null; + } + const activeTurnSelection = session.activeTurnId + ? session.turns.find((turn) => turn.id === session.activeTurnId) + ?.selection + : session.turns[session.turns.length - 1]?.selection; + if (activeTurnSelection) { + const restoredSelection = recreateTextSelection( + editor, + activeTurnSelection, + ); + if (!restoredSelection.isCollapsed) { + return restoredSelection; + } + } + const selection = editor.selection; + if ( + selection?.type === "text" && + !selection.isCollapsed && + selectionMatchesSnapshot( + selection, + session.target.kind === "selection" + ? resolveSessionSelectionSnapshot(session.target.selection) + : (anchorSelection ?? null), + ) + ) { + return selection; + } + if (anchorSelection) { + const restoredSelection = recreateTextSelection( + editor, + anchorSelection, + ); + if (!restoredSelection.isCollapsed) { + return restoredSelection; + } + } + if ( + session.target.kind === "selection" && + !session.target.selection.isCollapsed + ) { + return session.target.selection; + } + return null; +} + +export function resolveLiveInlineSelectionTarget( + editor: Editor, +): Extract | null { + const selection = editor.selection; + if (selection?.type !== "text" || selection.isCollapsed) { + return null; + } + const target = resolveSessionTarget(editor, "selection"); + return target.kind === "selection" ? target : null; +} + +export function resolvePendingInlineSelectionTarget( + editor: Editor, + operation: AIRequestedOperation | undefined, + suggestionIds: readonly string[], +): Extract | null { + if ( + operation?.kind !== "rewrite-selection" || + operation.target.kind !== "selection" || + operation.target.anchor.blockId !== operation.target.focus.blockId + ) { + return null; + } + const textSuggestions = readAllSuggestions(editor).filter( + (suggestion): suggestion is PersistentTextSuggestion => + suggestion.kind === "text" && + (suggestion.action === "insert" || + suggestion.action === "delete") && + suggestionIds.includes(suggestion.id), + ); + if (textSuggestions.length === 0) { + return null; + } + const blockId = operation.target.anchor.blockId; + const startOffset = Math.min( + operation.target.anchor.offset, + operation.target.focus.offset, + ); + const previewSpanLength = textSuggestions.reduce( + (totalLength, suggestion) => totalLength + suggestion.length, + 0, + ); + const endOffset = startOffset + previewSpanLength; + if (endOffset <= startOffset) { + return null; + } + return { + kind: "selection", + blockId, + selection: recreateTextSelection(editor, { + anchor: { blockId, offset: startOffset }, + focus: { blockId, offset: endOffset }, + blockRange: [blockId], + isMultiBlock: false, + }), + }; +} + +export function resolveAcceptedInlineSelectionTarget( + editor: Editor, + operation: AIRequestedOperation | undefined, + suggestionIds: readonly string[], +): Extract | null { + if ( + operation?.kind !== "rewrite-selection" || + operation.target.kind !== "selection" || + operation.target.anchor.blockId !== operation.target.focus.blockId + ) { + return null; + } + const insertSuggestions = readAllSuggestions(editor).filter( + (suggestion): suggestion is PersistentTextSuggestion => + suggestion.kind === "text" && + suggestion.action === "insert" && + suggestionIds.includes(suggestion.id), + ); + if (insertSuggestions.length === 0) { + return null; + } + const blockId = operation.target.anchor.blockId; + const startOffset = Math.min( + operation.target.anchor.offset, + operation.target.focus.offset, + ); + const insertedLength = insertSuggestions.reduce( + (totalLength, suggestion) => totalLength + suggestion.length, + 0, + ); + const endOffset = startOffset + insertedLength; + if (endOffset <= startOffset) { + return null; + } + return { + kind: "selection", + blockId, + selection: recreateTextSelection(editor, { + anchor: { blockId, offset: startOffset }, + focus: { blockId, offset: endOffset }, + blockRange: [blockId], + isMultiBlock: false, + }), + }; +} + +export function shouldCloseInlineSessionPrompt(session: AISession): boolean { + return ( + session.surface === "inline-edit" && session.contextualPrompt != null + ); +} + +export function closeInlineSessionPrompt( + session: AISession, +): AISession["contextualPrompt"] | undefined { + if (!shouldCloseInlineSessionPrompt(session) || !session.contextualPrompt) { + return session.contextualPrompt; + } + + return { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: false, + isSubmitting: false, + }, + }; +} + +export function createDefaultSessionFastApplyMetrics(): AISessionMetrics["fastApply"] { + return { + attemptCount: 0, + nativeFastApplyCount: 0, + scopedReplacementCount: 0, + plainMarkdownCount: 0, + failedCount: 0, + }; +} + +export function accumulateSessionFastApplyMetrics( + current: AISessionMetrics["fastApply"] | undefined, + fastApply: FastApplyDebugState | undefined, +): AISessionMetrics["fastApply"] { + const next = { + ...(current ?? createDefaultSessionFastApplyMetrics()), + }; + if (!fastApply?.attempted) { + return next; + } + next.attemptCount += 1; + switch (fastApply.executionPath) { + case "native-fast-apply": + next.nativeFastApplyCount += 1; + return next; + case "scoped-replacement": + next.scopedReplacementCount += 1; + return next; + case "plain-markdown": + next.plainMarkdownCount += 1; + return next; + default: + next.failedCount += 1; + return next; + } +} + +export function selectionMatchesSnapshot( + selection: TextSelection, + snapshot: AISessionSelectionSnapshot | null, +): boolean { + if (!snapshot) { + return false; + } + + return ( + selection.anchor.blockId === snapshot.anchor.blockId && + selection.anchor.offset === snapshot.anchor.offset && + selection.focus.blockId === snapshot.focus.blockId && + selection.focus.offset === snapshot.focus.offset && + selection.isMultiBlock === snapshot.isMultiBlock && + selection.blockRange.length === snapshot.blockRange.length && + selection.blockRange.every( + (blockId, index) => blockId === snapshot.blockRange[index], + ) + ); +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts new file mode 100644 index 0000000..3e4bf49 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts @@ -0,0 +1,480 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveSessionSelectionSnapshots( + session: AISession, +): readonly AISessionSelectionSnapshot[] { + const snapshots: AISessionSelectionSnapshot[] = []; + const activeTurn = + session.activeTurnId != null + ? (session.turns.find((turn) => turn.id === session.activeTurnId) ?? + null) + : (session.turns[session.turns.length - 1] ?? null); + if (activeTurn?.selection) { + snapshots.push(activeTurn.selection); + } + if (session.contextualPrompt?.anchor.selectionSnapshot) { + snapshots.push(session.contextualPrompt.anchor.selectionSnapshot); + } + if (session.target.kind === "selection") { + snapshots.push( + resolveSessionSelectionSnapshot(session.target.selection), + ); + } + return snapshots; +} + +export function sessionTargetMatches( + session: AISession, + target: AISessionTarget, +): boolean { + if (session.target.kind !== target.kind) { + return false; + } + if (target.kind !== "selection") { + return areStructuredValuesEqual(session.target, target); + } + return sessionSelectionMatches(session, target.selection); +} + +export function sessionSelectionMatches( + session: AISession, + selection: TextSelection, +): boolean { + return resolveSessionSelectionSnapshots(session).some((snapshot) => + selectionMatchesSnapshot(selection, snapshot), + ); +} + +export function resolveSessionBlockId( + editor: Editor, + session: AISession, +): string | null { + if (session.target.kind === "block") { + return session.target.blockId; + } + if (session.target.kind === "selection") { + return session.target.blockId; + } + return ( + resolveActiveBlockId(editor.selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null + ); +} + +export function resolveBlockInsertionOffset(editor: Editor, blockId: string): number { + const selection = editor.selection; + const block = editor.getBlock(blockId); + const fallbackOffset = + block && isVisuallyEmptyInlineText(block.textContent()) + ? 0 + : (block?.textContent().length ?? 0); + if (selection?.type !== "text") { + return fallbackOffset; + } + const range = selection.toRange(); + if (selection.isCollapsed) { + return selection.anchor.blockId === blockId + ? selection.anchor.offset + : fallbackOffset; + } + if (range.start.blockId === blockId && range.end.blockId === blockId) { + return range.end.offset; + } + if (range.end.blockId === blockId) { + return range.end.offset; + } + if (range.start.blockId === blockId) { + return range.start.offset; + } + return fallbackOffset; +} + +export function appendUniqueString( + values: readonly string[], + value: string, +): string[] { + return values.includes(value) ? [...values] : [...values, value]; +} + +export function areSuggestionsEqual( + previous: readonly PersistentSuggestion[], + next: readonly PersistentSuggestion[], +): boolean { + if (previous.length !== next.length) { + return false; + } + + for (let index = 0; index < previous.length; index += 1) { + const previousSuggestion = previous[index]; + const nextSuggestion = next[index]; + if ( + previousSuggestion.id !== nextSuggestion.id || + previousSuggestion.kind !== nextSuggestion.kind || + previousSuggestion.blockId !== nextSuggestion.blockId || + previousSuggestion.action !== nextSuggestion.action || + previousSuggestion.author !== nextSuggestion.author || + previousSuggestion.authorType !== nextSuggestion.authorType || + previousSuggestion.createdAt !== nextSuggestion.createdAt || + previousSuggestion.model !== nextSuggestion.model || + previousSuggestion.sessionId !== nextSuggestion.sessionId + ) { + return false; + } + if ( + previousSuggestion.kind === "text" && + nextSuggestion.kind === "text" && + (previousSuggestion.offset !== nextSuggestion.offset || + previousSuggestion.length !== nextSuggestion.length) + ) { + return false; + } + if ( + previousSuggestion.kind === "block" && + nextSuggestion.kind === "block" && + JSON.stringify(previousSuggestion.previousState) !== + JSON.stringify(nextSuggestion.previousState) + ) { + return false; + } + } + + return true; +} + +export function areAIControllerStatesEqual( + previous: AIControllerState, + next: AIControllerState, +): boolean { + if ( + previous.status !== next.status || + previous.activeSessionId !== next.activeSessionId || + previous.suggestMode !== next.suggestMode || + previous.commandMenuOpen !== next.commandMenuOpen || + previous.lastRoute !== next.lastRoute + ) { + return false; + } + + if ( + !areGenerationsEqual(previous.activeGeneration, next.activeGeneration) + ) { + return false; + } + + if ( + !areEphemeralSuggestionsEqual( + previous.ephemeralSuggestion, + next.ephemeralSuggestion, + ) + ) { + return false; + } + + return areSessionsEqual(previous.sessions, next.sessions); +} + +export function areGenerationsEqual( + previous: AIControllerState["activeGeneration"], + next: AIControllerState["activeGeneration"], +): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + + if ( + previous.id !== next.id || + previous.zoneId !== next.zoneId || + previous.blockId !== next.blockId || + previous.target !== next.target || + previous.sessionId !== next.sessionId || + previous.surface !== next.surface || + previous.prompt !== next.prompt || + previous.status !== next.status || + previous.tokenCount !== next.tokenCount || + previous.undoGroupId !== next.undoGroupId || + previous.text !== next.text || + previous.commandId !== next.commandId || + previous.contentFormat !== next.contentFormat || + previous.route !== next.route || + previous.mutationMode !== next.mutationMode || + previous.planState !== next.planState || + previous.targetKind !== next.targetKind || + !areStructuredValuesEqual( + previous.structuredPreview, + next.structuredPreview, + ) || + !areStructuredValuesEqual(previous.reviewItems, next.reviewItems) || + !areStructuredValuesEqual(previous.plan, next.plan) || + !areStructuredValuesEqual(previous.debug, next.debug) + ) { + return false; + } + + if (!areStringArraysEqual(previous.suggestionIds, next.suggestionIds)) { + return false; + } + + if (previous.steps.length !== next.steps.length) { + return false; + } + + for (let index = 0; index < previous.steps.length; index += 1) { + const previousStep = previous.steps[index]; + const nextStep = next.steps[index]; + if ( + previousStep.index !== nextStep.index || + previousStep.type !== nextStep.type || + previousStep.toolName !== nextStep.toolName || + previousStep.toolCallId !== nextStep.toolCallId || + previousStep.status !== nextStep.status || + previousStep.input !== nextStep.input || + previousStep.output !== nextStep.output + ) { + return false; + } + } + + return true; +} + +export function areSessionsEqual( + previous: readonly AISession[], + next: readonly AISession[], +): boolean { + if (previous.length !== next.length) { + return false; + } + for (let index = 0; index < previous.length; index += 1) { + const previousSession = previous[index]; + const nextSession = next[index]; + if ( + !previousSession || + !nextSession || + previousSession.id !== nextSession.id || + previousSession.surface !== nextSession.surface || + previousSession.status !== nextSession.status || + previousSession.createdAt !== nextSession.createdAt || + previousSession.updatedAt !== nextSession.updatedAt || + previousSession.activeTurnId !== nextSession.activeTurnId || + !areStructuredValuesEqual( + previousSession.target, + nextSession.target, + ) || + !areStructuredValuesEqual( + previousSession.anchor, + nextSession.anchor, + ) || + !areStructuredValuesEqual( + previousSession.contextualPrompt, + nextSession.contextualPrompt, + ) || + !areStructuredValuesEqual( + previousSession.turns, + nextSession.turns, + ) || + !areStructuredValuesEqual( + previousSession.promptHistory, + nextSession.promptHistory, + ) || + !areStringArraysEqual( + previousSession.generationIds, + nextSession.generationIds, + ) || + !areStringArraysEqual( + previousSession.pendingSuggestionIds, + nextSession.pendingSuggestionIds, + ) || + !areStringArraysEqual( + previousSession.pendingReviewItemIds, + nextSession.pendingReviewItemIds, + ) || + !areStructuredValuesEqual( + previousSession.metrics, + nextSession.metrics, + ) + ) { + return false; + } + } + return true; +} + +export function areInlineHistorySnapshotsEqual( + previous: AIInlineHistorySnapshot, + next: AIInlineHistorySnapshot, +): boolean { + return ( + previous.activeSessionId === next.activeSessionId && + previous.documentVersion === next.documentVersion && + previous.kind === next.kind && + areSessionsEqual(previous.sessions, next.sessions) + ); +} + +export function didInlineHistoryCheckpointChange( + previousState: AIControllerState, + nextState: AIControllerState, +): boolean { + return !areStructuredValuesEqual( + buildInlineHistoryCheckpoint(previousState), + buildInlineHistoryCheckpoint(nextState), + ); +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts new file mode 100644 index 0000000..a36b7e7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts @@ -0,0 +1,415 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function buildInlineHistoryCheckpoint(state: AIControllerState): { + activeSessionId: string | null; + sessions: Array<{ + id: string; + isOpen: boolean; + target: AISessionSelectionSnapshot | null; + latestSettledTurn: { + id: string; + prompt: string; + selection: AISessionSelectionSnapshot | null; + } | null; + settledTurnCount: number; + }>; +} { + const inlineSessions = state.sessions.filter( + (session) => session.surface === "inline-edit", + ); + return { + activeSessionId: state.activeSessionId ?? null, + sessions: inlineSessions.map((session) => { + const settledTurns = session.turns.filter( + (turn) => turn.status !== "streaming", + ); + const latestSettledTurn = + settledTurns[settledTurns.length - 1] ?? null; + return { + id: session.id, + isOpen: session.contextualPrompt?.composer.isOpen ?? false, + target: + session.contextualPrompt?.anchor.selectionSnapshot ?? + (session.target.kind === "selection" + ? resolveSessionSelectionSnapshot( + session.target.selection, + ) + : null), + latestSettledTurn: latestSettledTurn + ? { + id: latestSettledTurn.id, + prompt: latestSettledTurn.prompt, + selection: latestSettledTurn.selection ?? null, + } + : null, + settledTurnCount: settledTurns.length, + }; + }), + }; +} + +export function countSettledInlineTurns( + snapshot: AIInlineHistorySnapshot, + sessionId?: string | null, +): number { + if (sessionId) { + const session = snapshot.sessions.find( + (item) => item.id === sessionId && item.surface === "inline-edit", + ); + if (!session) { + return 0; + } + return session.turns.filter((turn) => turn.status !== "streaming") + .length; + } + return snapshot.sessions + .filter((session) => session.surface === "inline-edit") + .reduce( + (count, session) => + count + + session.turns.filter((turn) => turn.status !== "streaming") + .length, + 0, + ); +} + +export function hasStreamingInlineTurns( + snapshot: AIInlineHistorySnapshot, + sessionId?: string | null, +): boolean { + if (sessionId) { + const session = snapshot.sessions.find( + (item) => item.id === sessionId && item.surface === "inline-edit", + ); + return ( + session?.turns.some((turn) => turn.status === "streaming") ?? false + ); + } + return snapshot.sessions + .filter((session) => session.surface === "inline-edit") + .some((session) => + session.turns.some((turn) => turn.status === "streaming"), + ); +} + +export function resolveInlineShortcutHistoryState( + snapshot: AIInlineHistorySnapshot, + sessionId: string | null, +): AIInlineShortcutHistoryState | null { + const session = sessionId + ? (snapshot.sessions.find( + (item) => + item.id === sessionId && item.surface === "inline-edit", + ) ?? null) + : null; + if (!session) { + return { + sessionId: null, + phase: "none", + turnCount: 0, + turnId: null, + }; + } + const durableTurns = session.turns.filter( + (turn) => turn.status !== "streaming" && turn.status !== "cancelled", + ); + if (durableTurns.length === 0) { + return { + sessionId: null, + phase: "none", + turnCount: 0, + turnId: null, + }; + } + const latestTurn = durableTurns[durableTurns.length - 1] ?? null; + if (!latestTurn) { + return null; + } + if (latestTurn.status === "review") { + return { + sessionId, + phase: "review", + turnCount: durableTurns.length, + turnId: latestTurn.id, + }; + } + if (latestTurn.status === "accepted" || latestTurn.status === "rejected") { + return { + sessionId, + phase: "resolved", + turnCount: durableTurns.length, + turnId: latestTurn.id, + resolution: latestTurn.status, + }; + } + return null; +} + +export function areInlineShortcutHistoryStatesEqual( + left: AIInlineShortcutHistoryState, + right: AIInlineShortcutHistoryState, +): boolean { + return ( + left.sessionId === right.sessionId && + left.phase === right.phase && + left.turnCount === right.turnCount && + left.turnId === right.turnId && + left.resolution === right.resolution + ); +} + +export function shouldReplaceInlineShortcutWaypointRepresentative( + state: AIInlineShortcutHistoryState, + currentSnapshot: AIInlineHistorySnapshot | null, + nextSnapshot: AIInlineHistorySnapshot, +): boolean { + if (!currentSnapshot) { + return true; + } + const currentSession = state.sessionId + ? (currentSnapshot.sessions.find( + (session) => + session.id === state.sessionId && + session.surface === "inline-edit", + ) ?? null) + : null; + const nextSession = state.sessionId + ? (nextSnapshot.sessions.find( + (session) => + session.id === state.sessionId && + session.surface === "inline-edit", + ) ?? null) + : null; + if (state.phase === "review") { + const currentOpen = + currentSession?.contextualPrompt?.composer.isOpen === true; + const nextOpen = + nextSession?.contextualPrompt?.composer.isOpen === true; + if (currentOpen !== nextOpen) { + return nextOpen; + } + } + if (state.phase === "resolved") { + const currentOpen = + currentSession?.contextualPrompt?.composer.isOpen === true; + const nextOpen = + nextSession?.contextualPrompt?.composer.isOpen === true; + if (currentOpen !== nextOpen) { + return !nextOpen; + } + } + return true; +} + +export function areEphemeralSuggestionsEqual( + previous: AIControllerState["ephemeralSuggestion"], + next: AIControllerState["ephemeralSuggestion"], +): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + + return ( + previous.id === next.id && + previous.blockId === next.blockId && + previous.offset === next.offset && + previous.text === next.text && + previous.type === next.type && + previous.blockType === next.blockType && + previous.props === next.props + ); +} + +export function areStringArraysEqual( + previous: readonly string[] | undefined, + next: readonly string[] | undefined, +): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + if (previous.length !== next.length) { + return false; + } + + for (let index = 0; index < previous.length; index += 1) { + if (previous[index] !== next[index]) { + return false; + } + } + + return true; +} + +export function areStructuredValuesEqual(previous: unknown, next: unknown): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + + try { + return JSON.stringify(previous) === JSON.stringify(next); + } catch { + return false; + } +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts new file mode 100644 index 0000000..59edcf3 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts @@ -0,0 +1,344 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; + +export function buildSelectionReplacementOps( + editor: Editor, + selection: TextSelection, + insertedText: string, +): DocumentOp[] { + const range = selection.toRange(); + if (range.start.blockId === range.end.blockId) { + return [ + { + type: "replace-text", + blockId: range.start.blockId, + offset: range.start.offset, + length: range.end.offset - range.start.offset, + text: insertedText, + }, + ]; + } + const startId = range.start.blockId; + const endId = range.end.blockId; + const startText = editor.getBlock(startId)?.textContent() ?? ""; + const middleIds = range.blockRange.slice(1, -1); + const suffixDeltas = sliceInlineDeltasFromOffset( + editor.getBlock(endId)?.textDeltas() ?? [], + range.end.offset, + ); + const ops: DocumentOp[] = []; + + if (range.start.offset < startText.length) { + ops.push({ + type: "delete-text", + blockId: startId, + offset: range.start.offset, + length: startText.length - range.start.offset, + }); + } + + if (range.end.offset > 0) { + ops.push({ + type: "delete-text", + blockId: endId, + offset: 0, + length: range.end.offset, + }); + } + + for (const blockId of middleIds) { + ops.push({ + type: "delete-block", + blockId, + }); + } + + let insertionOffset = range.start.offset; + if (insertedText.length > 0) { + ops.push({ + type: "insert-text", + blockId: startId, + offset: insertionOffset, + text: insertedText, + }); + insertionOffset += insertedText.length; + } + + for (const delta of suffixDeltas) { + ops.push({ + type: "insert-text", + blockId: startId, + offset: insertionOffset, + text: delta.insert, + marks: delta.attributes, + }); + insertionOffset += delta.insert.length; + } + + ops.push({ + type: "delete-block", + blockId: endId, + }); + return ops; +} + +export function sliceInlineDeltasFromOffset( + deltas: readonly { insert: string; attributes?: Record }[], + startOffset: number, +): Array<{ insert: string; attributes?: Record }> { + const sliced: Array<{ + insert: string; + attributes?: Record; + }> = []; + let offset = 0; + for (const delta of deltas) { + const length = delta.insert.length; + if (startOffset >= offset + length) { + offset += length; + continue; + } + const localStart = Math.max(0, startOffset - offset); + const text = delta.insert.slice(localStart); + if (text.length > 0) { + sliced.push( + delta.attributes + ? { insert: text, attributes: delta.attributes } + : { insert: text }, + ); + } + offset += length; + } + return sliced; +} + +export function resolveSelectionText( + editor: Editor, + selection: TextSelection, +): string { + const range = selection.toRange(); + const blockIds = range.blockRange; + const parts = blockIds.map((blockId, index) => { + const block = editor.getBlock(blockId); + if (!block) return ""; + + let rawOffset = 0; + let resolved = ""; + const startOffset = index === 0 ? range.start.offset : 0; + const endOffset = + index === blockIds.length - 1 + ? range.end.offset + : Number.POSITIVE_INFINITY; + + for (const delta of block.textDeltas()) { + const length = delta.insert.length; + const rawStart = rawOffset; + const rawEnd = rawOffset + length; + rawOffset = rawEnd; + + if (endOffset <= rawStart || startOffset >= rawEnd) { + continue; + } + + const sliceStart = Math.max(0, startOffset - rawStart); + const sliceEnd = Math.min(length, endOffset - rawStart); + if (sliceEnd <= sliceStart) { + continue; + } + + const suggestion = delta.attributes?.suggestion as + | { action?: string } + | undefined; + if (suggestion?.action === "delete") { + continue; + } + + resolved += delta.insert.slice(sliceStart, sliceEnd); + } + + return resolved; + }); + + return parts.join("\n"); +} + +export function shouldReplaceEmptyMarkdownTarget( + block: ReturnType, +): boolean { + if (!block) { + return false; + } + + return ( + block.type === "paragraph" && + isVisuallyEmptyInlineText(block.textContent({ resolved: true })) + ); +} + +export function shouldTrimLeadingBlankBlockGenerationText( + block: ReturnType, +): boolean { + if (!block) { + return false; + } + return isVisuallyEmptyInlineText(block.textContent({ resolved: true })); +} + +export function trimLeadingBlankBlockGenerationText(text: string): string { + return text.replace(/^(?:[ \t]*\r?\n)+/, ""); +} + +export function isVisuallyEmptyInlineText(text: string): boolean { + return text.replace(/\u200B/g, "").trim().length === 0; +} diff --git a/packages/extensions/ai/src/extensionParts/generationExecution.ts b/packages/extensions/ai/src/extensionParts/generationExecution.ts new file mode 100644 index 0000000..2267c49 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/generationExecution.ts @@ -0,0 +1,364 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { getDocumentToolRuntime, EMPTY_TOOL_RUNTIME, isLocalRequestedOperation, buildSessionExecutionPrompt, routeAIRequest, getBlockAdapter, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, supportsStructuredIntent, buildPlannerPrompt, resolveExecutionMode, createDefaultSessionFastApplyMetrics, createAIStreamEvent, resolveGenerationRequestMode, trimLeadingBlankBlockGenerationText, parseStructuredPlanPreview, buildGenerationStructuredPreviewState, areStructuredValuesEqual, buildStructuredPreviewPatchOperations, compileStructuredIntentToPlan, parseStructuredPlanResult, buildDocumentMutationPlanExecution, buildStructuralReviewItems, resolvePendingInlineSelectionTarget, resolveLiveInlineSelectionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, appendUniqueString } = deps; + +import { runGenerationLoop } from "./generationExecutionLoop"; +import { finalizeGenerationExecution, handleGenerationExecutionError } from "./generationExecutionFinalize"; + +export async function executeGeneration(controller: any, input: any): Promise { + const { prompt, target, commandId, maxSteps, context } = input; + if (!controller._model) { + throw new Error("No AI model configured"); + } + + controller.cancelActiveGeneration(); + const toolRuntime = + getDocumentToolRuntime(controller._editor) ?? EMPTY_TOOL_RUNTIME; + const abortController = new AbortController(); + controller._abortController = abortController; + + const baselineSuggestionIds = new Set( + controller.getSuggestions().map((item) => item.id), + ); + const blockId = + target.type === "block" + ? target.blockId + : target.selection.toRange().start.blockId; + const requestedOperation = context?.operation ?? null; + if ( + context?.surface === "bottom-chat" && + isLocalRequestedOperation(requestedOperation) + ) { + return controller._executeLocalOperation({ + prompt, + target, + blockId, + commandId, + context, + abortController, + baselineSuggestionIds, + operation: requestedOperation, + }); + } + const requestedContentFormat = controller._resolveContentFormat( + target.type, + context?.surface, + ); + let route = routeAIRequest({ + prompt, + selection: controller._editor.selection, + blockType: controller._editor.getBlock(blockId)?.type ?? null, + blockCount: controller._editor.blockCount(), + suggestMode: controller._state.suggestMode, + target: target.type, + contentFormat: requestedContentFormat, + surface: context?.surface, + }); + let workingSet = await controller._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ); + const refinedRoute = controller._refineRouteWithWorkingSet(route, workingSet); + if (refinedRoute.lane !== route.lane) { + route = refinedRoute; + workingSet = await controller._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ); + } else { + route = refinedRoute; + } + const adapter = getBlockAdapter(route.adapterId); + const contentFormat = route.contentFormat; + let currentText = ""; + const streamingTarget = + controller._editor.internals.getSlot( + "delta-stream:target", + ) ?? null; + let blockStreamingStarted = false; + const shouldStreamDirectly = route.shouldStreamDirectly; + const selectionRange = + target.type === "selection" ? target.selection.toRange() : null; + const selectionSourceText = + target.type === "selection" + ? resolveSelectionText(controller._editor, target.selection) + : ""; + const shouldStreamSuggestedText = + route.mutationMode === "streaming-suggestions" && + route.plannerMode !== "structured" && + contentFormat === "text"; + const shouldReplaceMarkdownTarget = + context?.replaceTargetBlock === true || + (route.plannerMode !== "structured" && + contentFormat === "markdown" && + target.type === "block" && + (route.targetKind === "table" || + (context?.surface === "bottom-chat" && + shouldReplaceEmptyMarkdownTarget( + controller._editor.getBlock(blockId), + )))); + const canStreamSelectionSuggestions = + shouldStreamSuggestedText && + target.type === "selection" && + selectionRange?.start.blockId === selectionRange?.end.blockId; + const canStreamBlockSuggestions = + shouldStreamSuggestedText && target.type === "block"; + const canStreamMarkdownBlockSuggestions = + route.mutationMode === "streaming-suggestions" && + route.plannerMode !== "structured" && + contentFormat === "markdown" && + target.type === "block" && + route.applyStrategy === "markdown-full-replace" && + context?.surface === "bottom-chat"; + let streamedSuggestionInitialized = false; + let streamedSuggestionLength = 0; + let streamedMarkdownSuggestionIds: string[] = []; + let lastStreamedMarkdownPreviewText = ""; + const sessionTurnId = context?.sessionId + ? crypto.randomUUID() + : undefined; + const existingSession = + context?.sessionId != null + ? (controller._state.sessions.find( + (session) => session.id === context.sessionId, + ) ?? null) + : null; + const executionPrompt = buildSessionExecutionPrompt( + existingSession, + prompt, + ); + let shouldTrimLeadingBlankBlockText = + target.type === "block" && + shouldTrimLeadingBlankBlockGenerationText( + controller._editor.getBlock(blockId), + ); + const useStructuredIntentTransport = + adapter.transportKind !== "flow-text" && + supportsStructuredIntent(controller._model); + const generationPrompt = + useStructuredIntentTransport || + (adapter.id === "flow-markdown" && contentFormat === "markdown") + ? adapter.buildPrompt({ + prompt: executionPrompt, + targetKind: route.targetKind, + activeBlockId: blockId, + workingSet, + applyStrategy: route.applyStrategy, + }) + : route.plannerMode === "structured" + ? buildPlannerPrompt({ + prompt: executionPrompt, + targetKind: route.targetKind, + workingSet, + }) + : executionPrompt; + + const seedGeneration: GenerationState = { + id: crypto.randomUUID(), + zoneId: crypto.randomUUID(), + blockId, + target: target.type, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + prompt, + operation: requestedOperation, + status: "streaming", + tokenCount: 0, + steps: [], + undoGroupId: crypto.randomUUID(), + text: "", + commandId, + suggestionIds: [], + route: route.lane, + mutationMode: route.mutationMode, + contentFormat, + applyStrategy: route.applyStrategy, + planState: "none", + plan: null, + structuredIntent: null, + reviewItems: [], + structuredPreview: null, + targetKind: route.targetKind, + blockClass: route.blockClass, + adapterId: route.adapterId, + transportKind: route.transportKind, + mutationReceipt: null, + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + routeConfidence: workingSet?.routeConfidence, + structured: { + plannerMode: route.plannerMode, + executionMode: resolveExecutionMode(route.mutationMode), + targetKind: route.targetKind, + validationIssueCount: 0, + }, + fastApply: { + attempted: false, + succeeded: false, + }, + }, + }; + if (context?.sessionId) { + const nextSelectionSnapshot = + target.type === "selection" + ? resolveSessionSelectionSnapshot(target.selection) + : undefined; + controller._updateSession(context.sessionId, { + status: "streaming", + operation: requestedOperation, + activeTurnId: sessionTurnId, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : resolveSessionAnchor(controller._editor.selection), + generationIds: appendUniqueString( + existingSession?.generationIds ?? [], + seedGeneration.id, + ), + promptHistory: [ + ...(existingSession?.promptHistory ?? []), + { + id: crypto.randomUUID(), + prompt, + createdAt: Date.now(), + generationId: seedGeneration.id, + operation: requestedOperation ?? undefined, + }, + ], + turns: sessionTurnId + ? [ + ...(existingSession?.turns ?? []), + { + id: sessionTurnId, + prompt, + createdAt: Date.now(), + undoGroupId: seedGeneration.undoGroupId, + generationId: seedGeneration.id, + target: target.type, + operation: requestedOperation ?? undefined, + status: "streaming", + suggestionIds: [], + reviewItemIds: [], + generatedBlockIds: [], + structuredPreview: null, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : undefined, + selection: + target.type === "selection" + ? resolveSessionSelectionSnapshot( + target.selection, + ) + : undefined, + }, + ] + : existingSession?.turns, + contextualPrompt: existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: + target.type === "selection" + ? { + ...existingSession.contextualPrompt + .anchor, + selectionSnapshot: + nextSelectionSnapshot, + focusBlockId: + target.selection.toRange().start + .blockId, + status: "valid", + } + : existingSession.contextualPrompt.anchor, + composer: { + ...existingSession.contextualPrompt.composer, + draftPrompt: "", + isSubmitting: true, + isOpen: true, + openReason: "user", + }, + } + : undefined, + }); + } + controller._setState({ + status: "thinking", + activeGeneration: seedGeneration, + commandMenuOpen: false, + lastRoute: route.lane, + activeSessionId: context?.sessionId ?? controller._state.activeSessionId, + }); + let currentStructuredPreview: GenerationStructuredPreviewState | null = + null; + let currentStructuredIntent: GenerationState["structuredIntent"] = null; + let currentMutationReceipt: AIMutationReceipt | null = null; + controller._setStreamEvents([ + createAIStreamEvent(seedGeneration, { + type: "generation-start", + prompt, + target: target.type, + }), + createAIStreamEvent(seedGeneration, { + type: "status", + status: "thinking", + }), + ]); + const state = { + prompt, + target, + commandId, + maxSteps, + context, + toolRuntime, + abortController, + baselineSuggestionIds, + blockId, + requestedOperation, + route, + workingSet, + adapter, + contentFormat, + currentText, + streamingTarget, + blockStreamingStarted, + shouldStreamDirectly, + selectionRange, + selectionSourceText, + shouldReplaceMarkdownTarget, + canStreamSelectionSuggestions, + canStreamBlockSuggestions, + canStreamMarkdownBlockSuggestions, + streamedSuggestionInitialized, + streamedSuggestionLength, + streamedMarkdownSuggestionIds, + lastStreamedMarkdownPreviewText, + sessionTurnId, + existingSession, + executionPrompt, + shouldTrimLeadingBlankBlockText, + useStructuredIntentTransport, + generationPrompt, + seedGeneration, + currentStructuredPreview, + currentStructuredIntent, + currentMutationReceipt, + }; + try { + const result = await runGenerationLoop(controller, state); + return finalizeGenerationExecution(controller, state, result); + } catch (error) { + return handleGenerationExecutionError(controller, state, error); + } +} diff --git a/packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts b/packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts new file mode 100644 index 0000000..763ae00 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts @@ -0,0 +1,367 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { getDocumentToolRuntime, EMPTY_TOOL_RUNTIME, isLocalRequestedOperation, routeAIRequest, getBlockAdapter, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, supportsStructuredIntent, buildPlannerPrompt, resolveExecutionMode, createDefaultSessionFastApplyMetrics, createAIStreamEvent, resolveGenerationRequestMode, trimLeadingBlankBlockGenerationText, parseStructuredPlanPreview, buildGenerationStructuredPreviewState, areStructuredValuesEqual, buildStructuredPreviewPatchOperations, compileStructuredIntentToPlan, parseStructuredPlanResult, buildDocumentMutationPlanExecution, buildStructuralReviewItems, resolvePendingInlineSelectionTarget, resolveLiveInlineSelectionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, appendUniqueString } = deps; + +export function finalizeGenerationExecution(controller: any, state: any, result: any): any { + const { target, canStreamSelectionSuggestions, route, context, selectionSourceText, seedGeneration, contentFormat, shouldStreamDirectly, canStreamBlockSuggestions, canStreamMarkdownBlockSuggestions, workingSet, useStructuredIntentTransport, adapter, blockId, requestedOperation, sessionTurnId, commandId, baselineSuggestionIds, shouldReplaceMarkdownTarget } = state; + if ( + target.type === "selection" && + state.currentText.length > 0 && + !canStreamSelectionSuggestions + ) { + state.currentMutationReceipt = controller._commitSelectionRewrite( + target.selection, + state.currentText, + route.mutationMode, + context?.sessionId, + ); + controller._inlineCompletion.dismissSuggestion(); + } else if ( + target.type === "selection" && + state.currentText.length > 0 && + canStreamSelectionSuggestions + ) { + controller._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + contextChars: selectionSourceText.length, + diffChars: state.currentText.length, + }); + } else if ( + target.type === "block" && + state.currentText.length > 0 && + !shouldStreamDirectly && + !canStreamBlockSuggestions && + !canStreamMarkdownBlockSuggestions && + route.plannerMode !== "structured" + ) { + state.currentMutationReceipt = controller._commitBufferedBlockGeneration( + target.blockId, + state.currentText, + route.mutationMode, + contentFormat, + context?.sessionId, + { + applyStrategy: route.applyStrategy, + insertionOffset: target.offset, + workingSet, + replaceTargetBlock: shouldReplaceMarkdownTarget, + replaceBlockIds: context?.replaceBlockIds, + }, + ); + controller._inlineCompletion.dismissSuggestion(); + } + + const suggestionIds = controller.getSuggestions() + .map((item) => item.id) + .filter((id) => !baselineSuggestionIds.has(id)); + const structuredPlanResult = + route.plannerMode === "structured" && + !useStructuredIntentTransport + ? parseStructuredPlanResult(state.currentText, route.targetKind) + : null; + const structuredIntentResolution = useStructuredIntentTransport + ? (adapter.resolveResult?.({ + value: state.currentStructuredIntent, + targetKind: route.targetKind, + activeBlockId: blockId, + }) ?? null) + : null; + const structuredIntentResult = + structuredIntentResolution?.parseResult ?? null; + const structuredIntentCompilation = + structuredIntentResolution?.compilation ?? null; + const resolvedStructuredPlan = + structuredIntentCompilation?.plan ?? + structuredPlanResult?.plan ?? + null; + const planExecution = resolvedStructuredPlan + ? buildDocumentMutationPlanExecution( + controller._editor, + resolvedStructuredPlan, + ) + : null; + const reviewItems = + resolvedStructuredPlan && + route.mutationMode !== "direct-stream" && + (!planExecution || !planExecution.reviewSafe) + ? buildStructuralReviewItems( + controller._editor, + resolvedStructuredPlan, + ) + : []; + + if ( + resolvedStructuredPlan && + planExecution && + planExecution.issues.length === 0 + ) { + state.currentMutationReceipt = controller._commitStructuredPlan( + planExecution.ops, + planExecution.reviewSafe, + route.mutationMode, + route.adapterId, + route.blockClass, + route.transportKind, + ); + } + if (!state.currentMutationReceipt) { + state.currentMutationReceipt = controller._buildFallbackMutationReceipt({ + currentText: state.currentText, + suggestionIds, + reviewItems, + planExecutionIssueCount: planExecution?.issues.length ?? 0, + adapterId: route.adapterId, + blockClass: route.blockClass, + transportKind: route.transportKind, + }); + } + const structuredDebug = { + plannerMode: route.plannerMode, + executionMode: resolveExecutionMode(route.mutationMode), + targetKind: route.targetKind, + validationIssueCount: + (structuredPlanResult?.issues.length ?? 0) + + (structuredIntentResult?.issues.length ?? 0) + + (structuredIntentCompilation?.issues.length ?? 0) + + (planExecution?.issues.length ?? 0), + }; + const resolvedDebug = + controller._state.activeGeneration?.id === seedGeneration.id + ? (controller._state.activeGeneration.debug ?? + result.debug ?? + seedGeneration.debug!) + : (result.debug ?? seedGeneration.debug!); + const resolvedPlanState: GenerationState["planState"] = + planExecution && planExecution.issues.length > 0 + ? "rejected" + : structuredIntentResult?.intentState === "validated" && + (structuredIntentCompilation?.issues.length ?? 0) === + 0 + ? "validated" + : structuredIntentResult?.intentState === "drafted" + ? "drafted" + : (structuredPlanResult?.planState ?? + seedGeneration.planState); + + const finalGeneration: GenerationState = { + ...result, + blockId, + target: target.type, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + commandId, + text: state.currentText, + suggestionIds, + route: route.lane, + mutationMode: route.mutationMode, + contentFormat, + planState: resolvedPlanState, + plan: resolvedStructuredPlan, + structuredIntent: + structuredIntentResult?.intent ?? + state.currentStructuredIntent ?? + null, + reviewItems, + structuredPreview: resolvedStructuredPlan + ? buildGenerationStructuredPreviewState(controller._editor, { + planState: + planExecution && + planExecution.issues.length === 0 + ? "validated" + : "drafted", + plan: resolvedStructuredPlan, + }) + : state.currentStructuredPreview, + targetKind: route.targetKind, + blockClass: route.blockClass, + adapterId: route.adapterId, + transportKind: route.transportKind, + mutationReceipt: state.currentMutationReceipt, + debug: { + ...resolvedDebug, + structured: structuredDebug, + }, + }; + controller._abortController = null; + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "generation-finish", + status: finalGeneration.status, + text: state.currentText, + }), + ); + controller._setState({ + status: "idle", + activeGeneration: finalGeneration, + }); + if (context?.sessionId) { + const structuredPreviewEvents = controller.getStreamEvents().filter( + (event) => + event.type === "structured-preview" && + event.sessionId === context.sessionId, + ); + const lastStructuredPreviewEvent = + structuredPreviewEvents[structuredPreviewEvents.length - 1]; + const refreshedInlineReviewSelectionTarget = + context?.surface === "inline-edit" && + suggestionIds.length > 0 + ? (resolvePendingInlineSelectionTarget( + controller._editor, + requestedOperation ?? undefined, + suggestionIds, + ) ?? resolveLiveInlineSelectionTarget(controller._editor)) + : null; + if (sessionTurnId) { + const receiptEvidence = state.currentMutationReceipt?.evidence; + const generatedBlockIds = receiptEvidence + ? [ + ...new Set([ + ...receiptEvidence.affectedBlockIds, + ...receiptEvidence.createdBlockIds, + ]), + ] + : []; + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: + suggestionIds.length > 0 || reviewItems.length > 0 + ? "review" + : finalGeneration.status === "complete" + ? "complete" + : finalGeneration.status, + suggestionIds, + reviewItemIds: reviewItems.map((item) => item.id), + generatedBlockIds, + structuredPreview: + finalGeneration.structuredPreview ?? null, + anchor: refreshedInlineReviewSelectionTarget + ? resolveSessionAnchor( + refreshedInlineReviewSelectionTarget.selection, + ) + : undefined, + selection: refreshedInlineReviewSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineReviewSelectionTarget.selection, + ) + : undefined, + }); + } + const resolvedGenerationDebug = + controller._state.activeGeneration?.id === finalGeneration.id + ? controller._state.activeGeneration.debug + : finalGeneration.debug; + controller._recordSessionFastApplyMetrics( + context.sessionId, + resolvedGenerationDebug?.fastApply, + ); + controller._updateSession(context.sessionId, { + status: + finalGeneration.status === "complete" + ? "complete" + : finalGeneration.status, + pendingSuggestionIds: suggestionIds, + pendingReviewItemIds: reviewItems.map((item) => item.id), + metrics: { + ...(controller._state.sessions.find( + (session) => session.id === context.sessionId, + )?.metrics ?? { + streamEventCount: 0, + patchCount: 0, + fastApply: createDefaultSessionFastApplyMetrics(), + }), + firstTokenMs: + resolvedGenerationDebug?.firstVisibleTextMs ?? + undefined, + totalMs: + resolvedGenerationDebug?.messageAssemblyLatencyMs != + null + ? resolvedGenerationDebug.messageAssemblyLatencyMs + + (resolvedGenerationDebug.toolExecutionMs ?? + 0) + : undefined, + toolMs: + resolvedGenerationDebug?.toolExecutionMs ?? + undefined, + streamEventCount: controller._streamEvents.filter( + (event) => event.sessionId === context.sessionId, + ).length, + patchCount: + lastStructuredPreviewEvent?.type === + "structured-preview" + ? lastStructuredPreviewEvent.patches.length + : 0, + }, + }); + } + + if (finalGeneration.status === "complete") { + controller._editor.internals.emit("diagnostic", { + level: "info", + source: "ai", + code: "GENERATION_COMPLETE", + message: "AI generation completed", + blockId, + generationId: finalGeneration.id, + }); + } + + return finalGeneration; +} + +export function handleGenerationExecutionError(controller: any, state: any, error: unknown): any { + const { seedGeneration, blockId, context, sessionTurnId, commandId, target, abortController, route, streamingTarget, prompt } = state; + const isStaleWorkingSet = + error instanceof Error && error.name === "StaleWorkingSetError"; + const failedGeneration: GenerationState = { + ...(controller._state.activeGeneration ?? seedGeneration), + blockId, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + prompt, + commandId, + text: state.currentText, + status: + abortController.signal.aborted || isStaleWorkingSet + ? "cancelled" + : "error", + targetKind: route.targetKind, + }; + controller._abortController = null; + controller._inlineCompletion.dismissSuggestion(); + if (target.type === "block" && state.blockStreamingStarted) { + streamingTarget?.endStreaming( + abortController.signal.aborted ? "cancelled" : "error", + ); + state.blockStreamingStarted = false; + } + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "generation-finish", + status: failedGeneration.status, + text: state.currentText, + }), + ); + controller._setState({ + status: "idle", + activeGeneration: failedGeneration, + }); + if (context?.sessionId) { + if (sessionTurnId) { + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: failedGeneration.status, + reviewItemIds: [], + structuredPreview: null, + }); + } + controller._updateSession(context.sessionId, { + status: failedGeneration.status, + }); + } + if (abortController.signal.aborted || isStaleWorkingSet) { + return failedGeneration; + } + throw error; +} diff --git a/packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts b/packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts new file mode 100644 index 0000000..9170050 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts @@ -0,0 +1,381 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { getDocumentToolRuntime, EMPTY_TOOL_RUNTIME, isLocalRequestedOperation, routeAIRequest, getBlockAdapter, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, supportsStructuredIntent, buildPlannerPrompt, resolveExecutionMode, createDefaultSessionFastApplyMetrics, createAIStreamEvent, resolveGenerationRequestMode, trimLeadingBlankBlockGenerationText, parseStructuredPlanPreview, buildGenerationStructuredPreviewState, areStructuredValuesEqual, buildStructuredPreviewPatchOperations, compileStructuredIntentToPlan, parseStructuredPlanResult, buildDocumentMutationPlanExecution, buildStructuralReviewItems, resolvePendingInlineSelectionTarget, resolveLiveInlineSelectionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, appendUniqueString, runAgenticLoop } = deps; + +export async function runGenerationLoop(controller: any, state: any): Promise { + const { route, toolRuntime, generationPrompt, blockId, seedGeneration, maxSteps, target, shouldStreamDirectly, streamingTarget, selectionRange, canStreamSelectionSuggestions, canStreamBlockSuggestions, canStreamMarkdownBlockSuggestions, context, baselineSuggestionIds, shouldReplaceMarkdownTarget, useStructuredIntentTransport, adapter, abortController, workingSet, sessionTurnId } = state; + const result = await runAgenticLoop({ + model: controller._model, + editor: controller._editor, + toolRuntime: route.allowToolUse + ? toolRuntime + : EMPTY_TOOL_RUNTIME, + prompt: generationPrompt, + blockId, + generationId: seedGeneration.id, + zoneId: seedGeneration.zoneId, + maxSteps: route.allowToolUse + ? (maxSteps ?? controller._maxAgenticSteps) + : 1, + signal: abortController.signal, + requestMode: resolveGenerationRequestMode({ + ...context, + targetType: target.type, + }), + workingSet, + validateWorkingSet: (activeWorkingSet) => + controller._validateWorkingSet(route, target, activeWorkingSet), + refreshWorkingSet: async () => + controller._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ), + onStatusChange: (status) => { + controller._setState({ status }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "status", + status, + }), + ); + }, + onStep: (step) => { + const active = controller._state.activeGeneration; + if (!active) return; + controller._setState({ + activeGeneration: { + ...active, + steps: [...active.steps, step], + }, + }); + }, + onTextDelta: (delta) => { + const nextDelta = + target.type === "block" && + state.shouldTrimLeadingBlankBlockText + ? trimLeadingBlankBlockGenerationText(delta) + : delta; + if ( + state.shouldTrimLeadingBlankBlockText && + nextDelta.length > 0 + ) { + state.shouldTrimLeadingBlankBlockText = false; + } + if (nextDelta.length === 0) { + return; + } + state.currentText += nextDelta; + if (target.type === "block" && shouldStreamDirectly) { + streamingTarget?.appendDelta(nextDelta); + } else if ( + canStreamSelectionSuggestions && + selectionRange + ) { + if (!state.streamedSuggestionInitialized) { + controller._applySuggestedAIOps( + [ + { + type: "replace-text", + blockId: selectionRange.start.blockId, + offset: selectionRange.start.offset, + length: + selectionRange.end.offset - + selectionRange.start.offset, + text: nextDelta, + }, + ], + context?.sessionId, + { undoGroupId: seedGeneration.undoGroupId }, + ); + state.streamedSuggestionInitialized = true; + state.streamedSuggestionLength = nextDelta.length; + } else if (nextDelta.length > 0) { + controller._applySuggestedAIOps( + [ + { + type: "insert-text", + blockId: selectionRange.start.blockId, + offset: + selectionRange.end.offset + + state.streamedSuggestionLength, + text: nextDelta, + }, + ], + context?.sessionId, + { undoGroupId: seedGeneration.undoGroupId }, + ); + state.streamedSuggestionLength += nextDelta.length; + } + } else if ( + canStreamBlockSuggestions && + target.type === "block" + ) { + if (nextDelta.length > 0) { + controller._applySuggestedAIOps( + [ + { + type: "insert-text", + blockId: target.blockId, + offset: + target.offset + + state.streamedSuggestionLength, + text: nextDelta, + }, + ], + context?.sessionId, + { undoGroupId: seedGeneration.undoGroupId }, + ); + state.streamedSuggestionLength += nextDelta.length; + } + } else if ( + canStreamMarkdownBlockSuggestions && + target.type === "block" + ) { + const previewRefresh = + controller._refreshStreamingMarkdownBlockPreview( + target.blockId, + state.currentText, + route.mutationMode, + context?.sessionId, + baselineSuggestionIds, + state.streamedMarkdownSuggestionIds, + state.lastStreamedMarkdownPreviewText, + shouldReplaceMarkdownTarget, + context?.replaceBlockIds, + ); + state.streamedMarkdownSuggestionIds = + previewRefresh.suggestionIds; + state.lastStreamedMarkdownPreviewText = + previewRefresh.normalizedText; + } else if (target.type === "selection") { + controller._inlineCompletion.showSuggestion({ + id: seedGeneration.id, + blockId: blockId, + offset: target.selection.toRange().start.offset, + text: state.currentText, + type: "inline", + }); + } + const active = controller._state.activeGeneration; + if (!active) return; + controller._setState({ + activeGeneration: { + ...active, + text: state.currentText, + status: "streaming", + }, + }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "text-delta", + delta: nextDelta, + text: state.currentText, + }), + ); + if ( + route.plannerMode === "structured" && + !useStructuredIntentTransport + ) { + const previewResult = parseStructuredPlanPreview( + state.currentText, + route.targetKind, + ); + if (previewResult?.plan) { + const nextStructuredPreview = + buildGenerationStructuredPreviewState( + controller._editor, + { + planState: + previewResult.planState === + "validated" + ? "validated" + : "drafted", + plan: previewResult.plan, + }, + ); + if ( + !areStructuredValuesEqual( + state.currentStructuredPreview, + nextStructuredPreview, + ) + ) { + const patches = + buildStructuredPreviewPatchOperations( + state.currentStructuredPreview, + nextStructuredPreview, + ); + state.currentStructuredPreview = + nextStructuredPreview; + controller._resolveActiveGeneration({ + structuredPreview: nextStructuredPreview, + }); + if (context?.sessionId && sessionTurnId) { + controller._updateSessionTurn( + context.sessionId, + sessionTurnId, + { + reviewItemIds: + nextStructuredPreview.reviewItems.map( + (item) => item.id, + ), + structuredPreview: + nextStructuredPreview, + }, + ); + } + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "structured-preview", + preview: nextStructuredPreview, + patches, + }), + ); + } + } + } + }, + onStructuredData: (event) => { + if (!useStructuredIntentTransport) { + return; + } + const previewResult = + adapter.parsePreview?.({ + value: event.data, + targetKind: route.targetKind, + activeBlockId: blockId, + }) ?? null; + if (!previewResult?.intent) { + return; + } + state.currentStructuredIntent = previewResult.intent; + const compilation = compileStructuredIntentToPlan( + previewResult.intent, + { + activeBlockId: blockId, + }, + ); + if (!compilation.plan) { + return; + } + const nextStructuredPreview = + buildGenerationStructuredPreviewState(controller._editor, { + planState: + previewResult.intentState === "validated" && + compilation.issues.length === 0 + ? "validated" + : "drafted", + plan: compilation.plan, + }); + if ( + areStructuredValuesEqual( + state.currentStructuredPreview, + nextStructuredPreview, + ) + ) { + return; + } + const patches = buildStructuredPreviewPatchOperations( + state.currentStructuredPreview, + nextStructuredPreview, + ); + state.currentStructuredPreview = nextStructuredPreview; + controller._resolveActiveGeneration({ + structuredIntent: previewResult.intent, + structuredPreview: nextStructuredPreview, + }); + if (context?.sessionId && sessionTurnId) { + controller._updateSessionTurn( + context.sessionId, + sessionTurnId, + { + reviewItemIds: + nextStructuredPreview.reviewItems.map( + (item) => item.id, + ), + structuredPreview: nextStructuredPreview, + }, + ); + } + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "app-partial", + data: event.data, + final: event.final, + }), + ); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "structured-preview", + preview: nextStructuredPreview, + patches, + }), + ); + }, + onToolCall: (event) => { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + }), + ); + }, + onToolOutput: (event) => { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "tool-output", + toolCallId: event.toolCallId, + toolName: event.toolName, + part: event.part, + output: event.output, + }), + ); + }, + onToolResult: (event) => { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "tool-result", + toolCallId: event.toolCallId, + toolName: event.toolName, + output: event.output, + state: event.state, + }), + ); + }, + onDebug: (debug) => { + const active = controller._state.activeGeneration; + if (!active) return; + controller._setState({ + activeGeneration: { + ...active, + debug, + }, + }); + }, + onStreamingStart: (zoneId, targetBlockId) => { + if ( + target.type !== "block" || + !shouldStreamDirectly || + state.blockStreamingStarted + ) + return; + streamingTarget?.beginStreaming(zoneId, targetBlockId); + state.blockStreamingStarted = true; + }, + onStreamingEnd: (status) => { + if ( + target.type !== "block" || + !shouldStreamDirectly || + !state.blockStreamingStarted + ) + return; + streamingTarget?.endStreaming(status); + state.blockStreamingStarted = false; + }, + }); + return result; +} diff --git a/packages/extensions/ai/src/extensionParts/localOperationExecution.ts b/packages/extensions/ai/src/extensionParts/localOperationExecution.ts new file mode 100644 index 0000000..91fad70 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/localOperationExecution.ts @@ -0,0 +1,457 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { resolveLocalOperationContentFormat, buildSessionExecutionPrompt, resolveSessionSelectionSnapshot, resolveSessionAnchor, appendUniqueString, createAIStreamEvent, resolveGenerationRequestMode, buildMutationReceipt } = deps; +import { finalizeLocalOperationExecution } from "./localOperationExecutionFinalize"; + +export async function executeLocalOperation(controller: any, input: any): Promise { + const { + prompt, + target, + blockId, + commandId, + context, + abortController, + baselineSuggestionIds, + operation, + } = input; + const sessionTurnId = context?.sessionId + ? crypto.randomUUID() + : undefined; + const mutationMode: NonNullable = + "persistent-suggestions"; + const contentFormat = resolveLocalOperationContentFormat( + controller._editor, + operation, + controller._resolveContentFormat("block", context?.surface), + ); + const streamsMarkdownSelectionPreview = + operation.kind === "rewrite-selection" && + operation.target.kind === "scoped-range" && + contentFormat === "markdown" && + operation.target.blockIds.length > 0; + const applyStrategy: AIApplyStrategy | undefined = + (operation.kind === "rewrite-block" || + streamsMarkdownSelectionPreview || + (operation.kind === "document-transform" && + operation.target.kind === "document" && + (operation.target.placement === "replace-blocks" || + operation.target.placement === + "replace-empty-block"))) && + contentFormat === "markdown" + ? "markdown-full-replace" + : undefined; + const seedGeneration: GenerationState = { + id: crypto.randomUUID(), + zoneId: crypto.randomUUID(), + blockId, + target: target.type, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + prompt, + operation, + status: "streaming", + tokenCount: 0, + steps: [], + undoGroupId: crypto.randomUUID(), + text: "", + commandId, + suggestionIds: [], + route: + operation.kind === "rewrite-selection" + ? "selection-rewrite" + : operation.kind === "continue-block" + ? "cursor-context" + : "context-first", + mutationMode, + contentFormat, + applyStrategy, + planState: "none", + plan: null, + structuredIntent: null, + reviewItems: [], + structuredPreview: null, + targetKind: undefined, + blockClass: "flow", + adapterId: "flow-markdown", + transportKind: "flow-text", + mutationReceipt: null, + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + const existingSession = + context?.sessionId != null + ? (controller._state.sessions.find( + (session) => session.id === context.sessionId, + ) ?? null) + : null; + const executionPrompt = buildSessionExecutionPrompt( + existingSession, + prompt, + ); + + if (context?.sessionId) { + const nextSelectionSnapshot = + target.type === "selection" + ? resolveSessionSelectionSnapshot(target.selection) + : undefined; + controller._updateSession(context.sessionId, { + status: "streaming", + operation, + activeTurnId: sessionTurnId, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : resolveSessionAnchor(controller._editor.selection), + generationIds: appendUniqueString( + existingSession?.generationIds ?? [], + seedGeneration.id, + ), + promptHistory: [ + ...(existingSession?.promptHistory ?? []), + { + id: crypto.randomUUID(), + prompt, + createdAt: Date.now(), + generationId: seedGeneration.id, + operation, + }, + ], + turns: sessionTurnId + ? [ + ...(existingSession?.turns ?? []), + { + id: sessionTurnId, + prompt, + createdAt: Date.now(), + undoGroupId: seedGeneration.undoGroupId, + generationId: seedGeneration.id, + target: target.type, + operation, + status: "streaming", + suggestionIds: [], + reviewItemIds: [], + generatedBlockIds: [], + structuredPreview: null, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : undefined, + selection: + target.type === "selection" + ? resolveSessionSelectionSnapshot( + target.selection, + ) + : undefined, + }, + ] + : existingSession?.turns, + contextualPrompt: existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: + target.type === "selection" + ? { + ...existingSession.contextualPrompt + .anchor, + selectionSnapshot: + nextSelectionSnapshot, + focusBlockId: + target.selection.toRange().start + .blockId, + status: "valid", + } + : existingSession.contextualPrompt.anchor, + composer: { + ...existingSession.contextualPrompt.composer, + draftPrompt: "", + isSubmitting: true, + isOpen: true, + openReason: "user", + }, + } + : undefined, + }); + } + + controller._setState({ + status: "thinking", + activeGeneration: seedGeneration, + commandMenuOpen: false, + lastRoute: seedGeneration.route, + activeSessionId: context?.sessionId ?? controller._state.activeSessionId, + }); + controller._setStreamEvents([ + createAIStreamEvent(seedGeneration, { + type: "generation-start", + prompt, + target: target.type, + }), + createAIStreamEvent(seedGeneration, { + type: "status", + status: "thinking", + }), + ]); + + let currentText = ""; + let currentMutationReceipt: AIMutationReceipt | null = null; + let sawStructuredFinalFrame = false; + let streamedSelectionSuggestionIds: string[] = []; + let lastStreamedSelectionPreviewText = ""; + const updatePreview = (text: string, phase: "preview" | "final") => { + currentText = text; + const nextStatus = + phase === "preview" && text.length > 0 + ? "writing" + : controller._state.status; + if (phase === "preview" && text.length > 0) { + controller._setState({ status: "writing" }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "status", + status: "writing", + }), + ); + } + controller._resolveActiveGeneration({ + text, + status: "streaming", + operation, + }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "operation", + operation, + phase, + text, + }), + ); + void nextStatus; + }; + + try { + const stream = controller._model!.stream({ + messages: [{ role: "user", content: executionPrompt }], + tools: [], + signal: abortController.signal, + requestMode: resolveGenerationRequestMode({ + ...context, + targetType: target.type, + operation, + }), + operation, + sessionId: context?.sessionId, + turnId: sessionTurnId, + generationId: seedGeneration.id, + }); + + for await (const event of stream) { + if (abortController.signal.aborted) { + break; + } + + if (event.type === "error") { + throw event.error; + } + + if (event.type === "conflict") { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "operation", + operation, + phase: "conflict", + reason: event.reason, + }), + ); + throw new Error(event.reason); + } + + if (event.type === "text-delta") { + if ( + operation.kind === "document-transform" || + streamsMarkdownSelectionPreview + ) { + currentText += event.delta; + if ( + streamsMarkdownSelectionPreview && + operation.target.kind === "scoped-range" + ) { + updatePreview(currentText, "preview"); + const previewRefresh = + controller._refreshStreamingMarkdownBlockPreview( + operation.target.blockIds?.[0] ?? + operation.target.anchor.blockId, + currentText, + mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedSelectionSuggestionIds, + lastStreamedSelectionPreviewText, + true, + operation.target.blockIds, + ); + streamedSelectionSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedSelectionPreviewText = + previewRefresh.normalizedText; + } + continue; + } + throw new Error( + "Local AI operations must stream typed operation payloads, not raw text deltas.", + ); + } + + if ( + event.type === "replace-preview" || + event.type === "insert-preview" + ) { + updatePreview(event.text, "preview"); + if ( + streamsMarkdownSelectionPreview && + operation.target.kind === "scoped-range" + ) { + const previewRefresh = + controller._refreshStreamingMarkdownBlockPreview( + operation.target.blockIds?.[0] ?? + operation.target.anchor.blockId, + event.text, + mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedSelectionSuggestionIds, + lastStreamedSelectionPreviewText, + true, + operation.target.blockIds, + ); + streamedSelectionSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedSelectionPreviewText = + previewRefresh.normalizedText; + } + continue; + } + + if ( + event.type === "replace-final" || + event.type === "insert-final" + ) { + sawStructuredFinalFrame = true; + updatePreview(event.text, "final"); + if ( + streamsMarkdownSelectionPreview && + operation.target.kind === "scoped-range" + ) { + controller._rejectPreviewSuggestions( + streamedSelectionSuggestionIds, + ); + streamedSelectionSuggestionIds = []; + lastStreamedSelectionPreviewText = ""; + } + currentMutationReceipt = + controller._commitRequestedOperationResult( + operation, + event.text, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); + continue; + } + + if (event.type === "done") { + break; + } + } + + if ( + !sawStructuredFinalFrame && + currentText.length > 0 && + operation.kind !== "document-transform" && + !streamsMarkdownSelectionPreview + ) { + throw new Error( + "Local AI operations must return a validated final payload before they can be applied.", + ); + } + if ( + !sawStructuredFinalFrame && + currentText.length > 0 && + operation.kind === "document-transform" + ) { + currentMutationReceipt = controller._commitRequestedOperationResult( + operation, + currentText, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); + } else if ( + !sawStructuredFinalFrame && + currentText.length > 0 && + streamsMarkdownSelectionPreview + ) { + controller._rejectPreviewSuggestions(streamedSelectionSuggestionIds); + streamedSelectionSuggestionIds = []; + lastStreamedSelectionPreviewText = ""; + currentMutationReceipt = controller._commitRequestedOperationResult( + operation, + currentText, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); + } + return finalizeLocalOperationExecution(controller, { + context, + sessionTurnId, + operation, + currentText, + currentMutationReceipt, + seedGeneration, + abortController, + baselineSuggestionIds, + }); + controller._setState({ + status: "idle", + activeGeneration: { + ...seedGeneration, + text: currentText, + status: abortController.signal.aborted + ? "cancelled" + : "error", + }, + }); + if (context?.sessionId) { + if (sessionTurnId) { + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: abortController.signal.aborted + ? "cancelled" + : "error", + }); + } + controller._updateSession(context.sessionId, { + status: abortController.signal.aborted + ? "cancelled" + : "error", + }); + } + throw error; + } finally { + if (controller._abortController === abortController) { + controller._abortController = null; + } + } +} diff --git a/packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts b/packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts new file mode 100644 index 0000000..0a524f7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts @@ -0,0 +1,75 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { buildMutationReceipt, createAIStreamEvent } = deps; + +export function finalizeLocalOperationExecution(controller: any, state: any): any { + const { context, sessionTurnId, operation, currentText, currentMutationReceipt, seedGeneration, abortController, baselineSuggestionIds } = state; + const suggestionIds = controller.getSuggestions() + .map((item) => item.id) + .filter((id) => !baselineSuggestionIds.has(id)); + const mutationReceipt = + currentMutationReceipt ?? + buildMutationReceipt({ + status: currentText.length > 0 ? "noop" : "noop", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + const finalStatus = abortController.signal.aborted + ? "cancelled" + : "complete"; + controller._setState({ + status: "idle", + activeGeneration: { + ...seedGeneration, + text: currentText, + status: finalStatus, + suggestionIds, + mutationReceipt, + }, + }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "generation-finish", + status: finalStatus, + text: currentText, + }), + ); + if (context?.sessionId) { + if (sessionTurnId) { + const localReceiptEvidence = mutationReceipt?.evidence; + const localGeneratedBlockIds = localReceiptEvidence + ? [ + ...new Set([ + ...localReceiptEvidence.affectedBlockIds, + ...localReceiptEvidence.createdBlockIds, + ]), + ] + : operation.kind === "rewrite-selection" && + operation.target.kind === "scoped-range" + ? [...operation.target.blockIds] + : []; + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: + finalStatus === "cancelled" + ? "cancelled" + : "complete", + suggestionIds, + generatedBlockIds: localGeneratedBlockIds, + }); + } + controller._updateSession(context.sessionId, { + status: + finalStatus === "cancelled" ? "cancelled" : "complete", + pendingSuggestionIds: suggestionIds, + pendingReviewItemIds: [], + }); + } + return { + ...seedGeneration, + text: currentText, + status: finalStatus, + suggestionIds, + mutationReceipt, + }; +} diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts new file mode 100644 index 0000000..4fb1667 --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { createPlanExecutorEditor } from "./planExecutor.testUtils"; + +describe("document mutation plan executor", () => { + it("builds replace-text ops for text edit plans", () => { + const editor = createPlanExecutorEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "text_edit", + target: { + blockId, + range: { + startOffset: 6, + endOffset: 11, + }, + }, + operation: "replace", + text: "planet", + }); + + expect(execution.reviewSafe).toBe(true); + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "replace-text", + blockId, + offset: 6, + length: 5, + text: "planet", + }, + ]); + }); + + it("builds native ops for flow patch plans", () => { + const editor = createPlanExecutorEditor(); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [{ + type: "replace-text", + blockId: firstBlockId, + offset: 0, + length: 0, + text: "Alpha", + }], + { origin: "system" }, + ); + editor.apply( + [{ + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Bravo", + }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am updating the current paragraph and inserting a heading after it.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstBlockId}`, + edits: [ + { + operation: "replace_text", + locator: { + blockId: firstBlockId, + expectedBlockType: "paragraph", + }, + text: "Alpha updated", + }, + { + operation: "insert_after", + locator: { + blockId: "block-2", + }, + markdown: "## Next step", + }, + ], + }); + + expect(execution.reviewSafe).toBe(true); + expect(execution.issues).toEqual([]); + expect(execution.ops[0]).toEqual({ + type: "replace-text", + blockId: firstBlockId, + offset: 0, + length: 5, + text: "Alpha updated", + }); + expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); + expect(execution.ops.some((op) => op.type === "insert-text")).toBe(true); + }); + + it("optimizes single-block markdown replacements into native ops", () => { + const editor = createPlanExecutorEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Old title" }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am turning the paragraph into a heading with new copy.", + scope: "single-block", + targetSpanId: `span:${blockId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [blockId], + }, + markdown: "## New title", + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "convert-block", + blockId, + newType: "heading", + newProps: { level: 2 }, + }, + { + type: "replace-text", + blockId, + offset: 0, + length: "Old title".length, + text: "New title", + }, + ]); + }); + + it("optimizes adjacent multi-block markdown replacements into native ops", () => { + const editor = createPlanExecutorEditor(); + const headingId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "convert-block", blockId: headingId, newType: "heading", newProps: { level: 1 } }, + { type: "insert-text", blockId: headingId, offset: 0, text: "Old heading" }, + { + type: "insert-block", + blockId: "paragraph-2", + blockType: "paragraph", + props: {}, + position: { after: headingId }, + }, + { + type: "insert-text", + blockId: "paragraph-2", + offset: 0, + text: "Old body", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am rewriting the heading and paragraph together.", + scope: "adjacent-blocks", + targetSpanId: `span:${headingId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [headingId, "paragraph-2"], + }, + markdown: ["## New heading", "", "New body copy"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "update-block", + blockId: headingId, + props: { level: 2 }, + }, + { + type: "replace-text", + blockId: headingId, + offset: 0, + length: "Old heading".length, + text: "New heading", + }, + { + type: "replace-text", + blockId: "paragraph-2", + offset: 0, + length: "Old body".length, + text: "New body copy", + }, + ]); + }); + + it("optimizes adjacent list rewrites into native ops", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "convert-block", blockId: firstId, newType: "bulletListItem", newProps: { indent: 0 } }, + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "item-2", + blockType: "bulletListItem", + props: { indent: 0 }, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "item-2", + offset: 0, + text: "Beta", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am converting the bullet list into a numbered list.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "item-2"], + }, + markdown: ["1. First", "2. Second"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "convert-block", + blockId: firstId, + newType: "numberedListItem", + newProps: { indent: 0, start: 1 }, + }, + { + type: "replace-text", + blockId: firstId, + offset: 0, + length: "Alpha".length, + text: "First", + }, + { + type: "convert-block", + blockId: "item-2", + newType: "numberedListItem", + newProps: { indent: 0, start: undefined }, + }, + { + type: "replace-text", + blockId: "item-2", + offset: 0, + length: "Beta".length, + text: "Second", + }, + ]); + }); + + it("reuses matching suffix blocks when a flow patch inserts at the front", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Keep second", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new heading before the existing paragraphs.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2"], + }, + markdown: ["## New intro", "", "Keep first", "", "Keep second"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "heading", + props: { level: 2 }, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "New intro", + }, + ]); + }); +}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts new file mode 100644 index 0000000..90ae75d --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { createPlanExecutorEditor } from "./planExecutor.testUtils"; + +describe("document mutation plan executor", () => { + it("reuses matching prefix blocks when a flow patch deletes at the end", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Keep second", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Remove me", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am trimming the trailing paragraph.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Keep first", "", "Keep second"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "delete-block", + blockId: "block-3", + }, + ]); + }); + + it("reuses and rewrites a near-match suffix block during front insertions", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Final thoughts", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new intro and lightly revising the ending.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2"], + }, + markdown: [ + "New intro", + "", + "Keep first", + "", + "Final thoughts updated", + ].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "New intro", + }, + { + type: "replace-text", + blockId: "block-2", + offset: 0, + length: "Final thoughts".length, + text: "Final thoughts updated", + }, + ]); + }); + + it("reuses and reformats a suffix block when inline marks are added", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Final thoughts", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new intro and bolding the ending.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2"], + }, + markdown: [ + "New intro", + "", + "Keep first", + "", + "**Final thoughts**", + ].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "New intro", + }, + { + type: "replace-text", + blockId: "block-2", + offset: 0, + length: "Final thoughts".length, + text: "Final thoughts", + }, + { + type: "format-text", + blockId: "block-2", + offset: 0, + length: "Final thoughts".length, + marks: { bold: true }, + }, + ]); + }); + + it("reuses block ids when a flow patch inserts in the middle", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Bravo", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new paragraph between Bravo and Charlie.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Alpha", "", "Bravo", "", "Inserted middle", "", "Charlie"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "Inserted middle", + }, + ]); + expect(execution.metrics?.flowPatchAlignment).toEqual({ + preservedBlockCount: 3, + rewrittenBlockCount: 0, + unchangedBlockCount: 3, + insertedBlockCount: 1, + deletedBlockCount: 0, + estimatedOperationCost: 2, + }); + }); + + it("reuses block ids when a flow patch deletes in the middle", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Remove me", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am deleting the middle paragraph.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Alpha", "", "Charlie"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "delete-block", + blockId: "block-2", + }, + ]); + expect(execution.metrics?.flowPatchAlignment).toEqual({ + preservedBlockCount: 2, + rewrittenBlockCount: 0, + unchangedBlockCount: 2, + insertedBlockCount: 0, + deletedBlockCount: 1, + estimatedOperationCost: 1, + }); + }); +}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts new file mode 100644 index 0000000..c7374cd --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { createPlanExecutorEditor } from "./planExecutor.testUtils"; + +describe("document mutation plan executor", () => { + it("prefers the lower-op middle alignment when repeated blocks create multiple match options", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { + type: "convert-block", + blockId: firstId, + newType: "heading", + newProps: { level: 1 }, + }, + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Note", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Omega", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am moving a revised note before Alpha while keeping Omega.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Note updated", "", "# Alpha", "", "Omega"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "Note updated", + }, + { + type: "delete-block", + blockId: "block-2", + }, + ]); + expect(execution.metrics?.flowPatchAlignment).toEqual({ + preservedBlockCount: 2, + rewrittenBlockCount: 0, + unchangedBlockCount: 2, + insertedBlockCount: 1, + deletedBlockCount: 1, + estimatedOperationCost: 3, + }); + }); + + it("builds database ops and stringifies database values", () => { + const editor = createPlanExecutorEditor(); + editor.apply( + [{ + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: "last", + }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "insert_row", + rowId: "row-1", + values: { done: true, count: 3 }, + }, + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: { label: "Ship" }, + }, + ], + }); + + expect(execution.reviewSafe).toBe(false); + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { done: "true", count: "3" }, + }, + { + type: "database-update-cell", + blockId: "database-1", + rowId: "row-1", + columnId: "name", + value: JSON.stringify({ label: "Ship" }), + }, + ]); + }); + + it("marks review bundles as not review-safe when they contain database edits", () => { + const editor = createPlanExecutorEditor(); + editor.apply( + [ + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: "last", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Review", + reason: "Bundle", + plans: [ + { + kind: "block_insert", + blockType: "paragraph", + position: "last", + initialText: "Hello", + }, + { + kind: "database_edit", + blockId: "database-1", + steps: [{ op: "set_active_view", viewId: "view-1" }], + }, + ], + }); + + expect(execution.reviewSafe).toBe(false); + expect(execution.issues).toEqual([]); + expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); + expect( + execution.ops.some((op) => op.type === "database-set-active-view"), + ).toBe(true); + }); + + it("supports review bundles that insert then update and edit a regular block", () => { + const editor = createPlanExecutorEditor(); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Create heading", + reason: "Insert, refine props, and edit text.", + plans: [ + { + kind: "block_insert", + blockId: "heading-new", + blockType: "paragraph", + position: "last", + initialText: "Draft", + }, + { + kind: "block_update", + blockId: "heading-new", + props: { tone: "title" }, + }, + { + kind: "text_edit", + target: { + blockId: "heading-new", + range: { + startOffset: 0, + endOffset: 5, + }, + }, + operation: "replace", + text: "Final", + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: "heading-new", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "heading-new", + offset: 0, + text: "Draft", + }, + { + type: "update-block", + blockId: "heading-new", + props: { tone: "title" }, + }, + { + type: "replace-text", + blockId: "heading-new", + offset: 0, + length: 5, + text: "Final", + }, + ]); + }); + + it("supports review bundles that insert then convert a regular block", () => { + const editor = createPlanExecutorEditor(); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Create heading", + reason: "Insert then convert the new block.", + plans: [ + { + kind: "block_insert", + blockId: "heading-new", + blockType: "paragraph", + position: "last", + initialText: "Hello", + }, + { + kind: "block_convert", + blockId: "heading-new", + newType: "heading", + props: { level: 2 }, + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: "heading-new", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "heading-new", + offset: 0, + text: "Hello", + }, + { + type: "convert-block", + blockId: "heading-new", + newType: "heading", + newProps: { level: 2 }, + }, + ]); + }); + + it("supports review bundles that insert and then populate a database", () => { + const editor = createPlanExecutorEditor(); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Create people database", + reason: "Insert and populate a new database.", + plans: [ + { + kind: "block_insert", + blockId: "database-new", + blockType: "database", + position: "last", + }, + { + kind: "database_edit", + blockId: "database-new", + steps: [ + { + op: "add_column", + column: { id: "name", title: "Name", type: "text" }, + }, + { + op: "insert_row", + rowId: "row-1", + values: { name: "Alice" }, + }, + ], + }, + ], + }); + + expect(execution.reviewSafe).toBe(false); + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: "database-new", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-add-column", + blockId: "database-new", + column: { id: "name", title: "Name", type: "text" }, + }, + { + type: "database-insert-row", + blockId: "database-new", + rowId: "row-1", + values: { name: "Alice" }, + }, + ]); + }); +}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts index 2fbca03..db5cd01 100644 --- a/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts @@ -1,1065 +1,3 @@ -import { describe, expect, it } from "vitest"; -import { createEditor } from "@pen/core"; -import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { describe } from "vitest"; -const noDefaultExtensionsPreset = { - resolve() { - return { extensions: [] }; - }, -}; - -function createPlanExecutorEditor() { - return createEditor({ - preset: noDefaultExtensionsPreset, - }); -} - -describe("document mutation plan executor", () => { - it("builds replace-text ops for text edit plans", () => { - const editor = createPlanExecutorEditor(); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "text_edit", - target: { - blockId, - range: { - startOffset: 6, - endOffset: 11, - }, - }, - operation: "replace", - text: "planet", - }); - - expect(execution.reviewSafe).toBe(true); - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "replace-text", - blockId, - offset: 6, - length: 5, - text: "planet", - }, - ]); - }); - - it("builds native ops for flow patch plans", () => { - const editor = createPlanExecutorEditor(); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [{ - type: "replace-text", - blockId: firstBlockId, - offset: 0, - length: 0, - text: "Alpha", - }], - { origin: "system" }, - ); - editor.apply( - [{ - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Bravo", - }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am updating the current paragraph and inserting a heading after it.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstBlockId}`, - edits: [ - { - operation: "replace_text", - locator: { - blockId: firstBlockId, - expectedBlockType: "paragraph", - }, - text: "Alpha updated", - }, - { - operation: "insert_after", - locator: { - blockId: "block-2", - }, - markdown: "## Next step", - }, - ], - }); - - expect(execution.reviewSafe).toBe(true); - expect(execution.issues).toEqual([]); - expect(execution.ops[0]).toEqual({ - type: "replace-text", - blockId: firstBlockId, - offset: 0, - length: 5, - text: "Alpha updated", - }); - expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); - expect(execution.ops.some((op) => op.type === "insert-text")).toBe(true); - }); - - it("optimizes single-block markdown replacements into native ops", () => { - const editor = createPlanExecutorEditor(); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Old title" }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am turning the paragraph into a heading with new copy.", - scope: "single-block", - targetSpanId: `span:${blockId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [blockId], - }, - markdown: "## New title", - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "convert-block", - blockId, - newType: "heading", - newProps: { level: 2 }, - }, - { - type: "replace-text", - blockId, - offset: 0, - length: "Old title".length, - text: "New title", - }, - ]); - }); - - it("optimizes adjacent multi-block markdown replacements into native ops", () => { - const editor = createPlanExecutorEditor(); - const headingId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "convert-block", blockId: headingId, newType: "heading", newProps: { level: 1 } }, - { type: "insert-text", blockId: headingId, offset: 0, text: "Old heading" }, - { - type: "insert-block", - blockId: "paragraph-2", - blockType: "paragraph", - props: {}, - position: { after: headingId }, - }, - { - type: "insert-text", - blockId: "paragraph-2", - offset: 0, - text: "Old body", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am rewriting the heading and paragraph together.", - scope: "adjacent-blocks", - targetSpanId: `span:${headingId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [headingId, "paragraph-2"], - }, - markdown: ["## New heading", "", "New body copy"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "update-block", - blockId: headingId, - props: { level: 2 }, - }, - { - type: "replace-text", - blockId: headingId, - offset: 0, - length: "Old heading".length, - text: "New heading", - }, - { - type: "replace-text", - blockId: "paragraph-2", - offset: 0, - length: "Old body".length, - text: "New body copy", - }, - ]); - }); - - it("optimizes adjacent list rewrites into native ops", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "convert-block", blockId: firstId, newType: "bulletListItem", newProps: { indent: 0 } }, - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "item-2", - blockType: "bulletListItem", - props: { indent: 0 }, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "item-2", - offset: 0, - text: "Beta", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am converting the bullet list into a numbered list.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "item-2"], - }, - markdown: ["1. First", "2. Second"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "convert-block", - blockId: firstId, - newType: "numberedListItem", - newProps: { indent: 0, start: 1 }, - }, - { - type: "replace-text", - blockId: firstId, - offset: 0, - length: "Alpha".length, - text: "First", - }, - { - type: "convert-block", - blockId: "item-2", - newType: "numberedListItem", - newProps: { indent: 0, start: undefined }, - }, - { - type: "replace-text", - blockId: "item-2", - offset: 0, - length: "Beta".length, - text: "Second", - }, - ]); - }); - - it("reuses matching suffix blocks when a flow patch inserts at the front", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Keep second", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new heading before the existing paragraphs.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2"], - }, - markdown: ["## New intro", "", "Keep first", "", "Keep second"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "heading", - props: { level: 2 }, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "New intro", - }, - ]); - }); - - it("reuses matching prefix blocks when a flow patch deletes at the end", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Keep second", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Remove me", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am trimming the trailing paragraph.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Keep first", "", "Keep second"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "delete-block", - blockId: "block-3", - }, - ]); - }); - - it("reuses and rewrites a near-match suffix block during front insertions", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Final thoughts", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new intro and lightly revising the ending.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2"], - }, - markdown: [ - "New intro", - "", - "Keep first", - "", - "Final thoughts updated", - ].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "New intro", - }, - { - type: "replace-text", - blockId: "block-2", - offset: 0, - length: "Final thoughts".length, - text: "Final thoughts updated", - }, - ]); - }); - - it("reuses and reformats a suffix block when inline marks are added", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Final thoughts", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new intro and bolding the ending.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2"], - }, - markdown: [ - "New intro", - "", - "Keep first", - "", - "**Final thoughts**", - ].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "New intro", - }, - { - type: "replace-text", - blockId: "block-2", - offset: 0, - length: "Final thoughts".length, - text: "Final thoughts", - }, - { - type: "format-text", - blockId: "block-2", - offset: 0, - length: "Final thoughts".length, - marks: { bold: true }, - }, - ]); - }); - - it("reuses block ids when a flow patch inserts in the middle", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Bravo", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new paragraph between Bravo and Charlie.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Alpha", "", "Bravo", "", "Inserted middle", "", "Charlie"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "Inserted middle", - }, - ]); - expect(execution.metrics?.flowPatchAlignment).toEqual({ - preservedBlockCount: 3, - rewrittenBlockCount: 0, - unchangedBlockCount: 3, - insertedBlockCount: 1, - deletedBlockCount: 0, - estimatedOperationCost: 2, - }); - }); - - it("reuses block ids when a flow patch deletes in the middle", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Remove me", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am deleting the middle paragraph.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Alpha", "", "Charlie"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "delete-block", - blockId: "block-2", - }, - ]); - expect(execution.metrics?.flowPatchAlignment).toEqual({ - preservedBlockCount: 2, - rewrittenBlockCount: 0, - unchangedBlockCount: 2, - insertedBlockCount: 0, - deletedBlockCount: 1, - estimatedOperationCost: 1, - }); - }); - - it("prefers the lower-op middle alignment when repeated blocks create multiple match options", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { - type: "convert-block", - blockId: firstId, - newType: "heading", - newProps: { level: 1 }, - }, - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Note", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Omega", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am moving a revised note before Alpha while keeping Omega.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Note updated", "", "# Alpha", "", "Omega"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "Note updated", - }, - { - type: "delete-block", - blockId: "block-2", - }, - ]); - expect(execution.metrics?.flowPatchAlignment).toEqual({ - preservedBlockCount: 2, - rewrittenBlockCount: 0, - unchangedBlockCount: 2, - insertedBlockCount: 1, - deletedBlockCount: 1, - estimatedOperationCost: 3, - }); - }); - - it("builds database ops and stringifies database values", () => { - const editor = createPlanExecutorEditor(); - editor.apply( - [{ - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: "last", - }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "insert_row", - rowId: "row-1", - values: { done: true, count: 3 }, - }, - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: { label: "Ship" }, - }, - ], - }); - - expect(execution.reviewSafe).toBe(false); - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { done: "true", count: "3" }, - }, - { - type: "database-update-cell", - blockId: "database-1", - rowId: "row-1", - columnId: "name", - value: JSON.stringify({ label: "Ship" }), - }, - ]); - }); - - it("marks review bundles as not review-safe when they contain database edits", () => { - const editor = createPlanExecutorEditor(); - editor.apply( - [ - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Review", - reason: "Bundle", - plans: [ - { - kind: "block_insert", - blockType: "paragraph", - position: "last", - initialText: "Hello", - }, - { - kind: "database_edit", - blockId: "database-1", - steps: [{ op: "set_active_view", viewId: "view-1" }], - }, - ], - }); - - expect(execution.reviewSafe).toBe(false); - expect(execution.issues).toEqual([]); - expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); - expect( - execution.ops.some((op) => op.type === "database-set-active-view"), - ).toBe(true); - }); - - it("supports review bundles that insert then update and edit a regular block", () => { - const editor = createPlanExecutorEditor(); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Create heading", - reason: "Insert, refine props, and edit text.", - plans: [ - { - kind: "block_insert", - blockId: "heading-new", - blockType: "paragraph", - position: "last", - initialText: "Draft", - }, - { - kind: "block_update", - blockId: "heading-new", - props: { tone: "title" }, - }, - { - kind: "text_edit", - target: { - blockId: "heading-new", - range: { - startOffset: 0, - endOffset: 5, - }, - }, - operation: "replace", - text: "Final", - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: "heading-new", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "heading-new", - offset: 0, - text: "Draft", - }, - { - type: "update-block", - blockId: "heading-new", - props: { tone: "title" }, - }, - { - type: "replace-text", - blockId: "heading-new", - offset: 0, - length: 5, - text: "Final", - }, - ]); - }); - - it("supports review bundles that insert then convert a regular block", () => { - const editor = createPlanExecutorEditor(); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Create heading", - reason: "Insert then convert the new block.", - plans: [ - { - kind: "block_insert", - blockId: "heading-new", - blockType: "paragraph", - position: "last", - initialText: "Hello", - }, - { - kind: "block_convert", - blockId: "heading-new", - newType: "heading", - props: { level: 2 }, - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: "heading-new", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "heading-new", - offset: 0, - text: "Hello", - }, - { - type: "convert-block", - blockId: "heading-new", - newType: "heading", - newProps: { level: 2 }, - }, - ]); - }); - - it("supports review bundles that insert and then populate a database", () => { - const editor = createPlanExecutorEditor(); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Create people database", - reason: "Insert and populate a new database.", - plans: [ - { - kind: "block_insert", - blockId: "database-new", - blockType: "database", - position: "last", - }, - { - kind: "database_edit", - blockId: "database-new", - steps: [ - { - op: "add_column", - column: { id: "name", title: "Name", type: "text" }, - }, - { - op: "insert_row", - rowId: "row-1", - values: { name: "Alice" }, - }, - ], - }, - ], - }); - - expect(execution.reviewSafe).toBe(false); - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: "database-new", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-add-column", - blockId: "database-new", - column: { id: "name", title: "Name", type: "text" }, - }, - { - type: "database-insert-row", - blockId: "database-new", - rowId: "row-1", - values: { name: "Alice" }, - }, - ]); - }); -}); +describe.skip("document mutation plan executor split entrypoint", () => {}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts new file mode 100644 index 0000000..c426204 --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts @@ -0,0 +1,13 @@ +import { createEditor } from "@pen/core"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +export function createPlanExecutorEditor() { + return createEditor({ + preset: noDefaultExtensionsPreset, + }); +} diff --git a/packages/extensions/ai/src/runtime/planExecutor.ts b/packages/extensions/ai/src/runtime/planExecutor.ts index 575f367..52886e1 100644 --- a/packages/extensions/ai/src/runtime/planExecutor.ts +++ b/packages/extensions/ai/src/runtime/planExecutor.ts @@ -1,1361 +1,2 @@ -import type { DocumentOp, Editor } from "@pen/types"; -import { buildDocumentWriteOps } from "@pen/document-ops"; -import { generateId } from "@pen/types"; -import type { - BlockConvertPlan, - BlockInsertPlan, - BlockMovePlan, - BlockUpdatePlan, - DatabaseEditPlan, - DocumentMutationPlan, - FlowPatchEdit, - FlowPatchPlan, - ReviewBundlePlan, - TextEditPlan, -} from "./planTypes"; - -export interface PlanExecutionIssue { - path: string; - code: - | "missing-block" - | "invalid-target" - | "unsupported-target" - | "invalid-range"; - message: string; -} - -export interface PlanExecutionResult { - ops: DocumentOp[]; - issues: PlanExecutionIssue[]; - reviewSafe: boolean; - metrics?: PlanExecutionMetrics; -} - -export interface PlanExecutionMetrics { - flowPatchAlignment?: FlowPatchAlignmentMetrics; -} - -export interface FlowPatchAlignmentMetrics { - preservedBlockCount: number; - rewrittenBlockCount: number; - unchangedBlockCount: number; - insertedBlockCount: number; - deletedBlockCount: number; - estimatedOperationCost: number; -} - -interface VirtualBlockState { - type: string; - props: Record; - textLength: number; - database?: { - columnIds: Set; - rowIds: Set; - viewIds: Set; - }; -} - -interface PlanExecutionContext { - virtualBlocks: Map; -} - -interface PendingInlineMark { - type: string; - props?: Record; - start: number; - end: number; -} - -interface PendingInlineBlock { - type: string; - props: Record; - content?: string; - marks?: PendingInlineMark[]; - children?: unknown[]; - database?: unknown; -} - -interface InlineAlignmentStep { - kind: "substitute" | "insert" | "delete"; - targetIndex?: number; - parsedIndex?: number; -} - -interface InlineAlignmentResolution { - steps: InlineAlignmentStep[]; - metrics: FlowPatchAlignmentMetrics; -} - -export function buildDocumentMutationPlanExecution( - editor: Editor, - plan: DocumentMutationPlan, -): PlanExecutionResult { - const context: PlanExecutionContext = { - virtualBlocks: new Map(), - }; - return buildPlanExecution(editor, plan, context); -} - -function buildPlanExecution( - editor: Editor, - plan: DocumentMutationPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - switch (plan.kind) { - case "text_edit": - return buildTextEditExecution(editor, plan, context); - case "flow_patch": - return buildFlowPatchExecution(editor, plan); - case "block_insert": - return buildBlockInsertExecution(editor, plan, context); - case "block_update": - return buildBlockUpdateExecution(editor, plan, context); - case "block_move": - return buildBlockMoveExecution(editor, plan, context); - case "block_convert": - return buildBlockConvertExecution(editor, plan, context); - case "database_edit": - return buildDatabaseEditExecution(editor, plan, context); - case "review_bundle": - return buildReviewBundleExecution(editor, plan, context); - } -} - -function buildTextEditExecution( - editor: Editor, - plan: TextEditPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockState = resolveBlockState(editor, context, plan.target.blockId); - if (!blockState) { - return withIssue( - `${plan.kind}.target.blockId`, - "missing-block", - `Block "${plan.target.blockId}" was not found.`, - ); - } - - const blockLength = blockState.textLength; - if ( - plan.target.range && - (plan.target.range.startOffset < 0 || - plan.target.range.endOffset < plan.target.range.startOffset || - plan.target.range.endOffset > blockLength) - ) { - return withIssue( - `${plan.kind}.target.range`, - "invalid-range", - "Text edit range is outside the target block.", - ); - } - - if (plan.operation === "append") { - context.virtualBlocks.set(plan.target.blockId, { - ...blockState, - textLength: blockLength + plan.text.length, - }); - return { - ops: [{ - type: "insert-text", - blockId: plan.target.blockId, - offset: blockLength, - text: plan.text, - }], - issues: [], - reviewSafe: true, - }; - } - - if (plan.operation === "insert") { - const offset = plan.target.range?.startOffset ?? blockLength; - context.virtualBlocks.set(plan.target.blockId, { - ...blockState, - textLength: blockLength + plan.text.length, - }); - return { - ops: [{ - type: "insert-text", - blockId: plan.target.blockId, - offset, - text: plan.text, - }], - issues: [], - reviewSafe: true, - }; - } - - const offset = plan.target.range?.startOffset ?? 0; - const length = - plan.target.range != null - ? plan.target.range.endOffset - plan.target.range.startOffset - : blockLength; - context.virtualBlocks.set(plan.target.blockId, { - ...blockState, - textLength: blockLength - length + plan.text.length, - }); - - return { - ops: [{ - type: "replace-text", - blockId: plan.target.blockId, - offset, - length, - text: plan.text, - }], - issues: [], - reviewSafe: true, - }; -} - -function buildFlowPatchExecution( - editor: Editor, - plan: FlowPatchPlan, -): PlanExecutionResult { - const ops: DocumentOp[] = []; - const issues: PlanExecutionIssue[] = []; - let reviewSafe = true; - let flowPatchAlignmentMetrics: FlowPatchAlignmentMetrics | undefined; - - for (const [index, edit] of plan.edits.entries()) { - const execution = buildFlowPatchEditExecution(editor, edit, `${plan.kind}.edits[${index}]`); - ops.push(...execution.ops); - issues.push(...execution.issues); - reviewSafe = reviewSafe && execution.reviewSafe; - flowPatchAlignmentMetrics = mergeFlowPatchAlignmentMetrics( - flowPatchAlignmentMetrics, - execution.metrics?.flowPatchAlignment, - ); - } - - return { - ops, - issues, - reviewSafe, - metrics: - flowPatchAlignmentMetrics == null - ? undefined - : { flowPatchAlignment: flowPatchAlignmentMetrics }, - }; -} - -function buildBlockInsertExecution( - editor: Editor, - plan: BlockInsertPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockId = plan.blockId ?? generateId(); - if (resolveBlockState(editor, context, blockId)) { - return withIssue( - `${plan.kind}.blockId`, - "invalid-target", - `Block "${blockId}" already exists.`, - ); - } - - context.virtualBlocks.set( - blockId, - createVirtualBlockState( - plan.blockType, - plan.props ?? {}, - plan.initialText ?? "", - ), - ); - const ops: DocumentOp[] = [{ - type: "insert-block", - blockId, - blockType: plan.blockType, - props: plan.props ?? {}, - position: plan.position, - }]; - - if (plan.initialText && plan.initialText.length > 0) { - ops.push({ - type: "insert-text", - blockId, - offset: 0, - text: plan.initialText, - }); - } - - return { - ops, - issues: [], - reviewSafe: true, - }; -} - -function buildFlowPatchEditExecution( - editor: Editor, - edit: FlowPatchEdit, - path: string, -): PlanExecutionResult { - const targetBlockIds = - edit.locator.blockIds?.filter((blockId) => blockId.length > 0) ?? - (edit.locator.blockId ? [edit.locator.blockId] : []); - const primaryBlockId = targetBlockIds[0] ?? null; - const primaryBlock = primaryBlockId ? editor.getBlock(primaryBlockId) : null; - - if ( - edit.locator.expectedBlockType && - primaryBlock && - primaryBlock.type !== edit.locator.expectedBlockType - ) { - return withIssue( - `${path}.locator.expectedBlockType`, - "unsupported-target", - `Block "${primaryBlock.id}" is "${primaryBlock.type}", expected "${edit.locator.expectedBlockType}".`, - ); - } - - switch (edit.operation) { - case "replace_text": { - if (!primaryBlockId || !primaryBlock) { - return withIssue( - `${path}.locator.blockId`, - "missing-block", - "Flow patch replace_text requires an existing target block.", - ); - } - return { - ops: [{ - type: "replace-text", - blockId: primaryBlockId, - offset: 0, - length: primaryBlock.length(), - text: edit.text ?? "", - }], - issues: [], - reviewSafe: true, - }; - } - case "append_text": { - if (!primaryBlockId || !primaryBlock) { - return withIssue( - `${path}.locator.blockId`, - "missing-block", - "Flow patch append_text requires an existing target block.", - ); - } - return { - ops: [{ - type: "insert-text", - blockId: primaryBlockId, - offset: primaryBlock.length(), - text: edit.text ?? "", - }], - issues: [], - reviewSafe: true, - }; - } - case "insert_before": - case "insert_after": { - if (!primaryBlockId || !primaryBlock) { - return withIssue( - `${path}.locator.blockId`, - "missing-block", - `Flow patch ${edit.operation} requires an existing target block.`, - ); - } - const { ops } = buildDocumentWriteOps(editor, { - format: "markdown", - content: edit.markdown ?? "", - position: - edit.operation === "insert_before" - ? { before: primaryBlockId } - : { after: primaryBlockId }, - surface: "ai-flow-patch", - }); - return { - ops, - issues: [], - reviewSafe: true, - }; - } - case "replace_blocks": { - if (targetBlockIds.length === 0) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch replace_blocks requires one or more target blocks.", - ); - } - if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch replace_blocks targets a missing block.", - ); - } - const optimized = buildOptimizedBlockReplacement( - editor, - targetBlockIds, - edit.markdown ?? "", - ); - if (optimized) { - return optimized; - } - const { ops } = buildDocumentWriteOps(editor, { - format: "markdown", - content: edit.markdown ?? "", - position: { before: targetBlockIds[0]! }, - surface: "ai-flow-patch", - }); - return { - ops: [ - ...ops, - ...targetBlockIds.map((blockId) => ({ - type: "delete-block", - blockId, - }) satisfies DocumentOp), - ], - issues: [], - reviewSafe: true, - }; - } - case "delete_blocks": { - if (targetBlockIds.length === 0) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch delete_blocks requires one or more target blocks.", - ); - } - if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch delete_blocks targets a missing block.", - ); - } - return { - ops: targetBlockIds.map((blockId) => ({ - type: "delete-block", - blockId, - }) satisfies DocumentOp), - issues: [], - reviewSafe: true, - }; - } - } -} - -function buildOptimizedBlockReplacement( - editor: Editor, - targetBlockIds: string[], - markdown: string, -): PlanExecutionResult | null { - if (targetBlockIds.length === 0) { - return null; - } - - const targetBlocks = targetBlockIds - .map((blockId) => editor.getBlock(blockId)) - .filter((block): block is NonNullable => block != null); - if (targetBlocks.length !== targetBlockIds.length) { - return null; - } - - const parsedBlocks = buildDocumentWriteOps(editor, { - format: "markdown", - content: markdown, - surface: "ai-flow-patch-optimize", - }).blocks as PendingInlineBlock[]; - if ( - parsedBlocks.some((parsedBlock) => !isInlineConvertiblePendingBlock(parsedBlock)) - ) { - return null; - } - if (targetBlocks.some((block) => !isInlineConvertibleTargetBlock(block))) { - return null; - } - - const alignment = resolveInlineAlignmentPlan(targetBlocks, parsedBlocks); - const ops = buildInlineAlignmentOps(alignment.steps, targetBlocks, parsedBlocks); - - return { - ops, - issues: [], - reviewSafe: true, - metrics: { - flowPatchAlignment: alignment.metrics, - }, - }; -} - -function buildInlineBlockRewriteOps( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): DocumentOp[] { - const ops: DocumentOp[] = []; - if (parsedBlock.type !== targetBlock.type) { - ops.push({ - type: "convert-block", - blockId: targetBlock.id, - newType: parsedBlock.type, - newProps: parsedBlock.props, - }); - } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { - ops.push({ - type: "update-block", - blockId: targetBlock.id, - props: parsedBlock.props, - }); - } - - const nextText = parsedBlock.content ?? ""; - const needsTextRewrite = - targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0; - if (needsTextRewrite) { - ops.push({ - type: "replace-text", - blockId: targetBlock.id, - offset: 0, - length: targetBlock.length(), - text: nextText, - }); - for (const mark of parsedBlock.marks ?? []) { - if (mark.end <= mark.start) { - continue; - } - ops.push({ - type: "format-text", - blockId: targetBlock.id, - offset: mark.start, - length: mark.end - mark.start, - marks: { [mark.type]: mark.props ?? true }, - }); - } - } - - return ops; -} - -function buildInlineAlignmentOps( - alignment: InlineAlignmentStep[], - targetBlocks: Array>>, - parsedBlocks: PendingInlineBlock[], -): DocumentOp[] { - const ops: DocumentOp[] = []; - const pendingInserts: PendingInlineBlock[] = []; - let blockBefore: string | null = null; - - for (const step of alignment) { - if (step.kind === "insert") { - pendingInserts.push(parsedBlocks[step.parsedIndex!]!); - continue; - } - - if (step.kind === "substitute") { - const targetBlock = targetBlocks[step.targetIndex!]!; - if (pendingInserts.length > 0) { - const insertOps = buildInlinePendingBlockInsertOps( - pendingInserts, - resolveInsertionPosition(blockBefore, targetBlock.id), - ); - ops.push(...insertOps); - blockBefore = resolveLastInsertedBlockId(insertOps) ?? blockBefore; - pendingInserts.length = 0; - } - ops.push( - ...buildInlineBlockRewriteOps( - targetBlock, - parsedBlocks[step.parsedIndex!]!, - ), - ); - blockBefore = targetBlock.id; - continue; - } - - ops.push({ - type: "delete-block", - blockId: targetBlocks[step.targetIndex!]!.id, - }); - } - - if (pendingInserts.length > 0) { - ops.push( - ...buildInlinePendingBlockInsertOps( - pendingInserts, - resolveInsertionPosition(blockBefore, null), - ), - ); - } - - return ops; -} - -function buildBlockUpdateExecution( - editor: Editor, - plan: BlockUpdatePlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockState = resolveBlockState(editor, context, plan.blockId); - if (!blockState) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - context.virtualBlocks.set(plan.blockId, { - ...blockState, - props: plan.props, - }); - - return { - ops: [{ - type: "update-block", - blockId: plan.blockId, - props: plan.props, - }], - issues: [], - reviewSafe: false, - }; -} - -function isInlineConvertiblePendingBlock( - block: PendingInlineBlock, -): boolean { - return ( - (block.children?.length ?? 0) === 0 && - block.database == null && - block.type !== "table" && - block.type !== "database" - ); -} - -function isInlineConvertibleTargetBlock( - block: NonNullable>, -): boolean { - return block.children.length === 0 && block.type !== "table" && block.type !== "database"; -} - -function resolveInlineAlignmentPlan( - targetBlocks: Array>>, - parsedBlocks: PendingInlineBlock[], -): InlineAlignmentResolution { - const costs = Array.from( - { length: targetBlocks.length + 1 }, - () => new Array(parsedBlocks.length + 1).fill(0), - ); - - for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { - costs[targetIndex]![parsedBlocks.length] = - estimateInlineDeleteCost(targetBlocks[targetIndex]!) + - costs[targetIndex + 1]![parsedBlocks.length]!; - } - for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { - costs[targetBlocks.length]![parsedIndex] = - estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + - costs[targetBlocks.length]![parsedIndex + 1]!; - } - - for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { - for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { - const substituteCost = - estimateInlineSubstituteCost( - targetBlocks[targetIndex]!, - parsedBlocks[parsedIndex]!, - ) + costs[targetIndex + 1]![parsedIndex + 1]!; - const deleteCost = - estimateInlineDeleteCost(targetBlocks[targetIndex]!) + - costs[targetIndex + 1]![parsedIndex]!; - const insertCost = - estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + - costs[targetIndex]![parsedIndex + 1]!; - costs[targetIndex]![parsedIndex] = Math.min( - substituteCost, - deleteCost, - insertCost, - ); - } - } - - const alignment: InlineAlignmentStep[] = []; - let targetIndex = 0; - let parsedIndex = 0; - while (targetIndex < targetBlocks.length && parsedIndex < parsedBlocks.length) { - const bestCost = costs[targetIndex]![parsedIndex]!; - const substituteCost = - estimateInlineSubstituteCost( - targetBlocks[targetIndex]!, - parsedBlocks[parsedIndex]!, - ) + costs[targetIndex + 1]![parsedIndex + 1]!; - const deleteCost = - estimateInlineDeleteCost(targetBlocks[targetIndex]!) + - costs[targetIndex + 1]![parsedIndex]!; - const insertCost = - estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + - costs[targetIndex]![parsedIndex + 1]!; - - if ( - substituteCost === bestCost && - shouldPreferInlineSubstitution( - targetBlocks[targetIndex]!, - parsedBlocks[parsedIndex]!, - substituteCost, - deleteCost, - insertCost, - ) - ) { - alignment.push({ - kind: "substitute", - targetIndex, - parsedIndex, - }); - targetIndex += 1; - parsedIndex += 1; - continue; - } - - if (deleteCost === bestCost && deleteCost <= insertCost) { - alignment.push({ - kind: "delete", - targetIndex, - }); - targetIndex += 1; - continue; - } - alignment.push({ - kind: "insert", - parsedIndex, - }); - parsedIndex += 1; - } - - while (targetIndex < targetBlocks.length) { - alignment.push({ - kind: "delete", - targetIndex, - }); - targetIndex += 1; - } - while (parsedIndex < parsedBlocks.length) { - alignment.push({ - kind: "insert", - parsedIndex, - }); - parsedIndex += 1; - } - - return { - steps: alignment, - metrics: summarizeInlineAlignment(alignment, targetBlocks, parsedBlocks, costs[0]?.[0] ?? 0), - }; -} - -function shouldPreferInlineSubstitution( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, - substituteCost: number, - deleteCost: number, - insertCost: number, -): boolean { - if (substituteCost < deleteCost && substituteCost < insertCost) { - return true; - } - if (substituteCost > deleteCost || substituteCost > insertCost) { - return false; - } - return areBlocksReusableMatch(targetBlock, parsedBlock); -} - -function estimateInlineSubstituteCost( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): number { - return estimateInlineBlockRewriteCost(targetBlock, parsedBlock); -} - -function estimateInlineDeleteCost( - _targetBlock: NonNullable>, -): number { - return 1; -} - -function estimateInlineInsertCost(block: PendingInlineBlock): number { - let cost = 1; - if ((block.content ?? "").length > 0) { - cost += 1; - } - for (const mark of block.marks ?? []) { - if (mark.end > mark.start) { - cost += 1; - } - } - return cost; -} - -function estimateInlineBlockRewriteCost( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): number { - let cost = 0; - if (parsedBlock.type !== targetBlock.type) { - cost += 1; - } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { - cost += 1; - } - - const nextText = parsedBlock.content ?? ""; - if (targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0) { - cost += 1; - } - for (const mark of parsedBlock.marks ?? []) { - if (mark.end > mark.start) { - cost += 1; - } - } - return cost; -} - -function summarizeInlineAlignment( - alignment: InlineAlignmentStep[], - targetBlocks: Array>>, - parsedBlocks: PendingInlineBlock[], - estimatedOperationCost: number, -): FlowPatchAlignmentMetrics { - let preservedBlockCount = 0; - let rewrittenBlockCount = 0; - let unchangedBlockCount = 0; - let insertedBlockCount = 0; - let deletedBlockCount = 0; - - for (const step of alignment) { - if (step.kind === "insert") { - insertedBlockCount += 1; - continue; - } - if (step.kind === "delete") { - deletedBlockCount += 1; - continue; - } - - preservedBlockCount += 1; - const rewriteCost = estimateInlineBlockRewriteCost( - targetBlocks[step.targetIndex!]!, - parsedBlocks[step.parsedIndex!]!, - ); - if (rewriteCost > 0) { - rewrittenBlockCount += 1; - } else { - unchangedBlockCount += 1; - } - } - - return { - preservedBlockCount, - rewrittenBlockCount, - unchangedBlockCount, - insertedBlockCount, - deletedBlockCount, - estimatedOperationCost, - }; -} - -function mergeFlowPatchAlignmentMetrics( - left: FlowPatchAlignmentMetrics | undefined, - right: FlowPatchAlignmentMetrics | undefined, -): FlowPatchAlignmentMetrics | undefined { - if (!left) { - return right; - } - if (!right) { - return left; - } - return { - preservedBlockCount: left.preservedBlockCount + right.preservedBlockCount, - rewrittenBlockCount: left.rewrittenBlockCount + right.rewrittenBlockCount, - unchangedBlockCount: left.unchangedBlockCount + right.unchangedBlockCount, - insertedBlockCount: left.insertedBlockCount + right.insertedBlockCount, - deletedBlockCount: left.deletedBlockCount + right.deletedBlockCount, - estimatedOperationCost: - left.estimatedOperationCost + right.estimatedOperationCost, - }; -} - -function areBlocksReusableMatch( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): boolean { - return ( - targetBlock.type === parsedBlock.type && - areRecordValuesEqual(targetBlock.props, parsedBlock.props) && - areTextsReusableMatch(targetBlock.textContent(), parsedBlock.content ?? "") - ); -} - -function areTextsReusableMatch(left: string, right: string): boolean { - const normalizedLeft = normalizeReusableText(left); - const normalizedRight = normalizeReusableText(right); - if (normalizedLeft === normalizedRight) { - return true; - } - if (normalizedLeft.length === 0 || normalizedRight.length === 0) { - return false; - } - if ( - normalizedLeft.includes(normalizedRight) || - normalizedRight.includes(normalizedLeft) - ) { - return true; - } - const sharedBoundaryLength = - resolveSharedPrefixLength(normalizedLeft, normalizedRight) + - resolveSharedSuffixLength(normalizedLeft, normalizedRight); - const minLength = Math.min(normalizedLeft.length, normalizedRight.length); - if (sharedBoundaryLength < Math.ceil(minLength * 0.5)) { - return false; - } - const maxLength = Math.max(normalizedLeft.length, normalizedRight.length); - const maxDistance = Math.max(4, Math.floor(maxLength * 0.4)); - return resolveLevenshteinDistance(normalizedLeft, normalizedRight, maxDistance) <= maxDistance; -} - -function normalizeReusableText(text: string): string { - return text.trim().replace(/\s+/g, " ").toLowerCase(); -} - -function resolveSharedPrefixLength(left: string, right: string): number { - let index = 0; - while (index < left.length && index < right.length && left[index] === right[index]) { - index += 1; - } - return index; -} - -function resolveSharedSuffixLength(left: string, right: string): number { - let count = 0; - while ( - count < left.length && - count < right.length && - left[left.length - 1 - count] === right[right.length - 1 - count] - ) { - count += 1; - } - return count; -} - -function resolveLevenshteinDistance( - left: string, - right: string, - maxDistance: number, -): number { - const previous = Array.from({ length: right.length + 1 }, (_, index) => index); - const current = new Array(right.length + 1); - - for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { - current[0] = leftIndex; - let rowMin = current[0]!; - for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { - const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; - current[rightIndex] = Math.min( - current[rightIndex - 1]! + 1, - previous[rightIndex]! + 1, - previous[rightIndex - 1]! + substitutionCost, - ); - rowMin = Math.min(rowMin, current[rightIndex]!); - } - if (rowMin > maxDistance) { - return maxDistance + 1; - } - for (let index = 0; index <= right.length; index += 1) { - previous[index] = current[index]!; - } - } - - return previous[right.length]!; -} - -function buildInlinePendingBlockInsertOps( - blocks: PendingInlineBlock[], - position: { before: string } | { after: string } | "last", -): DocumentOp[] { - const ops: DocumentOp[] = []; - let currentPosition = position; - for (const block of blocks) { - const blockId = generateId(); - ops.push({ - type: "insert-block", - blockId, - blockType: block.type, - props: block.props, - position: currentPosition, - }); - if ((block.content ?? "").length > 0) { - ops.push({ - type: "insert-text", - blockId, - offset: 0, - text: block.content!, - }); - } - for (const mark of block.marks ?? []) { - if (mark.end <= mark.start) { - continue; - } - ops.push({ - type: "format-text", - blockId, - offset: mark.start, - length: mark.end - mark.start, - marks: { [mark.type]: mark.props ?? true }, - }); - } - currentPosition = { after: blockId }; - } - return ops; -} - -function resolveLastInsertedBlockId(ops: DocumentOp[]): string | null { - for (let index = ops.length - 1; index >= 0; index -= 1) { - const op = ops[index]!; - if (op.type === "insert-block") { - return op.blockId; - } - } - return null; -} - -function resolveInsertionPosition( - blockBefore: string | null, - blockAfter: string | null, -): { before: string } | { after: string } | "last" { - if (blockBefore) { - return { after: blockBefore }; - } - if (blockAfter) { - return { before: blockAfter }; - } - return "last"; -} - -function areRecordValuesEqual( - left: Record, - right: Record, -): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - - return leftEntries.every(([key, value]) => { - if (!(key in right)) { - return false; - } - return JSON.stringify(value) === JSON.stringify(right[key]); - }); -} - -function buildBlockMoveExecution( - editor: Editor, - plan: BlockMovePlan, - context: PlanExecutionContext, -): PlanExecutionResult { - if (!resolveBlockState(editor, context, plan.blockId)) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - - return { - ops: [{ - type: "move-block", - blockId: plan.blockId, - position: plan.position, - }], - issues: [], - reviewSafe: true, - }; -} - -function buildBlockConvertExecution( - editor: Editor, - plan: BlockConvertPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockState = resolveBlockState(editor, context, plan.blockId); - if (!blockState) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - context.virtualBlocks.set( - plan.blockId, - createVirtualBlockState( - plan.newType, - plan.props ?? blockState.props, - blockState.textLength, - ), - ); - - return { - ops: [{ - type: "convert-block", - blockId: plan.blockId, - newType: plan.newType, - newProps: plan.props, - }], - issues: [], - reviewSafe: true, - }; -} - -function buildDatabaseEditExecution( - editor: Editor, - plan: DatabaseEditPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const block = editor.getBlock(plan.blockId); - const virtualBlock = context.virtualBlocks.get(plan.blockId) ?? null; - const effectiveBlockType = virtualBlock?.type ?? block?.type ?? null; - if (!effectiveBlockType) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - if (effectiveBlockType !== "database") { - return withIssue( - `${plan.kind}.blockId`, - "unsupported-target", - `Block "${plan.blockId}" is not a database block.`, - ); - } - - const ops: DocumentOp[] = []; - const knownColumnIds = new Set([ - ...(block?.type === "database" - ? block.tableColumns().map((column) => column.id) - : []), - ...(virtualBlock?.database?.columnIds ?? []), - ]); - const knownRowIds = new Set([ - ...(block?.type === "database" ? readDatabaseRowIds(block) : []), - ...(virtualBlock?.database?.rowIds ?? []), - ]); - const knownViewIds = new Set([ - ...(block?.type === "database" - ? block.databaseViews().map((view) => view.id) - : []), - ...(virtualBlock?.database?.viewIds ?? []), - ]); - - for (const step of plan.steps) { - switch (step.op) { - case "add_column": - ops.push({ - type: "database-add-column", - blockId: plan.blockId, - column: step.column, - }); - knownColumnIds.add(step.column.id); - break; - case "update_column": - ops.push({ - type: "database-update-column", - blockId: plan.blockId, - columnId: step.columnId, - patch: step.patch, - }); - break; - case "insert_row": { - const rowId = step.rowId ?? generateId(); - ops.push({ - type: "database-insert-row", - blockId: plan.blockId, - rowId, - values: stringifyRecord(step.values), - }); - knownRowIds.add(rowId); - break; - } - case "update_cell": - ops.push({ - type: "database-update-cell", - blockId: plan.blockId, - rowId: step.rowId, - columnId: step.columnId, - value: stringifyDatabaseValue(step.value), - }); - break; - case "add_view": - ops.push({ - type: "database-add-view", - blockId: plan.blockId, - view: step.view, - }); - knownViewIds.add(step.view.id); - break; - case "set_active_view": - ops.push({ - type: "database-set-active-view", - blockId: plan.blockId, - viewId: step.viewId, - }); - break; - } - } - - if (virtualBlock?.database) { - virtualBlock.database.columnIds = knownColumnIds; - virtualBlock.database.rowIds = knownRowIds; - virtualBlock.database.viewIds = knownViewIds; - } - - return { - ops, - issues: [], - reviewSafe: false, - }; -} - -function buildReviewBundleExecution( - editor: Editor, - plan: ReviewBundlePlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const ops: DocumentOp[] = []; - const issues: PlanExecutionIssue[] = []; - let reviewSafe = true; - - for (let index = 0; index < plan.plans.length; index += 1) { - const nestedPlan = plan.plans[index]!; - const execution = buildPlanExecution(editor, nestedPlan, context); - ops.push(...execution.ops); - issues.push( - ...execution.issues.map((issue) => ({ - ...issue, - path: `${plan.kind}.plans[${index}].${issue.path}`, - })), - ); - reviewSafe &&= execution.reviewSafe; - } - - return { - ops, - issues, - reviewSafe, - }; -} - -function createVirtualBlockState( - blockType: string, - props: Record = {}, - text: string | number = 0, -): VirtualBlockState { - const textLength = typeof text === "number" ? text : text.length; - if (blockType === "database") { - return { - type: blockType, - props, - textLength, - database: { - columnIds: new Set(), - rowIds: new Set(), - viewIds: new Set(), - }, - }; - } - return { - type: blockType, - props, - textLength, - }; -} - -function resolveBlockState( - editor: Editor, - context: PlanExecutionContext, - blockId: string, -): VirtualBlockState | null { - const virtualBlock = context.virtualBlocks.get(blockId) ?? null; - if (virtualBlock) { - return virtualBlock; - } - - const block = editor.getBlock(blockId); - if (!block) { - return null; - } - - const nextState = createVirtualBlockState( - block.type, - { ...block.props }, - block.length(), - ); - if (block.type === "database") { - nextState.database = { - columnIds: new Set(block.tableColumns().map((column) => column.id)), - rowIds: new Set(readDatabaseRowIds(block)), - viewIds: new Set(block.databaseViews().map((view) => view.id)), - }; - } - return nextState; -} - -function withIssue( - path: string, - code: PlanExecutionIssue["code"], - message: string, -): PlanExecutionResult { - return { - ops: [], - issues: [{ path, code, message }], - reviewSafe: false, - }; -} - -function stringifyRecord( - value: Record, -): Record { - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [ - key, - stringifyDatabaseValue(entryValue), - ]), - ); -} - -function readDatabaseRowIds( - block: ReturnType, -): string[] { - if (!block) { - return []; - } - const rowIds: string[] = []; - for (let index = 0; index < block.tableRowCount(); index += 1) { - const rowId = block.tableRow(index)?.id; - if (rowId) { - rowIds.push(rowId); - } - } - return rowIds; -} - -function stringifyDatabaseValue(value: unknown): string { - if (value == null) { - return ""; - } - if (typeof value === "string") { - return value; - } - if ( - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} +export { buildDocumentMutationPlanExecution } from "./planExecutorParts/planExecutorPart1"; +export type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics } from "./planExecutorParts/planExecutorPart1"; diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts new file mode 100644 index 0000000..a79933a --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts @@ -0,0 +1,290 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export interface PlanExecutionIssue { + path: string; + code: + | "missing-block" + | "invalid-target" + | "unsupported-target" + | "invalid-range"; + message: string; +} + +export interface PlanExecutionResult { + ops: DocumentOp[]; + issues: PlanExecutionIssue[]; + reviewSafe: boolean; + metrics?: PlanExecutionMetrics; +} + +export interface PlanExecutionMetrics { + flowPatchAlignment?: FlowPatchAlignmentMetrics; +} + +export interface FlowPatchAlignmentMetrics { + preservedBlockCount: number; + rewrittenBlockCount: number; + unchangedBlockCount: number; + insertedBlockCount: number; + deletedBlockCount: number; + estimatedOperationCost: number; +} + +export interface VirtualBlockState { + type: string; + props: Record; + textLength: number; + database?: { + columnIds: Set; + rowIds: Set; + viewIds: Set; + }; +} + +export interface PlanExecutionContext { + virtualBlocks: Map; +} + +export interface PendingInlineMark { + type: string; + props?: Record; + start: number; + end: number; +} + +export interface PendingInlineBlock { + type: string; + props: Record; + content?: string; + marks?: PendingInlineMark[]; + children?: unknown[]; + database?: unknown; +} + +export interface InlineAlignmentStep { + kind: "substitute" | "insert" | "delete"; + targetIndex?: number; + parsedIndex?: number; +} + +export interface InlineAlignmentResolution { + steps: InlineAlignmentStep[]; + metrics: FlowPatchAlignmentMetrics; +} + +export function buildDocumentMutationPlanExecution( + editor: Editor, + plan: DocumentMutationPlan, +): PlanExecutionResult { + const context: PlanExecutionContext = { + virtualBlocks: new Map(), + }; + return buildPlanExecution(editor, plan, context); +} + +export function buildPlanExecution( + editor: Editor, + plan: DocumentMutationPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + switch (plan.kind) { + case "text_edit": + return buildTextEditExecution(editor, plan, context); + case "flow_patch": + return buildFlowPatchExecution(editor, plan); + case "block_insert": + return buildBlockInsertExecution(editor, plan, context); + case "block_update": + return buildBlockUpdateExecution(editor, plan, context); + case "block_move": + return buildBlockMoveExecution(editor, plan, context); + case "block_convert": + return buildBlockConvertExecution(editor, plan, context); + case "database_edit": + return buildDatabaseEditExecution(editor, plan, context); + case "review_bundle": + return buildReviewBundleExecution(editor, plan, context); + } +} + +export function buildTextEditExecution( + editor: Editor, + plan: TextEditPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockState = resolveBlockState(editor, context, plan.target.blockId); + if (!blockState) { + return withIssue( + `${plan.kind}.target.blockId`, + "missing-block", + `Block "${plan.target.blockId}" was not found.`, + ); + } + + const blockLength = blockState.textLength; + if ( + plan.target.range && + (plan.target.range.startOffset < 0 || + plan.target.range.endOffset < plan.target.range.startOffset || + plan.target.range.endOffset > blockLength) + ) { + return withIssue( + `${plan.kind}.target.range`, + "invalid-range", + "Text edit range is outside the target block.", + ); + } + + if (plan.operation === "append") { + context.virtualBlocks.set(plan.target.blockId, { + ...blockState, + textLength: blockLength + plan.text.length, + }); + return { + ops: [{ + type: "insert-text", + blockId: plan.target.blockId, + offset: blockLength, + text: plan.text, + }], + issues: [], + reviewSafe: true, + }; + } + + if (plan.operation === "insert") { + const offset = plan.target.range?.startOffset ?? blockLength; + context.virtualBlocks.set(plan.target.blockId, { + ...blockState, + textLength: blockLength + plan.text.length, + }); + return { + ops: [{ + type: "insert-text", + blockId: plan.target.blockId, + offset, + text: plan.text, + }], + issues: [], + reviewSafe: true, + }; + } + + const offset = plan.target.range?.startOffset ?? 0; + const length = + plan.target.range != null + ? plan.target.range.endOffset - plan.target.range.startOffset + : blockLength; + context.virtualBlocks.set(plan.target.blockId, { + ...blockState, + textLength: blockLength - length + plan.text.length, + }); + + return { + ops: [{ + type: "replace-text", + blockId: plan.target.blockId, + offset, + length, + text: plan.text, + }], + issues: [], + reviewSafe: true, + }; +} + +export function buildFlowPatchExecution( + editor: Editor, + plan: FlowPatchPlan, +): PlanExecutionResult { + const ops: DocumentOp[] = []; + const issues: PlanExecutionIssue[] = []; + let reviewSafe = true; + let flowPatchAlignmentMetrics: FlowPatchAlignmentMetrics | undefined; + + for (const [index, edit] of plan.edits.entries()) { + const execution = buildFlowPatchEditExecution(editor, edit, `${plan.kind}.edits[${index}]`); + ops.push(...execution.ops); + issues.push(...execution.issues); + reviewSafe = reviewSafe && execution.reviewSafe; + flowPatchAlignmentMetrics = mergeFlowPatchAlignmentMetrics( + flowPatchAlignmentMetrics, + execution.metrics?.flowPatchAlignment, + ); + } + + return { + ops, + issues, + reviewSafe, + metrics: + flowPatchAlignmentMetrics == null + ? undefined + : { flowPatchAlignment: flowPatchAlignmentMetrics }, + }; +} + +export function buildBlockInsertExecution( + editor: Editor, + plan: BlockInsertPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockId = plan.blockId ?? generateId(); + if (resolveBlockState(editor, context, blockId)) { + return withIssue( + `${plan.kind}.blockId`, + "invalid-target", + `Block "${blockId}" already exists.`, + ); + } + + context.virtualBlocks.set( + blockId, + createVirtualBlockState( + plan.blockType, + plan.props ?? {}, + plan.initialText ?? "", + ), + ); + const ops: DocumentOp[] = [{ + type: "insert-block", + blockId, + blockType: plan.blockType, + props: plan.props ?? {}, + position: plan.position, + }]; + + if (plan.initialText && plan.initialText.length > 0) { + ops.push({ + type: "insert-text", + blockId, + offset: 0, + text: plan.initialText, + }); + } + + return { + ops, + issues: [], + reviewSafe: true, + }; +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts new file mode 100644 index 0000000..cd55e71 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts @@ -0,0 +1,367 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export function buildFlowPatchEditExecution( + editor: Editor, + edit: FlowPatchEdit, + path: string, +): PlanExecutionResult { + const targetBlockIds = + edit.locator.blockIds?.filter((blockId) => blockId.length > 0) ?? + (edit.locator.blockId ? [edit.locator.blockId] : []); + const primaryBlockId = targetBlockIds[0] ?? null; + const primaryBlock = primaryBlockId ? editor.getBlock(primaryBlockId) : null; + + if ( + edit.locator.expectedBlockType && + primaryBlock && + primaryBlock.type !== edit.locator.expectedBlockType + ) { + return withIssue( + `${path}.locator.expectedBlockType`, + "unsupported-target", + `Block "${primaryBlock.id}" is "${primaryBlock.type}", expected "${edit.locator.expectedBlockType}".`, + ); + } + + switch (edit.operation) { + case "replace_text": { + if (!primaryBlockId || !primaryBlock) { + return withIssue( + `${path}.locator.blockId`, + "missing-block", + "Flow patch replace_text requires an existing target block.", + ); + } + return { + ops: [{ + type: "replace-text", + blockId: primaryBlockId, + offset: 0, + length: primaryBlock.length(), + text: edit.text ?? "", + }], + issues: [], + reviewSafe: true, + }; + } + case "append_text": { + if (!primaryBlockId || !primaryBlock) { + return withIssue( + `${path}.locator.blockId`, + "missing-block", + "Flow patch append_text requires an existing target block.", + ); + } + return { + ops: [{ + type: "insert-text", + blockId: primaryBlockId, + offset: primaryBlock.length(), + text: edit.text ?? "", + }], + issues: [], + reviewSafe: true, + }; + } + case "insert_before": + case "insert_after": { + if (!primaryBlockId || !primaryBlock) { + return withIssue( + `${path}.locator.blockId`, + "missing-block", + `Flow patch ${edit.operation} requires an existing target block.`, + ); + } + const { ops } = buildDocumentWriteOps(editor, { + format: "markdown", + content: edit.markdown ?? "", + position: + edit.operation === "insert_before" + ? { before: primaryBlockId } + : { after: primaryBlockId }, + surface: "ai-flow-patch", + }); + return { + ops, + issues: [], + reviewSafe: true, + }; + } + case "replace_blocks": { + if (targetBlockIds.length === 0) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch replace_blocks requires one or more target blocks.", + ); + } + if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch replace_blocks targets a missing block.", + ); + } + const optimized = buildOptimizedBlockReplacement( + editor, + targetBlockIds, + edit.markdown ?? "", + ); + if (optimized) { + return optimized; + } + const { ops } = buildDocumentWriteOps(editor, { + format: "markdown", + content: edit.markdown ?? "", + position: { before: targetBlockIds[0]! }, + surface: "ai-flow-patch", + }); + return { + ops: [ + ...ops, + ...targetBlockIds.map((blockId) => ({ + type: "delete-block", + blockId, + }) satisfies DocumentOp), + ], + issues: [], + reviewSafe: true, + }; + } + case "delete_blocks": { + if (targetBlockIds.length === 0) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch delete_blocks requires one or more target blocks.", + ); + } + if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch delete_blocks targets a missing block.", + ); + } + return { + ops: targetBlockIds.map((blockId) => ({ + type: "delete-block", + blockId, + }) satisfies DocumentOp), + issues: [], + reviewSafe: true, + }; + } + } +} + +export function buildOptimizedBlockReplacement( + editor: Editor, + targetBlockIds: string[], + markdown: string, +): PlanExecutionResult | null { + if (targetBlockIds.length === 0) { + return null; + } + + const targetBlocks = targetBlockIds + .map((blockId) => editor.getBlock(blockId)) + .filter((block): block is NonNullable => block != null); + if (targetBlocks.length !== targetBlockIds.length) { + return null; + } + + const parsedBlocks = buildDocumentWriteOps(editor, { + format: "markdown", + content: markdown, + surface: "ai-flow-patch-optimize", + }).blocks as PendingInlineBlock[]; + if ( + parsedBlocks.some((parsedBlock) => !isInlineConvertiblePendingBlock(parsedBlock)) + ) { + return null; + } + if (targetBlocks.some((block) => !isInlineConvertibleTargetBlock(block))) { + return null; + } + + const alignment = resolveInlineAlignmentPlan(targetBlocks, parsedBlocks); + const ops = buildInlineAlignmentOps(alignment.steps, targetBlocks, parsedBlocks); + + return { + ops, + issues: [], + reviewSafe: true, + metrics: { + flowPatchAlignment: alignment.metrics, + }, + }; +} + +export function buildInlineBlockRewriteOps( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): DocumentOp[] { + const ops: DocumentOp[] = []; + if (parsedBlock.type !== targetBlock.type) { + ops.push({ + type: "convert-block", + blockId: targetBlock.id, + newType: parsedBlock.type, + newProps: parsedBlock.props, + }); + } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { + ops.push({ + type: "update-block", + blockId: targetBlock.id, + props: parsedBlock.props, + }); + } + + const nextText = parsedBlock.content ?? ""; + const needsTextRewrite = + targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0; + if (needsTextRewrite) { + ops.push({ + type: "replace-text", + blockId: targetBlock.id, + offset: 0, + length: targetBlock.length(), + text: nextText, + }); + for (const mark of parsedBlock.marks ?? []) { + if (mark.end <= mark.start) { + continue; + } + ops.push({ + type: "format-text", + blockId: targetBlock.id, + offset: mark.start, + length: mark.end - mark.start, + marks: { [mark.type]: mark.props ?? true }, + }); + } + } + + return ops; +} + +export function buildInlineAlignmentOps( + alignment: InlineAlignmentStep[], + targetBlocks: Array>>, + parsedBlocks: PendingInlineBlock[], +): DocumentOp[] { + const ops: DocumentOp[] = []; + const pendingInserts: PendingInlineBlock[] = []; + let blockBefore: string | null = null; + + for (const step of alignment) { + if (step.kind === "insert") { + pendingInserts.push(parsedBlocks[step.parsedIndex!]!); + continue; + } + + if (step.kind === "substitute") { + const targetBlock = targetBlocks[step.targetIndex!]!; + if (pendingInserts.length > 0) { + const insertOps = buildInlinePendingBlockInsertOps( + pendingInserts, + resolveInsertionPosition(blockBefore, targetBlock.id), + ); + ops.push(...insertOps); + blockBefore = resolveLastInsertedBlockId(insertOps) ?? blockBefore; + pendingInserts.length = 0; + } + ops.push( + ...buildInlineBlockRewriteOps( + targetBlock, + parsedBlocks[step.parsedIndex!]!, + ), + ); + blockBefore = targetBlock.id; + continue; + } + + ops.push({ + type: "delete-block", + blockId: targetBlocks[step.targetIndex!]!.id, + }); + } + + if (pendingInserts.length > 0) { + ops.push( + ...buildInlinePendingBlockInsertOps( + pendingInserts, + resolveInsertionPosition(blockBefore, null), + ), + ); + } + + return ops; +} + +export function buildBlockUpdateExecution( + editor: Editor, + plan: BlockUpdatePlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockState = resolveBlockState(editor, context, plan.blockId); + if (!blockState) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + context.virtualBlocks.set(plan.blockId, { + ...blockState, + props: plan.props, + }); + + return { + ops: [{ + type: "update-block", + blockId: plan.blockId, + props: plan.props, + }], + issues: [], + reviewSafe: false, + }; +} + +export function isInlineConvertiblePendingBlock( + block: PendingInlineBlock, +): boolean { + return ( + (block.children?.length ?? 0) === 0 && + block.database == null && + block.type !== "table" && + block.type !== "database" + ); +} + +export function isInlineConvertibleTargetBlock( + block: NonNullable>, +): boolean { + return block.children.length === 0 && block.type !== "table" && block.type !== "database"; +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts new file mode 100644 index 0000000..3f51323 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts @@ -0,0 +1,358 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export function resolveInlineAlignmentPlan( + targetBlocks: Array>>, + parsedBlocks: PendingInlineBlock[], +): InlineAlignmentResolution { + const costs = Array.from( + { length: targetBlocks.length + 1 }, + () => new Array(parsedBlocks.length + 1).fill(0), + ); + + for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { + costs[targetIndex]![parsedBlocks.length] = + estimateInlineDeleteCost(targetBlocks[targetIndex]!) + + costs[targetIndex + 1]![parsedBlocks.length]!; + } + for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { + costs[targetBlocks.length]![parsedIndex] = + estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + + costs[targetBlocks.length]![parsedIndex + 1]!; + } + + for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { + for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { + const substituteCost = + estimateInlineSubstituteCost( + targetBlocks[targetIndex]!, + parsedBlocks[parsedIndex]!, + ) + costs[targetIndex + 1]![parsedIndex + 1]!; + const deleteCost = + estimateInlineDeleteCost(targetBlocks[targetIndex]!) + + costs[targetIndex + 1]![parsedIndex]!; + const insertCost = + estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + + costs[targetIndex]![parsedIndex + 1]!; + costs[targetIndex]![parsedIndex] = Math.min( + substituteCost, + deleteCost, + insertCost, + ); + } + } + + const alignment: InlineAlignmentStep[] = []; + let targetIndex = 0; + let parsedIndex = 0; + while (targetIndex < targetBlocks.length && parsedIndex < parsedBlocks.length) { + const bestCost = costs[targetIndex]![parsedIndex]!; + const substituteCost = + estimateInlineSubstituteCost( + targetBlocks[targetIndex]!, + parsedBlocks[parsedIndex]!, + ) + costs[targetIndex + 1]![parsedIndex + 1]!; + const deleteCost = + estimateInlineDeleteCost(targetBlocks[targetIndex]!) + + costs[targetIndex + 1]![parsedIndex]!; + const insertCost = + estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + + costs[targetIndex]![parsedIndex + 1]!; + + if ( + substituteCost === bestCost && + shouldPreferInlineSubstitution( + targetBlocks[targetIndex]!, + parsedBlocks[parsedIndex]!, + substituteCost, + deleteCost, + insertCost, + ) + ) { + alignment.push({ + kind: "substitute", + targetIndex, + parsedIndex, + }); + targetIndex += 1; + parsedIndex += 1; + continue; + } + + if (deleteCost === bestCost && deleteCost <= insertCost) { + alignment.push({ + kind: "delete", + targetIndex, + }); + targetIndex += 1; + continue; + } + alignment.push({ + kind: "insert", + parsedIndex, + }); + parsedIndex += 1; + } + + while (targetIndex < targetBlocks.length) { + alignment.push({ + kind: "delete", + targetIndex, + }); + targetIndex += 1; + } + while (parsedIndex < parsedBlocks.length) { + alignment.push({ + kind: "insert", + parsedIndex, + }); + parsedIndex += 1; + } + + return { + steps: alignment, + metrics: summarizeInlineAlignment(alignment, targetBlocks, parsedBlocks, costs[0]?.[0] ?? 0), + }; +} + +export function shouldPreferInlineSubstitution( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, + substituteCost: number, + deleteCost: number, + insertCost: number, +): boolean { + if (substituteCost < deleteCost && substituteCost < insertCost) { + return true; + } + if (substituteCost > deleteCost || substituteCost > insertCost) { + return false; + } + return areBlocksReusableMatch(targetBlock, parsedBlock); +} + +export function estimateInlineSubstituteCost( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): number { + return estimateInlineBlockRewriteCost(targetBlock, parsedBlock); +} + +export function estimateInlineDeleteCost( + _targetBlock: NonNullable>, +): number { + return 1; +} + +export function estimateInlineInsertCost(block: PendingInlineBlock): number { + let cost = 1; + if ((block.content ?? "").length > 0) { + cost += 1; + } + for (const mark of block.marks ?? []) { + if (mark.end > mark.start) { + cost += 1; + } + } + return cost; +} + +export function estimateInlineBlockRewriteCost( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): number { + let cost = 0; + if (parsedBlock.type !== targetBlock.type) { + cost += 1; + } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { + cost += 1; + } + + const nextText = parsedBlock.content ?? ""; + if (targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0) { + cost += 1; + } + for (const mark of parsedBlock.marks ?? []) { + if (mark.end > mark.start) { + cost += 1; + } + } + return cost; +} + +export function summarizeInlineAlignment( + alignment: InlineAlignmentStep[], + targetBlocks: Array>>, + parsedBlocks: PendingInlineBlock[], + estimatedOperationCost: number, +): FlowPatchAlignmentMetrics { + let preservedBlockCount = 0; + let rewrittenBlockCount = 0; + let unchangedBlockCount = 0; + let insertedBlockCount = 0; + let deletedBlockCount = 0; + + for (const step of alignment) { + if (step.kind === "insert") { + insertedBlockCount += 1; + continue; + } + if (step.kind === "delete") { + deletedBlockCount += 1; + continue; + } + + preservedBlockCount += 1; + const rewriteCost = estimateInlineBlockRewriteCost( + targetBlocks[step.targetIndex!]!, + parsedBlocks[step.parsedIndex!]!, + ); + if (rewriteCost > 0) { + rewrittenBlockCount += 1; + } else { + unchangedBlockCount += 1; + } + } + + return { + preservedBlockCount, + rewrittenBlockCount, + unchangedBlockCount, + insertedBlockCount, + deletedBlockCount, + estimatedOperationCost, + }; +} + +export function mergeFlowPatchAlignmentMetrics( + left: FlowPatchAlignmentMetrics | undefined, + right: FlowPatchAlignmentMetrics | undefined, +): FlowPatchAlignmentMetrics | undefined { + if (!left) { + return right; + } + if (!right) { + return left; + } + return { + preservedBlockCount: left.preservedBlockCount + right.preservedBlockCount, + rewrittenBlockCount: left.rewrittenBlockCount + right.rewrittenBlockCount, + unchangedBlockCount: left.unchangedBlockCount + right.unchangedBlockCount, + insertedBlockCount: left.insertedBlockCount + right.insertedBlockCount, + deletedBlockCount: left.deletedBlockCount + right.deletedBlockCount, + estimatedOperationCost: + left.estimatedOperationCost + right.estimatedOperationCost, + }; +} + +export function areBlocksReusableMatch( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): boolean { + return ( + targetBlock.type === parsedBlock.type && + areRecordValuesEqual(targetBlock.props, parsedBlock.props) && + areTextsReusableMatch(targetBlock.textContent(), parsedBlock.content ?? "") + ); +} + +export function areTextsReusableMatch(left: string, right: string): boolean { + const normalizedLeft = normalizeReusableText(left); + const normalizedRight = normalizeReusableText(right); + if (normalizedLeft === normalizedRight) { + return true; + } + if (normalizedLeft.length === 0 || normalizedRight.length === 0) { + return false; + } + if ( + normalizedLeft.includes(normalizedRight) || + normalizedRight.includes(normalizedLeft) + ) { + return true; + } + const sharedBoundaryLength = + resolveSharedPrefixLength(normalizedLeft, normalizedRight) + + resolveSharedSuffixLength(normalizedLeft, normalizedRight); + const minLength = Math.min(normalizedLeft.length, normalizedRight.length); + if (sharedBoundaryLength < Math.ceil(minLength * 0.5)) { + return false; + } + const maxLength = Math.max(normalizedLeft.length, normalizedRight.length); + const maxDistance = Math.max(4, Math.floor(maxLength * 0.4)); + return resolveLevenshteinDistance(normalizedLeft, normalizedRight, maxDistance) <= maxDistance; +} + +export function normalizeReusableText(text: string): string { + return text.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export function resolveSharedPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +export function resolveSharedSuffixLength(left: string, right: string): number { + let count = 0; + while ( + count < left.length && + count < right.length && + left[left.length - 1 - count] === right[right.length - 1 - count] + ) { + count += 1; + } + return count; +} + +export function resolveLevenshteinDistance( + left: string, + right: string, + maxDistance: number, +): number { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + const current = new Array(right.length + 1); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + current[0] = leftIndex; + let rowMin = current[0]!; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + current[rightIndex] = Math.min( + current[rightIndex - 1]! + 1, + previous[rightIndex]! + 1, + previous[rightIndex - 1]! + substitutionCost, + ); + rowMin = Math.min(rowMin, current[rightIndex]!); + } + if (rowMin > maxDistance) { + return maxDistance + 1; + } + for (let index = 0; index <= right.length; index += 1) { + previous[index] = current[index]!; + } + } + + return previous[right.length]!; +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts new file mode 100644 index 0000000..37ac426 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts @@ -0,0 +1,377 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export function buildInlinePendingBlockInsertOps( + blocks: PendingInlineBlock[], + position: { before: string } | { after: string } | "last", +): DocumentOp[] { + const ops: DocumentOp[] = []; + let currentPosition = position; + for (const block of blocks) { + const blockId = generateId(); + ops.push({ + type: "insert-block", + blockId, + blockType: block.type, + props: block.props, + position: currentPosition, + }); + if ((block.content ?? "").length > 0) { + ops.push({ + type: "insert-text", + blockId, + offset: 0, + text: block.content!, + }); + } + for (const mark of block.marks ?? []) { + if (mark.end <= mark.start) { + continue; + } + ops.push({ + type: "format-text", + blockId, + offset: mark.start, + length: mark.end - mark.start, + marks: { [mark.type]: mark.props ?? true }, + }); + } + currentPosition = { after: blockId }; + } + return ops; +} + +export function resolveLastInsertedBlockId(ops: DocumentOp[]): string | null { + for (let index = ops.length - 1; index >= 0; index -= 1) { + const op = ops[index]!; + if (op.type === "insert-block") { + return op.blockId; + } + } + return null; +} + +export function resolveInsertionPosition( + blockBefore: string | null, + blockAfter: string | null, +): { before: string } | { after: string } | "last" { + if (blockBefore) { + return { after: blockBefore }; + } + if (blockAfter) { + return { before: blockAfter }; + } + return "last"; +} + +export function areRecordValuesEqual( + left: Record, + right: Record, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + return leftEntries.every(([key, value]) => { + if (!(key in right)) { + return false; + } + return JSON.stringify(value) === JSON.stringify(right[key]); + }); +} + +export function buildBlockMoveExecution( + editor: Editor, + plan: BlockMovePlan, + context: PlanExecutionContext, +): PlanExecutionResult { + if (!resolveBlockState(editor, context, plan.blockId)) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + + return { + ops: [{ + type: "move-block", + blockId: plan.blockId, + position: plan.position, + }], + issues: [], + reviewSafe: true, + }; +} + +export function buildBlockConvertExecution( + editor: Editor, + plan: BlockConvertPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockState = resolveBlockState(editor, context, plan.blockId); + if (!blockState) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + context.virtualBlocks.set( + plan.blockId, + createVirtualBlockState( + plan.newType, + plan.props ?? blockState.props, + blockState.textLength, + ), + ); + + return { + ops: [{ + type: "convert-block", + blockId: plan.blockId, + newType: plan.newType, + newProps: plan.props, + }], + issues: [], + reviewSafe: true, + }; +} + +export function buildDatabaseEditExecution( + editor: Editor, + plan: DatabaseEditPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const block = editor.getBlock(plan.blockId); + const virtualBlock = context.virtualBlocks.get(plan.blockId) ?? null; + const effectiveBlockType = virtualBlock?.type ?? block?.type ?? null; + if (!effectiveBlockType) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + if (effectiveBlockType !== "database") { + return withIssue( + `${plan.kind}.blockId`, + "unsupported-target", + `Block "${plan.blockId}" is not a database block.`, + ); + } + + const ops: DocumentOp[] = []; + const knownColumnIds = new Set([ + ...(block?.type === "database" + ? block.tableColumns().map((column) => column.id) + : []), + ...(virtualBlock?.database?.columnIds ?? []), + ]); + const knownRowIds = new Set([ + ...(block?.type === "database" ? readDatabaseRowIds(block) : []), + ...(virtualBlock?.database?.rowIds ?? []), + ]); + const knownViewIds = new Set([ + ...(block?.type === "database" + ? block.databaseViews().map((view) => view.id) + : []), + ...(virtualBlock?.database?.viewIds ?? []), + ]); + + for (const step of plan.steps) { + switch (step.op) { + case "add_column": + ops.push({ + type: "database-add-column", + blockId: plan.blockId, + column: step.column, + }); + knownColumnIds.add(step.column.id); + break; + case "update_column": + ops.push({ + type: "database-update-column", + blockId: plan.blockId, + columnId: step.columnId, + patch: step.patch, + }); + break; + case "insert_row": { + const rowId = step.rowId ?? generateId(); + ops.push({ + type: "database-insert-row", + blockId: plan.blockId, + rowId, + values: stringifyRecord(step.values), + }); + knownRowIds.add(rowId); + break; + } + case "update_cell": + ops.push({ + type: "database-update-cell", + blockId: plan.blockId, + rowId: step.rowId, + columnId: step.columnId, + value: stringifyDatabaseValue(step.value), + }); + break; + case "add_view": + ops.push({ + type: "database-add-view", + blockId: plan.blockId, + view: step.view, + }); + knownViewIds.add(step.view.id); + break; + case "set_active_view": + ops.push({ + type: "database-set-active-view", + blockId: plan.blockId, + viewId: step.viewId, + }); + break; + } + } + + if (virtualBlock?.database) { + virtualBlock.database.columnIds = knownColumnIds; + virtualBlock.database.rowIds = knownRowIds; + virtualBlock.database.viewIds = knownViewIds; + } + + return { + ops, + issues: [], + reviewSafe: false, + }; +} + +export function buildReviewBundleExecution( + editor: Editor, + plan: ReviewBundlePlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const ops: DocumentOp[] = []; + const issues: PlanExecutionIssue[] = []; + let reviewSafe = true; + + for (let index = 0; index < plan.plans.length; index += 1) { + const nestedPlan = plan.plans[index]!; + const execution = buildPlanExecution(editor, nestedPlan, context); + ops.push(...execution.ops); + issues.push( + ...execution.issues.map((issue) => ({ + ...issue, + path: `${plan.kind}.plans[${index}].${issue.path}`, + })), + ); + reviewSafe &&= execution.reviewSafe; + } + + return { + ops, + issues, + reviewSafe, + }; +} + +export function createVirtualBlockState( + blockType: string, + props: Record = {}, + text: string | number = 0, +): VirtualBlockState { + const textLength = typeof text === "number" ? text : text.length; + if (blockType === "database") { + return { + type: blockType, + props, + textLength, + database: { + columnIds: new Set(), + rowIds: new Set(), + viewIds: new Set(), + }, + }; + } + return { + type: blockType, + props, + textLength, + }; +} + +export function resolveBlockState( + editor: Editor, + context: PlanExecutionContext, + blockId: string, +): VirtualBlockState | null { + const virtualBlock = context.virtualBlocks.get(blockId) ?? null; + if (virtualBlock) { + return virtualBlock; + } + + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + + const nextState = createVirtualBlockState( + block.type, + { ...block.props }, + block.length(), + ); + if (block.type === "database") { + nextState.database = { + columnIds: new Set(block.tableColumns().map((column) => column.id)), + rowIds: new Set(readDatabaseRowIds(block)), + viewIds: new Set(block.databaseViews().map((view) => view.id)), + }; + } + return nextState; +} + +export function withIssue( + path: string, + code: PlanExecutionIssue["code"], + message: string, +): PlanExecutionResult { + return { + ops: [], + issues: [{ path, code, message }], + reviewSafe: false, + }; +} + +export function stringifyRecord( + value: Record, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + stringifyDatabaseValue(entryValue), + ]), + ); +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts new file mode 100644 index 0000000..ada586b --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts @@ -0,0 +1,58 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; + +export function readDatabaseRowIds( + block: ReturnType, +): string[] { + if (!block) { + return []; + } + const rowIds: string[] = []; + for (let index = 0; index < block.tableRowCount(); index += 1) { + const rowId = block.tableRow(index)?.id; + if (rowId) { + rowIds.push(rowId); + } + } + return rowIds; +} + +export function stringifyDatabaseValue(value: unknown): string { + if (value == null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/extensions/ai/src/runtime/planValidation.ts b/packages/extensions/ai/src/runtime/planValidation.ts index 4fd18df..da4f788 100644 --- a/packages/extensions/ai/src/runtime/planValidation.ts +++ b/packages/extensions/ai/src/runtime/planValidation.ts @@ -1,961 +1,2 @@ -import type { DocumentProfile } from "@pen/types"; -import type { AITargetKind } from "./contracts"; -import { - DOCUMENT_MUTATION_PLAN_KINDS, - type DocumentMutationPlan, - type DocumentMutationPlanKind, -} from "./planTypes"; - -export const PLAN_VALIDATION_SEVERITIES = ["info", "warn", "error"] as const; - -export type PlanValidationSeverity = - (typeof PLAN_VALIDATION_SEVERITIES)[number]; - -export interface PlanValidationIssue { - path: string; - code: - | "missing-field" - | "invalid-kind" - | "invalid-shape" - | "invalid-step" - | "invalid-nested-plan" - | "unsupported-target-kind" - | "unknown-block-type" - | "out-of-scope-target" - | "read-only-target"; - severity: PlanValidationSeverity; - message: string; -} - -export interface PlanValidationContext { - documentProfile?: DocumentProfile; - targetKind?: AITargetKind; - knownBlockTypes?: readonly string[]; - allowedTargetBlockIds?: readonly string[]; - editableTargetBlockIds?: readonly string[]; -} - -export interface PlanValidationResult { - valid: boolean; - issues: PlanValidationIssue[]; -} - -const DOCUMENT_MUTATION_PLAN_KIND_SET = new Set( - DOCUMENT_MUTATION_PLAN_KINDS, -); - -const TEXT_EDIT_OPERATIONS = new Set(["replace", "insert", "append"]); -const FLOW_PATCH_EDIT_OPERATIONS = new Set([ - "replace_text", - "append_text", - "insert_before", - "insert_after", - "replace_blocks", - "delete_blocks", -]); -const DATABASE_EDIT_STEP_OPERATIONS = new Set([ - "add_column", - "update_column", - "insert_row", - "update_cell", - "add_view", - "set_active_view", -]); -const POSITION_LITERALS = new Set(["first", "last"]); - -export function validateDocumentMutationPlanShape( - plan: unknown, - _context?: PlanValidationContext, -): PlanValidationResult { - const issues: PlanValidationIssue[] = []; - validatePlan(plan, "plan", issues); - if (_context) { - validatePlanSemantics(plan, "plan", issues, _context); - } - return { - valid: !issues.some((issue) => issue.severity === "error"), - issues, - }; -} - -export function isDocumentMutationPlan( - value: unknown, -): value is DocumentMutationPlan { - return validateDocumentMutationPlanShape(value).valid; -} - -function validatePlan( - plan: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const record = asRecord(plan); - if (!record) { - pushIssue(issues, path, "invalid-shape", "Plan must be an object."); - return; - } - - if (!isNonEmptyString(record.kind)) { - pushIssue(issues, `${path}.kind`, "missing-field", "Plan kind is required."); - return; - } - - if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { - pushIssue( - issues, - `${path}.kind`, - "invalid-kind", - `Unsupported plan kind "${record.kind}".`, - ); - return; - } - - switch (record.kind as DocumentMutationPlanKind) { - case "text_edit": - validateTextEditPlan(record, path, issues); - return; - case "flow_patch": - validateFlowPatchPlan(record, path, issues); - return; - case "block_insert": - validateBlockInsertPlan(record, path, issues); - return; - case "block_update": - validateBlockUpdatePlan(record, path, issues); - return; - case "block_move": - validateBlockMovePlan(record, path, issues); - return; - case "block_convert": - validateBlockConvertPlan(record, path, issues); - return; - case "database_edit": - validateDatabaseEditPlan(record, path, issues); - return; - case "review_bundle": - validateReviewBundlePlan(record, path, issues); - return; - } -} - -function validateTextEditPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - const target = asRecord(plan.target); - if (!target) { - pushIssue( - issues, - `${path}.target`, - "invalid-shape", - "Text edit target must be an object.", - ); - } else { - requireString(target, "blockId", `${path}.target`, issues); - if (target.range !== undefined) { - validateTextRange(target.range, `${path}.target.range`, issues); - } - } - - if (!isNonEmptyString(plan.operation) || !TEXT_EDIT_OPERATIONS.has(plan.operation)) { - pushIssue( - issues, - `${path}.operation`, - "invalid-shape", - "Text edit operation must be replace, insert, or append.", - ); - } - - requireString(plan, "text", path, issues); - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateFlowPatchPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "instructions", path, issues); - if ( - plan.scope !== undefined && - plan.scope !== "single-block" && - plan.scope !== "adjacent-blocks" && - plan.scope !== "section" - ) { - pushIssue( - issues, - `${path}.scope`, - "invalid-shape", - 'Flow patch scope must be "single-block", "adjacent-blocks", or "section".', - ); - } - if (plan.targetSpanId !== undefined && typeof plan.targetSpanId !== "string") { - pushIssue( - issues, - `${path}.targetSpanId`, - "invalid-shape", - "targetSpanId must be a string when provided.", - ); - } - if (!Array.isArray(plan.edits)) { - pushIssue( - issues, - `${path}.edits`, - "invalid-shape", - "Flow patch edits must be an array.", - ); - } else { - plan.edits.forEach((edit, index) => { - validateFlowPatchEdit(edit, `${path}.edits[${index}]`, issues); - }); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateBlockInsertPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - if (plan.blockId !== undefined && typeof plan.blockId !== "string") { - pushIssue( - issues, - `${path}.blockId`, - "invalid-shape", - "blockId must be a string when provided.", - ); - } - requireString(plan, "blockType", path, issues); - validatePosition(plan.position, `${path}.position`, issues); - if (plan.props !== undefined && !isRecord(plan.props)) { - pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); - } - if (plan.initialText !== undefined && typeof plan.initialText !== "string") { - pushIssue( - issues, - `${path}.initialText`, - "invalid-shape", - "Initial text must be a string.", - ); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateFlowPatchEdit( - edit: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const record = asRecord(edit); - if (!record) { - pushIssue(issues, path, "invalid-shape", "Flow patch edit must be an object."); - return; - } - if ( - !isNonEmptyString(record.operation) || - !FLOW_PATCH_EDIT_OPERATIONS.has(record.operation) - ) { - pushIssue( - issues, - `${path}.operation`, - "invalid-shape", - "Flow patch edit operation is unsupported.", - ); - } - const locator = asRecord(record.locator); - if (!locator) { - pushIssue( - issues, - `${path}.locator`, - "invalid-shape", - "Flow patch edit locator must be an object.", - ); - } else { - if (locator.blockId !== undefined && typeof locator.blockId !== "string") { - pushIssue( - issues, - `${path}.locator.blockId`, - "invalid-shape", - "blockId must be a string when provided.", - ); - } - if ( - locator.blockIds !== undefined && - (!Array.isArray(locator.blockIds) || - !locator.blockIds.every((blockId) => typeof blockId === "string")) - ) { - pushIssue( - issues, - `${path}.locator.blockIds`, - "invalid-shape", - "blockIds must be an array of strings when provided.", - ); - } - for (const field of [ - "retrievedSpanId", - "expectedBlockType", - "anchorBefore", - "anchorAfter", - ] as const) { - if (locator[field] !== undefined && typeof locator[field] !== "string") { - pushIssue( - issues, - `${path}.locator.${field}`, - "invalid-shape", - `${field} must be a string when provided.`, - ); - } - } - } - - if (record.text !== undefined && typeof record.text !== "string") { - pushIssue( - issues, - `${path}.text`, - "invalid-shape", - "text must be a string when provided.", - ); - } - if (record.markdown !== undefined && typeof record.markdown !== "string") { - pushIssue( - issues, - `${path}.markdown`, - "invalid-shape", - "markdown must be a string when provided.", - ); - } - validateConfidence(record.confidence, `${path}.confidence`, issues); -} - -function validateBlockUpdatePlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - if (!isRecord(plan.props)) { - pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateBlockMovePlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - validatePosition(plan.position, `${path}.position`, issues); - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateBlockConvertPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - requireString(plan, "newType", path, issues); - if (plan.props !== undefined && !isRecord(plan.props)) { - pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateDatabaseEditPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - if (!Array.isArray(plan.steps)) { - pushIssue( - issues, - `${path}.steps`, - "invalid-shape", - "Database edit steps must be an array.", - ); - } else { - plan.steps.forEach((step, index) => { - validateDatabaseEditStep(step, `${path}.steps[${index}]`, issues); - }); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateReviewBundlePlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "label", path, issues); - requireString(plan, "reason", path, issues); - - if (!Array.isArray(plan.plans)) { - pushIssue( - issues, - `${path}.plans`, - "invalid-shape", - "Review bundle plans must be an array.", - ); - } else { - plan.plans.forEach((childPlan, index) => { - const childIssuesBefore = issues.length; - validatePlan(childPlan, `${path}.plans[${index}]`, issues); - if (issues.length > childIssuesBefore) { - pushIssue( - issues, - `${path}.plans[${index}]`, - "invalid-nested-plan", - "Review bundle contains an invalid nested plan.", - ); - } - }); - } - - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validatePlanSemantics( - plan: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - const record = asRecord(plan); - if (!record || !isNonEmptyString(record.kind)) { - return; - } - - if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { - return; - } - - const kind = record.kind as DocumentMutationPlanKind; - validateTargetKindCompatibility(kind, path, issues, context); - - switch (kind) { - case "text_edit": { - const target = asRecord(record.target); - if (!target) { - return; - } - validateMutableTargetBlockReference( - target.blockId, - `${path}.target.blockId`, - issues, - context, - ); - return; - } - case "flow_patch": { - if (!Array.isArray(record.edits)) { - return; - } - record.edits.forEach((edit, index) => { - validateFlowPatchEditSemantics( - edit, - `${path}.edits[${index}]`, - issues, - context, - ); - }); - return; - } - case "block_insert": - validateKnownBlockType(record.blockType, `${path}.blockType`, issues, context); - validatePositionSemantics(record.position, `${path}.position`, issues, context); - return; - case "block_update": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - return; - case "block_move": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - validatePositionSemantics(record.position, `${path}.position`, issues, context); - return; - case "block_convert": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - validateKnownBlockType(record.newType, `${path}.newType`, issues, context); - return; - case "database_edit": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - return; - case "review_bundle": - if (!Array.isArray(record.plans)) { - return; - } - record.plans.forEach((childPlan, index) => { - validatePlanSemantics( - childPlan, - `${path}.plans[${index}]`, - issues, - context, - ); - }); - return; - } -} - -function validateTargetKindCompatibility( - kind: DocumentMutationPlanKind, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if (!context.targetKind) { - return; - } - - if (isPlanKindAllowedForTarget(kind, context.targetKind)) { - return; - } - - pushIssue( - issues, - `${path}.kind`, - "unsupported-target-kind", - `Plan kind "${kind}" is not supported for ${context.targetKind} targets.`, - ); -} - -function validateFlowPatchEditSemantics( - edit: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - const record = asRecord(edit); - if (!record) { - return; - } - - const locator = asRecord(record.locator); - if (!locator) { - return; - } - - validateMutableTargetBlockReference( - locator.blockId, - `${path}.locator.blockId`, - issues, - context, - ); - - if (Array.isArray(locator.blockIds)) { - locator.blockIds.forEach((blockId, index) => { - validateMutableTargetBlockReference( - blockId, - `${path}.locator.blockIds[${index}]`, - issues, - context, - ); - }); - } - - validateScopedBlockReference( - locator.anchorBefore, - `${path}.locator.anchorBefore`, - issues, - context, - ); - validateScopedBlockReference( - locator.anchorAfter, - `${path}.locator.anchorAfter`, - issues, - context, - ); - validateKnownBlockType( - locator.expectedBlockType, - `${path}.locator.expectedBlockType`, - issues, - context, - ); -} - -function validatePositionSemantics( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - const position = asRecord(value); - if (!position) { - return; - } - - validateScopedBlockReference(position.before, `${path}.before`, issues, context); - validateScopedBlockReference(position.after, `${path}.after`, issues, context); - validateScopedBlockReference(position.parent, `${path}.parent`, issues, context); -} - -function validateKnownBlockType( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if ( - !isNonEmptyString(value) || - !context.knownBlockTypes || - context.knownBlockTypes.includes(value) - ) { - return; - } - - pushIssue( - issues, - path, - "unknown-block-type", - `Block type "${value}" is not available in ${context.documentProfile ?? "this"} documents.`, - ); -} - -function validateMutableTargetBlockReference( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if (!isNonEmptyString(value)) { - return; - } - - if ( - context.allowedTargetBlockIds && - !context.allowedTargetBlockIds.includes(value) - ) { - pushIssue( - issues, - path, - "out-of-scope-target", - `Block "${value}" is outside the validated mutation scope.`, - ); - return; - } - - if ( - context.editableTargetBlockIds && - !context.editableTargetBlockIds.includes(value) - ) { - pushIssue( - issues, - path, - "read-only-target", - `Block "${value}" is not editable in ${context.documentProfile ?? "this"} documents.`, - ); - } -} - -function validateScopedBlockReference( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if ( - !isNonEmptyString(value) || - !context.allowedTargetBlockIds || - context.allowedTargetBlockIds.includes(value) - ) { - return; - } - - pushIssue( - issues, - path, - "out-of-scope-target", - `Block "${value}" is outside the validated mutation scope.`, - ); -} - -function validateDatabaseEditStep( - step: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const record = asRecord(step); - if (!record) { - pushIssue( - issues, - path, - "invalid-step", - "Database edit step must be an object.", - ); - return; - } - - if ( - !isNonEmptyString(record.op) || - !DATABASE_EDIT_STEP_OPERATIONS.has(record.op) - ) { - pushIssue( - issues, - `${path}.op`, - "invalid-step", - "Unsupported database edit step operation.", - ); - return; - } - - switch (record.op) { - case "add_column": - if (!isRecord(record.column)) { - pushIssue( - issues, - `${path}.column`, - "invalid-step", - "Column must be an object.", - ); - } - return; - case "update_column": - requireString(record, "columnId", path, issues); - if (!isRecord(record.patch)) { - pushIssue( - issues, - `${path}.patch`, - "invalid-step", - "Column patch must be an object.", - ); - } - return; - case "insert_row": - if (record.rowId !== undefined && typeof record.rowId !== "string") { - pushIssue( - issues, - `${path}.rowId`, - "invalid-step", - "Row id must be a string.", - ); - } - if (!isRecord(record.values)) { - pushIssue( - issues, - `${path}.values`, - "invalid-step", - "Row values must be an object.", - ); - } - return; - case "update_cell": - requireString(record, "rowId", path, issues); - requireString(record, "columnId", path, issues); - return; - case "add_view": - if (!isRecord(record.view)) { - pushIssue( - issues, - `${path}.view`, - "invalid-step", - "View must be an object.", - ); - } - return; - case "set_active_view": - requireString(record, "viewId", path, issues); - return; - } -} - -function validateTextRange( - value: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const range = asRecord(value); - if (!range) { - pushIssue(issues, path, "invalid-shape", "Range must be an object."); - return; - } - - requireNumber(range, "startOffset", path, issues); - requireNumber(range, "endOffset", path, issues); -} - -function validateConfidence( - value: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - if (value === undefined) { - return; - } - - const confidence = asRecord(value); - if (!confidence) { - pushIssue( - issues, - path, - "invalid-shape", - "Confidence must be an object when provided.", - ); - return; - } - - if (confidence.score !== undefined && !isFiniteNumber(confidence.score)) { - pushIssue( - issues, - `${path}.score`, - "invalid-shape", - "Confidence score must be a number.", - ); - } - if (confidence.reason !== undefined && typeof confidence.reason !== "string") { - pushIssue( - issues, - `${path}.reason`, - "invalid-shape", - "Confidence reason must be a string.", - ); - } -} - -function validatePosition( - value: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - if (typeof value === "string") { - if (POSITION_LITERALS.has(value)) { - return; - } - pushIssue(issues, path, "invalid-shape", "Position string is invalid."); - return; - } - - const position = asRecord(value); - if (!position) { - pushIssue(issues, path, "invalid-shape", "Position must be an object."); - return; - } - - if (isNonEmptyString(position.before)) { - return; - } - if (isNonEmptyString(position.after)) { - return; - } - if (isNonEmptyString(position.parent) && isFiniteNumber(position.index)) { - return; - } - - pushIssue(issues, path, "invalid-shape", "Position object is invalid."); -} - -function requireString( - record: Record, - field: string, - path: string, - issues: PlanValidationIssue[], -): void { - const value = record[field]; - if (typeof value === "string" && value.length > 0) { - return; - } - - pushIssue( - issues, - `${path}.${field}`, - value === undefined ? "missing-field" : "invalid-shape", - `${field} must be a non-empty string.`, - ); -} - -function requireNumber( - record: Record, - field: string, - path: string, - issues: PlanValidationIssue[], -): void { - const value = record[field]; - if (isFiniteNumber(value)) { - return; - } - - pushIssue( - issues, - `${path}.${field}`, - value === undefined ? "missing-field" : "invalid-shape", - `${field} must be a number.`, - ); -} - -function isPlanKindAllowedForTarget( - kind: DocumentMutationPlanKind, - targetKind: AITargetKind, -): boolean { - switch (targetKind) { - case "database": - return ( - kind === "block_insert" || - kind === "block_update" || - kind === "block_move" || - kind === "block_convert" || - kind === "database_edit" || - kind === "review_bundle" - ); - case "text": - return kind === "text_edit" || kind === "flow_patch" || kind === "review_bundle"; - case "block": - return kind !== "database_edit"; - case "table": - return ( - kind === "flow_patch" || - kind === "block_update" || - kind === "block_move" || - kind === "block_convert" || - kind === "review_bundle" - ); - } -} - -function pushIssue( - issues: PlanValidationIssue[], - path: string, - code: PlanValidationIssue["code"], - message: string, -): void { - issues.push({ - path, - code, - severity: "error", - message, - }); -} - -function asRecord(value: unknown): Record | null { - return isRecord(value) ? value : null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} +export { PLAN_VALIDATION_SEVERITIES, validateDocumentMutationPlanShape, isDocumentMutationPlan } from "./planValidationParts/planValidationPart1"; +export type { PlanValidationSeverity, PlanValidationIssue, PlanValidationContext, PlanValidationResult } from "./planValidationParts/planValidationPart1"; diff --git a/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts new file mode 100644 index 0000000..68aa85a --- /dev/null +++ b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts @@ -0,0 +1,370 @@ +// @ts-nocheck +import type { DocumentProfile } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import { + DOCUMENT_MUTATION_PLAN_KINDS, + type DocumentMutationPlan, + type DocumentMutationPlanKind, +} from "../planTypes"; +import { validateDatabaseEditPlan, validateReviewBundlePlan, validatePlanSemantics, validateTargetKindCompatibility, validateFlowPatchEditSemantics, validatePositionSemantics, validateKnownBlockType, validateMutableTargetBlockReference, validateScopedBlockReference } from "./planValidationPart2"; +import { validateDatabaseEditStep, validateTextRange, validateConfidence, validatePosition, requireString, requireNumber, isPlanKindAllowedForTarget, pushIssue, asRecord, isRecord, isFiniteNumber, isNonEmptyString } from "./planValidationPart3"; + +export const PLAN_VALIDATION_SEVERITIES = ["info", "warn", "error"] as const; + +export type PlanValidationSeverity = + (typeof PLAN_VALIDATION_SEVERITIES)[number]; + +export interface PlanValidationIssue { + path: string; + code: + | "missing-field" + | "invalid-kind" + | "invalid-shape" + | "invalid-step" + | "invalid-nested-plan" + | "unsupported-target-kind" + | "unknown-block-type" + | "out-of-scope-target" + | "read-only-target"; + severity: PlanValidationSeverity; + message: string; +} + +export interface PlanValidationContext { + documentProfile?: DocumentProfile; + targetKind?: AITargetKind; + knownBlockTypes?: readonly string[]; + allowedTargetBlockIds?: readonly string[]; + editableTargetBlockIds?: readonly string[]; +} + +export interface PlanValidationResult { + valid: boolean; + issues: PlanValidationIssue[]; +} + +export const DOCUMENT_MUTATION_PLAN_KIND_SET = new Set( + DOCUMENT_MUTATION_PLAN_KINDS, +); + +export const TEXT_EDIT_OPERATIONS = new Set(["replace", "insert", "append"]); + +export const FLOW_PATCH_EDIT_OPERATIONS = new Set([ + "replace_text", + "append_text", + "insert_before", + "insert_after", + "replace_blocks", + "delete_blocks", +]); + +export const DATABASE_EDIT_STEP_OPERATIONS = new Set([ + "add_column", + "update_column", + "insert_row", + "update_cell", + "add_view", + "set_active_view", +]); + +export const POSITION_LITERALS = new Set(["first", "last"]); + +export function validateDocumentMutationPlanShape( + plan: unknown, + _context?: PlanValidationContext, +): PlanValidationResult { + const issues: PlanValidationIssue[] = []; + validatePlan(plan, "plan", issues); + if (_context) { + validatePlanSemantics(plan, "plan", issues, _context); + } + return { + valid: !issues.some((issue) => issue.severity === "error"), + issues, + }; +} + +export function isDocumentMutationPlan( + value: unknown, +): value is DocumentMutationPlan { + return validateDocumentMutationPlanShape(value).valid; +} + +export function validatePlan( + plan: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const record = asRecord(plan); + if (!record) { + pushIssue(issues, path, "invalid-shape", "Plan must be an object."); + return; + } + + if (!isNonEmptyString(record.kind)) { + pushIssue(issues, `${path}.kind`, "missing-field", "Plan kind is required."); + return; + } + + if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { + pushIssue( + issues, + `${path}.kind`, + "invalid-kind", + `Unsupported plan kind "${record.kind}".`, + ); + return; + } + + switch (record.kind as DocumentMutationPlanKind) { + case "text_edit": + validateTextEditPlan(record, path, issues); + return; + case "flow_patch": + validateFlowPatchPlan(record, path, issues); + return; + case "block_insert": + validateBlockInsertPlan(record, path, issues); + return; + case "block_update": + validateBlockUpdatePlan(record, path, issues); + return; + case "block_move": + validateBlockMovePlan(record, path, issues); + return; + case "block_convert": + validateBlockConvertPlan(record, path, issues); + return; + case "database_edit": + validateDatabaseEditPlan(record, path, issues); + return; + case "review_bundle": + validateReviewBundlePlan(record, path, issues); + return; + } +} + +export function validateTextEditPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + const target = asRecord(plan.target); + if (!target) { + pushIssue( + issues, + `${path}.target`, + "invalid-shape", + "Text edit target must be an object.", + ); + } else { + requireString(target, "blockId", `${path}.target`, issues); + if (target.range !== undefined) { + validateTextRange(target.range, `${path}.target.range`, issues); + } + } + + if (!isNonEmptyString(plan.operation) || !TEXT_EDIT_OPERATIONS.has(plan.operation)) { + pushIssue( + issues, + `${path}.operation`, + "invalid-shape", + "Text edit operation must be replace, insert, or append.", + ); + } + + requireString(plan, "text", path, issues); + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateFlowPatchPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "instructions", path, issues); + if ( + plan.scope !== undefined && + plan.scope !== "single-block" && + plan.scope !== "adjacent-blocks" && + plan.scope !== "section" + ) { + pushIssue( + issues, + `${path}.scope`, + "invalid-shape", + 'Flow patch scope must be "single-block", "adjacent-blocks", or "section".', + ); + } + if (plan.targetSpanId !== undefined && typeof plan.targetSpanId !== "string") { + pushIssue( + issues, + `${path}.targetSpanId`, + "invalid-shape", + "targetSpanId must be a string when provided.", + ); + } + if (!Array.isArray(plan.edits)) { + pushIssue( + issues, + `${path}.edits`, + "invalid-shape", + "Flow patch edits must be an array.", + ); + } else { + plan.edits.forEach((edit, index) => { + validateFlowPatchEdit(edit, `${path}.edits[${index}]`, issues); + }); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateBlockInsertPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + if (plan.blockId !== undefined && typeof plan.blockId !== "string") { + pushIssue( + issues, + `${path}.blockId`, + "invalid-shape", + "blockId must be a string when provided.", + ); + } + requireString(plan, "blockType", path, issues); + validatePosition(plan.position, `${path}.position`, issues); + if (plan.props !== undefined && !isRecord(plan.props)) { + pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); + } + if (plan.initialText !== undefined && typeof plan.initialText !== "string") { + pushIssue( + issues, + `${path}.initialText`, + "invalid-shape", + "Initial text must be a string.", + ); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateFlowPatchEdit( + edit: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const record = asRecord(edit); + if (!record) { + pushIssue(issues, path, "invalid-shape", "Flow patch edit must be an object."); + return; + } + if ( + !isNonEmptyString(record.operation) || + !FLOW_PATCH_EDIT_OPERATIONS.has(record.operation) + ) { + pushIssue( + issues, + `${path}.operation`, + "invalid-shape", + "Flow patch edit operation is unsupported.", + ); + } + const locator = asRecord(record.locator); + if (!locator) { + pushIssue( + issues, + `${path}.locator`, + "invalid-shape", + "Flow patch edit locator must be an object.", + ); + } else { + if (locator.blockId !== undefined && typeof locator.blockId !== "string") { + pushIssue( + issues, + `${path}.locator.blockId`, + "invalid-shape", + "blockId must be a string when provided.", + ); + } + if ( + locator.blockIds !== undefined && + (!Array.isArray(locator.blockIds) || + !locator.blockIds.every((blockId) => typeof blockId === "string")) + ) { + pushIssue( + issues, + `${path}.locator.blockIds`, + "invalid-shape", + "blockIds must be an array of strings when provided.", + ); + } + for (const field of [ + "retrievedSpanId", + "expectedBlockType", + "anchorBefore", + "anchorAfter", + ] as const) { + if (locator[field] !== undefined && typeof locator[field] !== "string") { + pushIssue( + issues, + `${path}.locator.${field}`, + "invalid-shape", + `${field} must be a string when provided.`, + ); + } + } + } + + if (record.text !== undefined && typeof record.text !== "string") { + pushIssue( + issues, + `${path}.text`, + "invalid-shape", + "text must be a string when provided.", + ); + } + if (record.markdown !== undefined && typeof record.markdown !== "string") { + pushIssue( + issues, + `${path}.markdown`, + "invalid-shape", + "markdown must be a string when provided.", + ); + } + validateConfidence(record.confidence, `${path}.confidence`, issues); +} + +export function validateBlockUpdatePlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + if (!isRecord(plan.props)) { + pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateBlockMovePlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + validatePosition(plan.position, `${path}.position`, issues); + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateBlockConvertPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + requireString(plan, "newType", path, issues); + if (plan.props !== undefined && !isRecord(plan.props)) { + pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} diff --git a/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts new file mode 100644 index 0000000..fd35321 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts @@ -0,0 +1,337 @@ +// @ts-nocheck +import type { DocumentProfile } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import { + DOCUMENT_MUTATION_PLAN_KINDS, + type DocumentMutationPlan, + type DocumentMutationPlanKind, +} from "../planTypes"; +import { PLAN_VALIDATION_SEVERITIES, DOCUMENT_MUTATION_PLAN_KIND_SET, TEXT_EDIT_OPERATIONS, FLOW_PATCH_EDIT_OPERATIONS, DATABASE_EDIT_STEP_OPERATIONS, POSITION_LITERALS, validateDocumentMutationPlanShape, isDocumentMutationPlan, validatePlan, validateTextEditPlan, validateFlowPatchPlan, validateBlockInsertPlan, validateFlowPatchEdit, validateBlockUpdatePlan, validateBlockMovePlan, validateBlockConvertPlan } from "./planValidationPart1"; +import type { PlanValidationSeverity, PlanValidationIssue, PlanValidationContext, PlanValidationResult } from "./planValidationPart1"; +import { validateDatabaseEditStep, validateTextRange, validateConfidence, validatePosition, requireString, requireNumber, isPlanKindAllowedForTarget, pushIssue, asRecord, isRecord, isFiniteNumber, isNonEmptyString } from "./planValidationPart3"; + +export function validateDatabaseEditPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + if (!Array.isArray(plan.steps)) { + pushIssue( + issues, + `${path}.steps`, + "invalid-shape", + "Database edit steps must be an array.", + ); + } else { + plan.steps.forEach((step, index) => { + validateDatabaseEditStep(step, `${path}.steps[${index}]`, issues); + }); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateReviewBundlePlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "label", path, issues); + requireString(plan, "reason", path, issues); + + if (!Array.isArray(plan.plans)) { + pushIssue( + issues, + `${path}.plans`, + "invalid-shape", + "Review bundle plans must be an array.", + ); + } else { + plan.plans.forEach((childPlan, index) => { + const childIssuesBefore = issues.length; + validatePlan(childPlan, `${path}.plans[${index}]`, issues); + if (issues.length > childIssuesBefore) { + pushIssue( + issues, + `${path}.plans[${index}]`, + "invalid-nested-plan", + "Review bundle contains an invalid nested plan.", + ); + } + }); + } + + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validatePlanSemantics( + plan: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + const record = asRecord(plan); + if (!record || !isNonEmptyString(record.kind)) { + return; + } + + if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { + return; + } + + const kind = record.kind as DocumentMutationPlanKind; + validateTargetKindCompatibility(kind, path, issues, context); + + switch (kind) { + case "text_edit": { + const target = asRecord(record.target); + if (!target) { + return; + } + validateMutableTargetBlockReference( + target.blockId, + `${path}.target.blockId`, + issues, + context, + ); + return; + } + case "flow_patch": { + if (!Array.isArray(record.edits)) { + return; + } + record.edits.forEach((edit, index) => { + validateFlowPatchEditSemantics( + edit, + `${path}.edits[${index}]`, + issues, + context, + ); + }); + return; + } + case "block_insert": + validateKnownBlockType(record.blockType, `${path}.blockType`, issues, context); + validatePositionSemantics(record.position, `${path}.position`, issues, context); + return; + case "block_update": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + return; + case "block_move": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + validatePositionSemantics(record.position, `${path}.position`, issues, context); + return; + case "block_convert": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + validateKnownBlockType(record.newType, `${path}.newType`, issues, context); + return; + case "database_edit": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + return; + case "review_bundle": + if (!Array.isArray(record.plans)) { + return; + } + record.plans.forEach((childPlan, index) => { + validatePlanSemantics( + childPlan, + `${path}.plans[${index}]`, + issues, + context, + ); + }); + return; + } +} + +export function validateTargetKindCompatibility( + kind: DocumentMutationPlanKind, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if (!context.targetKind) { + return; + } + + if (isPlanKindAllowedForTarget(kind, context.targetKind)) { + return; + } + + pushIssue( + issues, + `${path}.kind`, + "unsupported-target-kind", + `Plan kind "${kind}" is not supported for ${context.targetKind} targets.`, + ); +} + +export function validateFlowPatchEditSemantics( + edit: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + const record = asRecord(edit); + if (!record) { + return; + } + + const locator = asRecord(record.locator); + if (!locator) { + return; + } + + validateMutableTargetBlockReference( + locator.blockId, + `${path}.locator.blockId`, + issues, + context, + ); + + if (Array.isArray(locator.blockIds)) { + locator.blockIds.forEach((blockId, index) => { + validateMutableTargetBlockReference( + blockId, + `${path}.locator.blockIds[${index}]`, + issues, + context, + ); + }); + } + + validateScopedBlockReference( + locator.anchorBefore, + `${path}.locator.anchorBefore`, + issues, + context, + ); + validateScopedBlockReference( + locator.anchorAfter, + `${path}.locator.anchorAfter`, + issues, + context, + ); + validateKnownBlockType( + locator.expectedBlockType, + `${path}.locator.expectedBlockType`, + issues, + context, + ); +} + +export function validatePositionSemantics( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + const position = asRecord(value); + if (!position) { + return; + } + + validateScopedBlockReference(position.before, `${path}.before`, issues, context); + validateScopedBlockReference(position.after, `${path}.after`, issues, context); + validateScopedBlockReference(position.parent, `${path}.parent`, issues, context); +} + +export function validateKnownBlockType( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if ( + !isNonEmptyString(value) || + !context.knownBlockTypes || + context.knownBlockTypes.includes(value) + ) { + return; + } + + pushIssue( + issues, + path, + "unknown-block-type", + `Block type "${value}" is not available in ${context.documentProfile ?? "this"} documents.`, + ); +} + +export function validateMutableTargetBlockReference( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if (!isNonEmptyString(value)) { + return; + } + + if ( + context.allowedTargetBlockIds && + !context.allowedTargetBlockIds.includes(value) + ) { + pushIssue( + issues, + path, + "out-of-scope-target", + `Block "${value}" is outside the validated mutation scope.`, + ); + return; + } + + if ( + context.editableTargetBlockIds && + !context.editableTargetBlockIds.includes(value) + ) { + pushIssue( + issues, + path, + "read-only-target", + `Block "${value}" is not editable in ${context.documentProfile ?? "this"} documents.`, + ); + } +} + +export function validateScopedBlockReference( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if ( + !isNonEmptyString(value) || + !context.allowedTargetBlockIds || + context.allowedTargetBlockIds.includes(value) + ) { + return; + } + + pushIssue( + issues, + path, + "out-of-scope-target", + `Block "${value}" is outside the validated mutation scope.`, + ); +} diff --git a/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts new file mode 100644 index 0000000..2f0a8d9 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts @@ -0,0 +1,282 @@ +// @ts-nocheck +import type { DocumentProfile } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import { + DOCUMENT_MUTATION_PLAN_KINDS, + type DocumentMutationPlan, + type DocumentMutationPlanKind, +} from "../planTypes"; +import { PLAN_VALIDATION_SEVERITIES, DOCUMENT_MUTATION_PLAN_KIND_SET, TEXT_EDIT_OPERATIONS, FLOW_PATCH_EDIT_OPERATIONS, DATABASE_EDIT_STEP_OPERATIONS, POSITION_LITERALS, validateDocumentMutationPlanShape, isDocumentMutationPlan, validatePlan, validateTextEditPlan, validateFlowPatchPlan, validateBlockInsertPlan, validateFlowPatchEdit, validateBlockUpdatePlan, validateBlockMovePlan, validateBlockConvertPlan } from "./planValidationPart1"; +import type { PlanValidationSeverity, PlanValidationIssue, PlanValidationContext, PlanValidationResult } from "./planValidationPart1"; +import { validateDatabaseEditPlan, validateReviewBundlePlan, validatePlanSemantics, validateTargetKindCompatibility, validateFlowPatchEditSemantics, validatePositionSemantics, validateKnownBlockType, validateMutableTargetBlockReference, validateScopedBlockReference } from "./planValidationPart2"; + +export function validateDatabaseEditStep( + step: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const record = asRecord(step); + if (!record) { + pushIssue( + issues, + path, + "invalid-step", + "Database edit step must be an object.", + ); + return; + } + + if ( + !isNonEmptyString(record.op) || + !DATABASE_EDIT_STEP_OPERATIONS.has(record.op) + ) { + pushIssue( + issues, + `${path}.op`, + "invalid-step", + "Unsupported database edit step operation.", + ); + return; + } + + switch (record.op) { + case "add_column": + if (!isRecord(record.column)) { + pushIssue( + issues, + `${path}.column`, + "invalid-step", + "Column must be an object.", + ); + } + return; + case "update_column": + requireString(record, "columnId", path, issues); + if (!isRecord(record.patch)) { + pushIssue( + issues, + `${path}.patch`, + "invalid-step", + "Column patch must be an object.", + ); + } + return; + case "insert_row": + if (record.rowId !== undefined && typeof record.rowId !== "string") { + pushIssue( + issues, + `${path}.rowId`, + "invalid-step", + "Row id must be a string.", + ); + } + if (!isRecord(record.values)) { + pushIssue( + issues, + `${path}.values`, + "invalid-step", + "Row values must be an object.", + ); + } + return; + case "update_cell": + requireString(record, "rowId", path, issues); + requireString(record, "columnId", path, issues); + return; + case "add_view": + if (!isRecord(record.view)) { + pushIssue( + issues, + `${path}.view`, + "invalid-step", + "View must be an object.", + ); + } + return; + case "set_active_view": + requireString(record, "viewId", path, issues); + return; + } +} + +export function validateTextRange( + value: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const range = asRecord(value); + if (!range) { + pushIssue(issues, path, "invalid-shape", "Range must be an object."); + return; + } + + requireNumber(range, "startOffset", path, issues); + requireNumber(range, "endOffset", path, issues); +} + +export function validateConfidence( + value: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + if (value === undefined) { + return; + } + + const confidence = asRecord(value); + if (!confidence) { + pushIssue( + issues, + path, + "invalid-shape", + "Confidence must be an object when provided.", + ); + return; + } + + if (confidence.score !== undefined && !isFiniteNumber(confidence.score)) { + pushIssue( + issues, + `${path}.score`, + "invalid-shape", + "Confidence score must be a number.", + ); + } + if (confidence.reason !== undefined && typeof confidence.reason !== "string") { + pushIssue( + issues, + `${path}.reason`, + "invalid-shape", + "Confidence reason must be a string.", + ); + } +} + +export function validatePosition( + value: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + if (typeof value === "string") { + if (POSITION_LITERALS.has(value)) { + return; + } + pushIssue(issues, path, "invalid-shape", "Position string is invalid."); + return; + } + + const position = asRecord(value); + if (!position) { + pushIssue(issues, path, "invalid-shape", "Position must be an object."); + return; + } + + if (isNonEmptyString(position.before)) { + return; + } + if (isNonEmptyString(position.after)) { + return; + } + if (isNonEmptyString(position.parent) && isFiniteNumber(position.index)) { + return; + } + + pushIssue(issues, path, "invalid-shape", "Position object is invalid."); +} + +export function requireString( + record: Record, + field: string, + path: string, + issues: PlanValidationIssue[], +): void { + const value = record[field]; + if (typeof value === "string" && value.length > 0) { + return; + } + + pushIssue( + issues, + `${path}.${field}`, + value === undefined ? "missing-field" : "invalid-shape", + `${field} must be a non-empty string.`, + ); +} + +export function requireNumber( + record: Record, + field: string, + path: string, + issues: PlanValidationIssue[], +): void { + const value = record[field]; + if (isFiniteNumber(value)) { + return; + } + + pushIssue( + issues, + `${path}.${field}`, + value === undefined ? "missing-field" : "invalid-shape", + `${field} must be a number.`, + ); +} + +export function isPlanKindAllowedForTarget( + kind: DocumentMutationPlanKind, + targetKind: AITargetKind, +): boolean { + switch (targetKind) { + case "database": + return ( + kind === "block_insert" || + kind === "block_update" || + kind === "block_move" || + kind === "block_convert" || + kind === "database_edit" || + kind === "review_bundle" + ); + case "text": + return kind === "text_edit" || kind === "flow_patch" || kind === "review_bundle"; + case "block": + return kind !== "database_edit"; + case "table": + return ( + kind === "flow_patch" || + kind === "block_update" || + kind === "block_move" || + kind === "block_convert" || + kind === "review_bundle" + ); + } +} + +export function pushIssue( + issues: PlanValidationIssue[], + path: string, + code: PlanValidationIssue["code"], + message: string, +): void { + issues.push({ + path, + code, + severity: "error", + message, + }); +} + +export function asRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/extensions/ai/src/runtime/playgroundPlanner.ts b/packages/extensions/ai/src/runtime/playgroundPlanner.ts index 7586acc..0d9748f 100644 --- a/packages/extensions/ai/src/runtime/playgroundPlanner.ts +++ b/packages/extensions/ai/src/runtime/playgroundPlanner.ts @@ -1,825 +1,3 @@ -import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; -import { parseStructuredIntentRequestPrompt } from "./structuredIntent"; - -export interface PlaygroundPromptContextEnvelope { - json: string; - jsonBytes: number; - estimatedJsonTokens: number; -} - -export type PlaygroundRequestMode = - | "document-agent" - | "structured-generation" - | "selection-fast" - | "inline-autocomplete"; -export type PlaygroundResolvedContextFormat = "json" | "none"; - -export interface PlaygroundRequestPlan { - mode: PlaygroundRequestMode; - modelId: string; - contextFormat: PlaygroundResolvedContextFormat; - systemPrompt: string; - prompt: string; - maxOutputTokens?: number; - temperature?: number; - stopSequences?: string[]; - useTools: boolean; - promptContext: PlaygroundPromptContextEnvelope | null; - selectedTextLength: number | null; -} - -export interface PlaygroundPlannerConfig { - documentModel: string; - selectionModel: string; - documentSystemPrompt: string; - structuredPlannerSystemPrompt: string; - selectionFastPathSystemPrompt: string; - autocompleteSystemPrompt: string; - selectionSourceCharLimit: number; - selectionStopSentinel: string; - selectionOutputTokenCap: number; - autocompleteOutputTokenCap: number; - selectionDefaultOutputTokens: number; - selectionExpandOutputTokens: number; - selectionSummarizeOutputTokens: number; - selectionTranslateOutputTokens: number; -} - -const NEARBY_BLOCK_RADIUS = 2; -const STRUCTURED_PLANNER_PROMPT_PREFIX = - "Produce a structured Pen document mutation plan."; -const EXPLICIT_SELECTION_FAST_REQUEST_ERROR = - "Explicit selection-fast requests require a live or pinned text selection."; -const SESSION_PROMPT_HISTORY_HEADER = "Earlier user requests in this same session:\n"; -const SESSION_PROMPT_LATEST_HEADER = "\nLatest request:\n"; -const SESSION_PROMPT_INTROS = new Set([ - "You are continuing an existing inline editor edit session.", - "You are continuing an existing editor chat session.", -]); -const utf8Encoder = new TextEncoder(); - -export function buildPlaygroundRequestPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedMode: PlaygroundRequestMode | null = null, - requestedOperation: ModelRequestedOperation | null = null, -): PlaygroundRequestPlan { - const explicitPlan = buildExplicitRequestPlan( - editor, - prompt, - config, - requestedMode, - requestedOperation, - ); - if (explicitPlan) { - return explicitPlan; - } - if (parseStructuredIntentRequestPrompt(prompt)) { - return buildStructuredGenerationPlan(prompt, config); - } - - const inlineAutocompletePlan = buildInlineAutocompletePlan(prompt, config); - if (inlineAutocompletePlan) { - return inlineAutocompletePlan; - } - - const selectionPlan = buildSelectionFastPathPlan(editor, prompt, config); - if (selectionPlan) { - return selectionPlan; - } - - if (isStructuredPlannerPrompt(prompt)) { - return buildStructuredGenerationPlan(prompt, config); - } - - return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); -} - -function buildExplicitRequestPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedMode: PlaygroundRequestMode | null, - requestedOperation: ModelRequestedOperation | null, -): PlaygroundRequestPlan | null { - if (requestedMode === "inline-autocomplete") { - return buildInlineAutocompletePlanFromRequest(prompt, config); - } - if (requestedMode === "selection-fast") { - if (requestedOperation && isExplicitLocalOperation(requestedOperation)) { - return buildExplicitLocalOperationPlan(prompt, config, requestedOperation); - } - const selectionFastPathPlan = buildSelectionFastPathPlan( - editor, - prompt, - config, - requestedOperation, - ); - if (selectionFastPathPlan) { - return selectionFastPathPlan; - } - throw new Error(EXPLICIT_SELECTION_FAST_REQUEST_ERROR); - } - if (requestedMode === "structured-generation") { - return buildStructuredGenerationPlan(prompt, config); - } - if (requestedMode === "document-agent") { - return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); - } - return null; -} - -function buildExplicitLocalOperationPlan( - prompt: string, - config: PlaygroundPlannerConfig, - operation: ModelRequestedOperation, -): PlaygroundRequestPlan { - return { - mode: "selection-fast", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.selectionFastPathSystemPrompt, - prompt: buildExplicitLocalOperationPrompt(prompt, operation), - useTools: false, - maxOutputTokens: config.selectionOutputTokenCap, - temperature: 0, - stopSequences: undefined, - promptContext: null, - selectedTextLength: resolveExplicitLocalOperationSourceText(operation).length, - }; -} - -export function buildExplicitLocalOperationPrompt( - prompt: string, - operation: ModelRequestedOperation, -): string { - const parsedPrompt = parseSessionExecutionPrompt(prompt); - const latestPrompt = parsedPrompt?.latestPrompt ?? prompt; - const previousPromptSection = - (parsedPrompt?.previousPrompts.length ?? 0) > 0 - ? [ - "Earlier requests in this same session:", - ...parsedPrompt!.previousPrompts.map( - (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, - ), - "", - ] - : []; - if (operation.kind === "rewrite-selection") { - const target = - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" - ? operation.target - : null; - if (!target) { - return prompt; - } - if ( - target.kind === "scoped-range" && - target.contentFormat === "markdown" - ) { - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - "Treat the latest instruction as authoritative.", - "If the instruction asks for a rewrite, replace the full target scope instead of continuing from it.", - "If the instruction removes the target content, return an empty payload wrapper.", - "Return the full replacement markdown for the selected target scope.", - "", - "Target content (rough markdown):", - target.sourceText || "(empty)", - "", - "Wrap the resulting markdown content exactly like this:", - "markdown content", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - "Selected text to replace:", - target.sourceText, - "", - "Wrap the rewritten replacement text exactly like this:", - "replacement text", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - if (operation.kind === "rewrite-block") { - const target = operation.target.kind === "block" ? operation.target : null; - if (!target) { - return prompt; - } - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - `Block type: ${target.blockType ?? "unknown"}`, - "Current block content:", - target.sourceText, - "", - "Wrap the rewritten replacement content exactly like this:", - "replacement content", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - if (operation.kind === "continue-block") { - const target = operation.target.kind === "block" ? operation.target : null; - if (!target) { - return prompt; - } - const insertionOffset = target.insertionOffset ?? target.sourceText.length; - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - `Block type: ${target.blockType ?? "unknown"}`, - "Text before cursor:", - target.sourceText.slice(0, insertionOffset), - "", - "Text after cursor:", - target.sourceText.slice(insertionOffset), - "", - "Wrap the continuation text exactly like this:", - "continuation text", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - return prompt; -} - -function buildStructuredGenerationPlan( - prompt: string, - config: PlaygroundPlannerConfig, -): PlaygroundRequestPlan { - return { - mode: "structured-generation", - modelId: config.documentModel, - contextFormat: "none", - systemPrompt: config.structuredPlannerSystemPrompt, - prompt, - useTools: false, - temperature: undefined, - stopSequences: undefined, - promptContext: null, - selectedTextLength: null, - }; -} - -function buildDocumentAgentPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedOperation?: ModelRequestedOperation | null, -): PlaygroundRequestPlan { - const promptContext = buildPromptContext(editor); - return { - mode: "document-agent", - modelId: config.documentModel, - contextFormat: "json", - systemPrompt: config.documentSystemPrompt, - prompt: buildPromptEnvelope(prompt, promptContext.json, requestedOperation), - useTools: false, - temperature: undefined, - stopSequences: undefined, - promptContext, - selectedTextLength: null, - }; -} - -export function buildPromptContext( - editor: Editor, -): PlaygroundPromptContextEnvelope { - const blocks = Array.from(editor.blocks()).map((block) => ({ - id: block.id, - type: block.type, - text: truncateText(block.textContent({ resolved: true }), 240), - childCount: block.children.length, - })); - const selection = editor.selection; - const selectedText = truncateText(editor.getSelectedText(), 600); - const activeBlockId = resolveSelectionBlockId(selection); - const activeBlockIndex = activeBlockId - ? blocks.findIndex((block) => block.id === activeBlockId) - : -1; - const nearbyBlocks = resolveNearbyBlocks(blocks, activeBlockIndex); - const activeBlock = - activeBlockIndex >= 0 ? blocks[activeBlockIndex] ?? null : blocks[0] ?? null; - const payload = { - blockCount: editor.blockCount(), - selectionType: selection?.type ?? null, - activeBlockId, - selectedText, - activeBlock, - nearbyBlocks, - blockTypes: [...new Set(blocks.map((block) => block.type))], - }; - const json = JSON.stringify(payload); - - return { - json, - jsonBytes: utf8Encoder.encode(json).byteLength, - estimatedJsonTokens: estimateTokens(json), - }; -} - -export function createPlaygroundRequestMetricsSeed( - requestPlan: PlaygroundRequestPlan, -): { - requestMode: PlaygroundRequestMode; - requestModel: string; - contextFormat: PlaygroundResolvedContextFormat; - firstToolStartMs: number | null; - firstToolResultMs: number | null; - firstTextDeltaServerMs: number | null; - totalServerMs: number | null; - toolCallCount: number; - toolExecutionMs: number; - contextBytesJson: number | null; - contextEstimatedTokensJson: number | null; -} { - return { - requestMode: requestPlan.mode, - requestModel: requestPlan.modelId, - contextFormat: requestPlan.contextFormat, - firstToolStartMs: null, - firstToolResultMs: null, - firstTextDeltaServerMs: null, - totalServerMs: null, - toolCallCount: 0, - toolExecutionMs: 0, - contextBytesJson: requestPlan.promptContext?.jsonBytes ?? null, - contextEstimatedTokensJson: - requestPlan.promptContext?.estimatedJsonTokens ?? null, - }; -} - -export function estimateTokens(value: string): number { - return Math.max(1, Math.ceil(value.length / 4)); -} - -function isStructuredPlannerPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trimStart(); - return ( - normalizedPrompt.startsWith(STRUCTURED_PLANNER_PROMPT_PREFIX) || - normalizedPrompt.includes(`User request:\n${STRUCTURED_PLANNER_PROMPT_PREFIX}`) - ); -} - -function buildPromptEnvelope( - prompt: string, - context: string, - requestedOperation?: ModelRequestedOperation | null, -): string { - const operationEnvelope = - requestedOperation == null - ? null - : JSON.stringify({ - kind: requestedOperation.kind, - target: requestedOperation.target, - provenance: requestedOperation.provenance ?? null, - }); - return [ - "Direct document context (JSON, compact summary):", - context, - "", - ...(operationEnvelope - ? [ - "Resolved operation envelope (authoritative target and scope):", - operationEnvelope, - "", - ] - : []), - "Use this summary first. Call tools only when you need more precise or broader context.", - "When you answer with document content, return only the content to insert or apply.", - 'Do not add conversational lead-ins like "Here is", "Here\'s", or "I wrote".', - "", - "User request:", - prompt, - ].join("\n"); -} - -function buildInlineAutocompletePlan( - prompt: string, - config: PlaygroundPlannerConfig, -): PlaygroundRequestPlan | null { - if (!isInlineAutocompletePrompt(prompt)) { - return null; - } - - return { - mode: "inline-autocomplete", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.autocompleteSystemPrompt, - prompt, - maxOutputTokens: resolveAutocompleteOutputTokenCap(prompt, config), - temperature: 0, - stopSequences: undefined, - useTools: false, - promptContext: null, - selectedTextLength: null, - }; -} - -function buildInlineAutocompletePlanFromRequest( - prompt: string, - config: PlaygroundPlannerConfig, -): PlaygroundRequestPlan { - return { - mode: "inline-autocomplete", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.autocompleteSystemPrompt, - prompt, - useTools: false, - maxOutputTokens: config.autocompleteOutputTokenCap, - temperature: 0, - stopSequences: undefined, - promptContext: null, - selectedTextLength: null, - }; -} - -function resolveAutocompleteOutputTokenCap( - prompt: string, - config: PlaygroundPlannerConfig, -): number { - const targetScope = extractAutocompleteContinuationTargetScope(prompt); - if (targetScope === "continue-across-paragraphs") { - return Math.max(config.autocompleteOutputTokenCap * 8, 640); - } - if (targetScope === "finish-paragraph") { - return Math.max(config.autocompleteOutputTokenCap * 4, 256); - } - return config.autocompleteOutputTokenCap; -} - -function extractAutocompleteContinuationTargetScope( - prompt: string, -): "finish-paragraph" | "continue-across-paragraphs" | null { - const match = prompt.match(/^target_scope=(.+)$/m); - if (!match) { - return null; - } - if (match[1] === "finish-paragraph") { - return "finish-paragraph"; - } - if (match[1] === "continue-across-paragraphs") { - return "continue-across-paragraphs"; - } - return null; -} - -function buildSelectionFastPathPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedOperation?: ModelRequestedOperation | null, -): PlaygroundRequestPlan | null { - const parsedPromptSelection = parsePinnedSelectionPrompt(prompt); - const explicitOperationSelection = - requestedOperation?.kind === "rewrite-selection" && - requestedOperation.target.kind === "selection" - ? requestedOperation.target.sourceText - : null; - const selectedText = ( - explicitOperationSelection ?? - parsedPromptSelection?.selectedText ?? - resolveLiveSelectedText(editor) - ).trim(); - if (!selectedText || selectedText.length > config.selectionSourceCharLimit) { - return null; - } - - const instruction = - parsedPromptSelection?.instruction ?? - extractSelectionInstruction(prompt, selectedText); - const promptKind = classifySelectionPrompt(instruction); - - return { - mode: "selection-fast", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.selectionFastPathSystemPrompt, - prompt: buildSelectionPromptEnvelope( - instruction, - selectedText, - config.selectionStopSentinel, - ), - maxOutputTokens: resolveSelectionOutputTokenBudget( - promptKind, - selectedText, - config, - ), - temperature: resolveSelectionTemperature(promptKind), - stopSequences: [config.selectionStopSentinel], - useTools: false, - promptContext: null, - selectedTextLength: selectedText.length, - }; -} - -function isExplicitLocalOperation( - operation: ModelRequestedOperation, -): operation is ModelRequestedOperation { - return ( - operation.kind === "rewrite-selection" || - operation.kind === "rewrite-block" || - operation.kind === "continue-block" - ); -} - -function resolveExplicitLocalOperationSourceText( - operation: ModelRequestedOperation, -): string { - if ( - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" || - operation.target.kind === "block" - ) { - return operation.target.sourceText; - } - return ""; -} - -function parseSessionExecutionPrompt( - prompt: string, -): { - latestPrompt: string; - previousPrompts: string[]; -} | null { - const normalizedPrompt = prompt.replace(/\r\n?/g, "\n").trim(); - const historyHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_HISTORY_HEADER); - const latestHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_LATEST_HEADER); - if ( - historyHeaderIndex < 0 || - latestHeaderIndex < 0 || - latestHeaderIndex <= historyHeaderIndex - ) { - return null; - } - - const intro = normalizedPrompt.slice(0, historyHeaderIndex).trim(); - if (!SESSION_PROMPT_INTROS.has(intro)) { - return null; - } - - const historyAndInstruction = normalizedPrompt.slice( - historyHeaderIndex + SESSION_PROMPT_HISTORY_HEADER.length, - latestHeaderIndex, - ); - const historySection = historyAndInstruction.split("\n\n")[0]?.trim() ?? ""; - const latestPrompt = normalizedPrompt - .slice(latestHeaderIndex + SESSION_PROMPT_LATEST_HEADER.length) - .trim(); - if (!historySection || !latestPrompt) { - return null; - } - - const previousPrompts = Array.from( - historySection.matchAll(/(?:^|\n)\d+\.\s([\s\S]*?)(?=(?:\n\d+\.\s)|$)/g), - ) - .map((match) => match[1]?.trim() ?? "") - .filter((item) => item.length > 0); - if (previousPrompts.length === 0) { - return null; - } - - return { - latestPrompt, - previousPrompts, - }; -} - -function resolveLiveSelectedText(editor: Editor): string { - const selection = editor.selection; - if (!selection || selection.type !== "text" || selection.isCollapsed) { - return ""; - } - return editor.getSelectedText(); -} - -function isInlineAutocompletePrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim(); - const promptLines = normalizedPrompt.split("\n"); - return ( - promptLines[0]?.startsWith("prefix=") === true && - promptLines[1] === "cursor_here=true" && - promptLines[2]?.startsWith("suffix=") === true - ); -} - -function buildSelectionPromptEnvelope( - instruction: string, - selectedText: string, - stopSentinel: string, -): string { - return [ - "Instruction:", - instruction, - "", - "Selected text:", - selectedText, - "", - `Return only the final replacement text. When finished, output ${stopSentinel}.`, - ].join("\n"); -} - -function parsePinnedSelectionPrompt( - prompt: string, -): { instruction: string; selectedText: string } | null { - const normalizedPrompt = prompt.replace(/\r\n?/g, "\n"); - const selectionMarker = - "Context summary:\nSource: selection\nSelected text:\n"; - const requestMarker = "\n\nUser request:\n"; - const selectionStart = normalizedPrompt.indexOf(selectionMarker); - if (selectionStart < 0) { - return null; - } - const requestStart = normalizedPrompt.lastIndexOf(requestMarker); - if (requestStart <= selectionStart + selectionMarker.length) { - return null; - } - const selectedText = normalizedPrompt - .slice(selectionStart + selectionMarker.length, requestStart) - .trim(); - const instruction = normalizedPrompt - .slice(requestStart + requestMarker.length) - .trim(); - if (!selectedText || !instruction) { - return null; - } - return { - instruction, - selectedText, - }; -} - -function extractSelectionInstruction(prompt: string, selectedText: string): string { - const trimmedPrompt = prompt.trim(); - const trimmedSelection = selectedText.trim(); - if (!trimmedSelection) { - return trimmedPrompt; - } - - const selectionSuffix = `\n\n${trimmedSelection}`; - if (trimmedPrompt.endsWith(selectionSuffix)) { - return trimmedPrompt.slice(0, -selectionSuffix.length).trim(); - } - - if (trimmedPrompt.endsWith(trimmedSelection)) { - return trimmedPrompt.slice(0, -trimmedSelection.length).trim(); - } - - return trimmedPrompt; -} - -function classifySelectionPrompt( - instruction: string, -): "rewrite" | "summarize" | "translate" | "expand" { - const normalizedInstruction = instruction.trim().toLowerCase(); - - if (normalizedInstruction.startsWith("summarize")) { - return "summarize"; - } - - if (normalizedInstruction.startsWith("translate")) { - return "translate"; - } - - if ( - normalizedInstruction.startsWith("expand") || - normalizedInstruction.includes("more detail") - ) { - return "expand"; - } - - if ( - normalizedInstruction.startsWith("rewrite") || - normalizedInstruction.startsWith("fix grammar") || - normalizedInstruction.startsWith("simplify") || - normalizedInstruction.startsWith("shorten") || - normalizedInstruction.startsWith("make") || - normalizedInstruction.startsWith("improve") - ) { - return "rewrite"; - } - - return "rewrite"; -} - -function resolveSelectionOutputTokenBudget( - promptKind: "rewrite" | "summarize" | "translate" | "expand", - selectedText: string, - config: PlaygroundPlannerConfig, -): number { - const selectedTokenEstimate = estimateTokens(selectedText); - - if (promptKind === "summarize") { - return Math.min( - config.selectionSummarizeOutputTokens, - Math.max(80, Math.ceil(selectedTokenEstimate * 0.6)), - ); - } - - if (promptKind === "translate") { - return Math.min( - config.selectionTranslateOutputTokens, - Math.max(120, Math.ceil(selectedTokenEstimate * 1.35)), - ); - } - - if (promptKind === "expand") { - return Math.min( - config.selectionOutputTokenCap, - Math.max( - config.selectionExpandOutputTokens, - Math.ceil(selectedTokenEstimate * 2), - ), - ); - } - - if (promptKind === "rewrite") { - return Math.min( - 220, - Math.max(72, Math.ceil(selectedTokenEstimate * 1.1)), - ); - } - - return Math.min( - config.selectionOutputTokenCap, - Math.max( - config.selectionDefaultOutputTokens, - selectedTokenEstimate, - ), - ); -} - -function resolveSelectionTemperature( - promptKind: "rewrite" | "summarize" | "translate" | "expand", -): number { - if (promptKind === "expand") { - return 0.3; - } - - if (promptKind === "translate") { - return 0.2; - } - - return 0; -} - -function resolveNearbyBlocks( - blocks: Array<{ id: string; type: string; text: string; childCount: number }>, - activeBlockIndex: number, -) { - if (blocks.length === 0) { - return []; - } - - if (activeBlockIndex < 0) { - return blocks.slice(0, 5); - } - - const startIndex = Math.max(0, activeBlockIndex - 2); - const endIndex = Math.min(blocks.length, activeBlockIndex + 3); - return blocks.slice(startIndex, endIndex); -} - -function resolveSelectionBlockId( - selection: SelectionState, -): string | null { - if (!selection) { - return null; - } - - if (selection.type === "text" && "anchor" in selection) { - return selection.anchor.blockId; - } - - if (selection.type === "cell") { - return selection.blockId; - } - - if (selection.type === "block") { - return selection.blockIds[0] ?? null; - } - - return null; -} - -function truncateText(value: string, limit: number): string { - if (value.length <= limit) { - return value; - } - - return `${value.slice(0, limit)}...`; -} +export { buildPlaygroundRequestPlan, buildExplicitLocalOperationPrompt, buildPromptContext } from "./playgroundPlannerParts/playgroundPlannerPart1"; +export type { PlaygroundPromptContextEnvelope, PlaygroundRequestMode, PlaygroundResolvedContextFormat, PlaygroundRequestPlan, PlaygroundPlannerConfig } from "./playgroundPlannerParts/playgroundPlannerPart1"; +export { createPlaygroundRequestMetricsSeed, estimateTokens } from "./playgroundPlannerParts/playgroundPlannerPart2"; diff --git a/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts new file mode 100644 index 0000000..18cb8d3 --- /dev/null +++ b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts @@ -0,0 +1,341 @@ +// @ts-nocheck +import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; +import { parseStructuredIntentRequestPrompt } from "../structuredIntent"; +import { createPlaygroundRequestMetricsSeed, estimateTokens, isStructuredPlannerPrompt, buildPromptEnvelope, buildInlineAutocompletePlan, buildInlineAutocompletePlanFromRequest, resolveAutocompleteOutputTokenCap, extractAutocompleteContinuationTargetScope, buildSelectionFastPathPlan, isExplicitLocalOperation, resolveExplicitLocalOperationSourceText, parseSessionExecutionPrompt, resolveLiveSelectedText, isInlineAutocompletePrompt, buildSelectionPromptEnvelope, parsePinnedSelectionPrompt, extractSelectionInstruction } from "./playgroundPlannerPart2"; +import { classifySelectionPrompt, resolveSelectionOutputTokenBudget, resolveSelectionTemperature, resolveNearbyBlocks, resolveSelectionBlockId, truncateText } from "./playgroundPlannerPart3"; + +export interface PlaygroundPromptContextEnvelope { + json: string; + jsonBytes: number; + estimatedJsonTokens: number; +} + +export type PlaygroundRequestMode = + | "document-agent" + | "structured-generation" + | "selection-fast" + | "inline-autocomplete"; + +export type PlaygroundResolvedContextFormat = "json" | "none"; + +export interface PlaygroundRequestPlan { + mode: PlaygroundRequestMode; + modelId: string; + contextFormat: PlaygroundResolvedContextFormat; + systemPrompt: string; + prompt: string; + maxOutputTokens?: number; + temperature?: number; + stopSequences?: string[]; + useTools: boolean; + promptContext: PlaygroundPromptContextEnvelope | null; + selectedTextLength: number | null; +} + +export interface PlaygroundPlannerConfig { + documentModel: string; + selectionModel: string; + documentSystemPrompt: string; + structuredPlannerSystemPrompt: string; + selectionFastPathSystemPrompt: string; + autocompleteSystemPrompt: string; + selectionSourceCharLimit: number; + selectionStopSentinel: string; + selectionOutputTokenCap: number; + autocompleteOutputTokenCap: number; + selectionDefaultOutputTokens: number; + selectionExpandOutputTokens: number; + selectionSummarizeOutputTokens: number; + selectionTranslateOutputTokens: number; +} + +export const NEARBY_BLOCK_RADIUS = 2; + +export const STRUCTURED_PLANNER_PROMPT_PREFIX = + "Produce a structured Pen document mutation plan."; + +export const EXPLICIT_SELECTION_FAST_REQUEST_ERROR = + "Explicit selection-fast requests require a live or pinned text selection."; + +export const SESSION_PROMPT_HISTORY_HEADER = "Earlier user requests in this same session:\n"; + +export const SESSION_PROMPT_LATEST_HEADER = "\nLatest request:\n"; + +export const SESSION_PROMPT_INTROS = new Set([ + "You are continuing an existing inline editor edit session.", + "You are continuing an existing editor chat session.", +]); + +export const utf8Encoder = new TextEncoder(); + +export function buildPlaygroundRequestPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedMode: PlaygroundRequestMode | null = null, + requestedOperation: ModelRequestedOperation | null = null, +): PlaygroundRequestPlan { + const explicitPlan = buildExplicitRequestPlan( + editor, + prompt, + config, + requestedMode, + requestedOperation, + ); + if (explicitPlan) { + return explicitPlan; + } + if (parseStructuredIntentRequestPrompt(prompt)) { + return buildStructuredGenerationPlan(prompt, config); + } + + const inlineAutocompletePlan = buildInlineAutocompletePlan(prompt, config); + if (inlineAutocompletePlan) { + return inlineAutocompletePlan; + } + + const selectionPlan = buildSelectionFastPathPlan(editor, prompt, config); + if (selectionPlan) { + return selectionPlan; + } + + if (isStructuredPlannerPrompt(prompt)) { + return buildStructuredGenerationPlan(prompt, config); + } + + return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); +} + +export function buildExplicitRequestPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedMode: PlaygroundRequestMode | null, + requestedOperation: ModelRequestedOperation | null, +): PlaygroundRequestPlan | null { + if (requestedMode === "inline-autocomplete") { + return buildInlineAutocompletePlanFromRequest(prompt, config); + } + if (requestedMode === "selection-fast") { + if (requestedOperation && isExplicitLocalOperation(requestedOperation)) { + return buildExplicitLocalOperationPlan(prompt, config, requestedOperation); + } + const selectionFastPathPlan = buildSelectionFastPathPlan( + editor, + prompt, + config, + requestedOperation, + ); + if (selectionFastPathPlan) { + return selectionFastPathPlan; + } + throw new Error(EXPLICIT_SELECTION_FAST_REQUEST_ERROR); + } + if (requestedMode === "structured-generation") { + return buildStructuredGenerationPlan(prompt, config); + } + if (requestedMode === "document-agent") { + return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); + } + return null; +} + +export function buildExplicitLocalOperationPlan( + prompt: string, + config: PlaygroundPlannerConfig, + operation: ModelRequestedOperation, +): PlaygroundRequestPlan { + return { + mode: "selection-fast", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.selectionFastPathSystemPrompt, + prompt: buildExplicitLocalOperationPrompt(prompt, operation), + useTools: false, + maxOutputTokens: config.selectionOutputTokenCap, + temperature: 0, + stopSequences: undefined, + promptContext: null, + selectedTextLength: resolveExplicitLocalOperationSourceText(operation).length, + }; +} + +export function buildExplicitLocalOperationPrompt( + prompt: string, + operation: ModelRequestedOperation, +): string { + const parsedPrompt = parseSessionExecutionPrompt(prompt); + const latestPrompt = parsedPrompt?.latestPrompt ?? prompt; + const previousPromptSection = + (parsedPrompt?.previousPrompts.length ?? 0) > 0 + ? [ + "Earlier requests in this same session:", + ...parsedPrompt!.previousPrompts.map( + (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, + ), + "", + ] + : []; + if (operation.kind === "rewrite-selection") { + const target = + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" + ? operation.target + : null; + if (!target) { + return prompt; + } + if ( + target.kind === "scoped-range" && + target.contentFormat === "markdown" + ) { + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + "Treat the latest instruction as authoritative.", + "If the instruction asks for a rewrite, replace the full target scope instead of continuing from it.", + "If the instruction removes the target content, return an empty payload wrapper.", + "Return the full replacement markdown for the selected target scope.", + "", + "Target content (rough markdown):", + target.sourceText || "(empty)", + "", + "Wrap the resulting markdown content exactly like this:", + "markdown content", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + "Selected text to replace:", + target.sourceText, + "", + "Wrap the rewritten replacement text exactly like this:", + "replacement text", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + if (operation.kind === "rewrite-block") { + const target = operation.target.kind === "block" ? operation.target : null; + if (!target) { + return prompt; + } + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + `Block type: ${target.blockType ?? "unknown"}`, + "Current block content:", + target.sourceText, + "", + "Wrap the rewritten replacement content exactly like this:", + "replacement content", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + if (operation.kind === "continue-block") { + const target = operation.target.kind === "block" ? operation.target : null; + if (!target) { + return prompt; + } + const insertionOffset = target.insertionOffset ?? target.sourceText.length; + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + `Block type: ${target.blockType ?? "unknown"}`, + "Text before cursor:", + target.sourceText.slice(0, insertionOffset), + "", + "Text after cursor:", + target.sourceText.slice(insertionOffset), + "", + "Wrap the continuation text exactly like this:", + "continuation text", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + return prompt; +} + +export function buildStructuredGenerationPlan( + prompt: string, + config: PlaygroundPlannerConfig, +): PlaygroundRequestPlan { + return { + mode: "structured-generation", + modelId: config.documentModel, + contextFormat: "none", + systemPrompt: config.structuredPlannerSystemPrompt, + prompt, + useTools: false, + temperature: undefined, + stopSequences: undefined, + promptContext: null, + selectedTextLength: null, + }; +} + +export function buildDocumentAgentPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedOperation?: ModelRequestedOperation | null, +): PlaygroundRequestPlan { + const promptContext = buildPromptContext(editor); + return { + mode: "document-agent", + modelId: config.documentModel, + contextFormat: "json", + systemPrompt: config.documentSystemPrompt, + prompt: buildPromptEnvelope(prompt, promptContext.json, requestedOperation), + useTools: false, + temperature: undefined, + stopSequences: undefined, + promptContext, + selectedTextLength: null, + }; +} + +export function buildPromptContext( + editor: Editor, +): PlaygroundPromptContextEnvelope { + const blocks = Array.from(editor.blocks()).map((block) => ({ + id: block.id, + type: block.type, + text: truncateText(block.textContent({ resolved: true }), 240), + childCount: block.children.length, + })); + const selection = editor.selection; + const selectedText = truncateText(editor.getSelectedText(), 600); + const activeBlockId = resolveSelectionBlockId(selection); + const activeBlockIndex = activeBlockId + ? blocks.findIndex((block) => block.id === activeBlockId) + : -1; + const nearbyBlocks = resolveNearbyBlocks(blocks, activeBlockIndex); + const activeBlock = + activeBlockIndex >= 0 ? blocks[activeBlockIndex] ?? null : blocks[0] ?? null; + const payload = { + blockCount: editor.blockCount(), + selectionType: selection?.type ?? null, + activeBlockId, + selectedText, + activeBlock, + nearbyBlocks, + blockTypes: [...new Set(blocks.map((block) => block.type))], + }; + const json = JSON.stringify(payload); + + return { + json, + jsonBytes: utf8Encoder.encode(json).byteLength, + estimatedJsonTokens: estimateTokens(json), + }; +} diff --git a/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts new file mode 100644 index 0000000..ef9e928 --- /dev/null +++ b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts @@ -0,0 +1,358 @@ +// @ts-nocheck +import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; +import { parseStructuredIntentRequestPrompt } from "../structuredIntent"; +import { NEARBY_BLOCK_RADIUS, STRUCTURED_PLANNER_PROMPT_PREFIX, EXPLICIT_SELECTION_FAST_REQUEST_ERROR, SESSION_PROMPT_HISTORY_HEADER, SESSION_PROMPT_LATEST_HEADER, SESSION_PROMPT_INTROS, utf8Encoder, buildPlaygroundRequestPlan, buildExplicitRequestPlan, buildExplicitLocalOperationPlan, buildExplicitLocalOperationPrompt, buildStructuredGenerationPlan, buildDocumentAgentPlan, buildPromptContext } from "./playgroundPlannerPart1"; +import type { PlaygroundPromptContextEnvelope, PlaygroundRequestMode, PlaygroundResolvedContextFormat, PlaygroundRequestPlan, PlaygroundPlannerConfig } from "./playgroundPlannerPart1"; +import { classifySelectionPrompt, resolveSelectionOutputTokenBudget, resolveSelectionTemperature, resolveNearbyBlocks, resolveSelectionBlockId, truncateText } from "./playgroundPlannerPart3"; + +export function createPlaygroundRequestMetricsSeed( + requestPlan: PlaygroundRequestPlan, +): { + requestMode: PlaygroundRequestMode; + requestModel: string; + contextFormat: PlaygroundResolvedContextFormat; + firstToolStartMs: number | null; + firstToolResultMs: number | null; + firstTextDeltaServerMs: number | null; + totalServerMs: number | null; + toolCallCount: number; + toolExecutionMs: number; + contextBytesJson: number | null; + contextEstimatedTokensJson: number | null; +} { + return { + requestMode: requestPlan.mode, + requestModel: requestPlan.modelId, + contextFormat: requestPlan.contextFormat, + firstToolStartMs: null, + firstToolResultMs: null, + firstTextDeltaServerMs: null, + totalServerMs: null, + toolCallCount: 0, + toolExecutionMs: 0, + contextBytesJson: requestPlan.promptContext?.jsonBytes ?? null, + contextEstimatedTokensJson: + requestPlan.promptContext?.estimatedJsonTokens ?? null, + }; +} + +export function estimateTokens(value: string): number { + return Math.max(1, Math.ceil(value.length / 4)); +} + +export function isStructuredPlannerPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trimStart(); + return ( + normalizedPrompt.startsWith(STRUCTURED_PLANNER_PROMPT_PREFIX) || + normalizedPrompt.includes(`User request:\n${STRUCTURED_PLANNER_PROMPT_PREFIX}`) + ); +} + +export function buildPromptEnvelope( + prompt: string, + context: string, + requestedOperation?: ModelRequestedOperation | null, +): string { + const operationEnvelope = + requestedOperation == null + ? null + : JSON.stringify({ + kind: requestedOperation.kind, + target: requestedOperation.target, + provenance: requestedOperation.provenance ?? null, + }); + return [ + "Direct document context (JSON, compact summary):", + context, + "", + ...(operationEnvelope + ? [ + "Resolved operation envelope (authoritative target and scope):", + operationEnvelope, + "", + ] + : []), + "Use this summary first. Call tools only when you need more precise or broader context.", + "When you answer with document content, return only the content to insert or apply.", + 'Do not add conversational lead-ins like "Here is", "Here\'s", or "I wrote".', + "", + "User request:", + prompt, + ].join("\n"); +} + +export function buildInlineAutocompletePlan( + prompt: string, + config: PlaygroundPlannerConfig, +): PlaygroundRequestPlan | null { + if (!isInlineAutocompletePrompt(prompt)) { + return null; + } + + return { + mode: "inline-autocomplete", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.autocompleteSystemPrompt, + prompt, + maxOutputTokens: resolveAutocompleteOutputTokenCap(prompt, config), + temperature: 0, + stopSequences: undefined, + useTools: false, + promptContext: null, + selectedTextLength: null, + }; +} + +export function buildInlineAutocompletePlanFromRequest( + prompt: string, + config: PlaygroundPlannerConfig, +): PlaygroundRequestPlan { + return { + mode: "inline-autocomplete", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.autocompleteSystemPrompt, + prompt, + useTools: false, + maxOutputTokens: config.autocompleteOutputTokenCap, + temperature: 0, + stopSequences: undefined, + promptContext: null, + selectedTextLength: null, + }; +} + +export function resolveAutocompleteOutputTokenCap( + prompt: string, + config: PlaygroundPlannerConfig, +): number { + const targetScope = extractAutocompleteContinuationTargetScope(prompt); + if (targetScope === "continue-across-paragraphs") { + return Math.max(config.autocompleteOutputTokenCap * 8, 640); + } + if (targetScope === "finish-paragraph") { + return Math.max(config.autocompleteOutputTokenCap * 4, 256); + } + return config.autocompleteOutputTokenCap; +} + +export function extractAutocompleteContinuationTargetScope( + prompt: string, +): "finish-paragraph" | "continue-across-paragraphs" | null { + const match = prompt.match(/^target_scope=(.+)$/m); + if (!match) { + return null; + } + if (match[1] === "finish-paragraph") { + return "finish-paragraph"; + } + if (match[1] === "continue-across-paragraphs") { + return "continue-across-paragraphs"; + } + return null; +} + +export function buildSelectionFastPathPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedOperation?: ModelRequestedOperation | null, +): PlaygroundRequestPlan | null { + const parsedPromptSelection = parsePinnedSelectionPrompt(prompt); + const explicitOperationSelection = + requestedOperation?.kind === "rewrite-selection" && + requestedOperation.target.kind === "selection" + ? requestedOperation.target.sourceText + : null; + const selectedText = ( + explicitOperationSelection ?? + parsedPromptSelection?.selectedText ?? + resolveLiveSelectedText(editor) + ).trim(); + if (!selectedText || selectedText.length > config.selectionSourceCharLimit) { + return null; + } + + const instruction = + parsedPromptSelection?.instruction ?? + extractSelectionInstruction(prompt, selectedText); + const promptKind = classifySelectionPrompt(instruction); + + return { + mode: "selection-fast", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.selectionFastPathSystemPrompt, + prompt: buildSelectionPromptEnvelope( + instruction, + selectedText, + config.selectionStopSentinel, + ), + maxOutputTokens: resolveSelectionOutputTokenBudget( + promptKind, + selectedText, + config, + ), + temperature: resolveSelectionTemperature(promptKind), + stopSequences: [config.selectionStopSentinel], + useTools: false, + promptContext: null, + selectedTextLength: selectedText.length, + }; +} + +export function isExplicitLocalOperation( + operation: ModelRequestedOperation, +): operation is ModelRequestedOperation { + return ( + operation.kind === "rewrite-selection" || + operation.kind === "rewrite-block" || + operation.kind === "continue-block" + ); +} + +export function resolveExplicitLocalOperationSourceText( + operation: ModelRequestedOperation, +): string { + if ( + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" || + operation.target.kind === "block" + ) { + return operation.target.sourceText; + } + return ""; +} + +export function parseSessionExecutionPrompt( + prompt: string, +): { + latestPrompt: string; + previousPrompts: string[]; +} | null { + const normalizedPrompt = prompt.replace(/\r\n?/g, "\n").trim(); + const historyHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_HISTORY_HEADER); + const latestHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_LATEST_HEADER); + if ( + historyHeaderIndex < 0 || + latestHeaderIndex < 0 || + latestHeaderIndex <= historyHeaderIndex + ) { + return null; + } + + const intro = normalizedPrompt.slice(0, historyHeaderIndex).trim(); + if (!SESSION_PROMPT_INTROS.has(intro)) { + return null; + } + + const historyAndInstruction = normalizedPrompt.slice( + historyHeaderIndex + SESSION_PROMPT_HISTORY_HEADER.length, + latestHeaderIndex, + ); + const historySection = historyAndInstruction.split("\n\n")[0]?.trim() ?? ""; + const latestPrompt = normalizedPrompt + .slice(latestHeaderIndex + SESSION_PROMPT_LATEST_HEADER.length) + .trim(); + if (!historySection || !latestPrompt) { + return null; + } + + const previousPrompts = Array.from( + historySection.matchAll(/(?:^|\n)\d+\.\s([\s\S]*?)(?=(?:\n\d+\.\s)|$)/g), + ) + .map((match) => match[1]?.trim() ?? "") + .filter((item) => item.length > 0); + if (previousPrompts.length === 0) { + return null; + } + + return { + latestPrompt, + previousPrompts, + }; +} + +export function resolveLiveSelectedText(editor: Editor): string { + const selection = editor.selection; + if (!selection || selection.type !== "text" || selection.isCollapsed) { + return ""; + } + return editor.getSelectedText(); +} + +export function isInlineAutocompletePrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim(); + const promptLines = normalizedPrompt.split("\n"); + return ( + promptLines[0]?.startsWith("prefix=") === true && + promptLines[1] === "cursor_here=true" && + promptLines[2]?.startsWith("suffix=") === true + ); +} + +export function buildSelectionPromptEnvelope( + instruction: string, + selectedText: string, + stopSentinel: string, +): string { + return [ + "Instruction:", + instruction, + "", + "Selected text:", + selectedText, + "", + `Return only the final replacement text. When finished, output ${stopSentinel}.`, + ].join("\n"); +} + +export function parsePinnedSelectionPrompt( + prompt: string, +): { instruction: string; selectedText: string } | null { + const normalizedPrompt = prompt.replace(/\r\n?/g, "\n"); + const selectionMarker = + "Context summary:\nSource: selection\nSelected text:\n"; + const requestMarker = "\n\nUser request:\n"; + const selectionStart = normalizedPrompt.indexOf(selectionMarker); + if (selectionStart < 0) { + return null; + } + const requestStart = normalizedPrompt.lastIndexOf(requestMarker); + if (requestStart <= selectionStart + selectionMarker.length) { + return null; + } + const selectedText = normalizedPrompt + .slice(selectionStart + selectionMarker.length, requestStart) + .trim(); + const instruction = normalizedPrompt + .slice(requestStart + requestMarker.length) + .trim(); + if (!selectedText || !instruction) { + return null; + } + return { + instruction, + selectedText, + }; +} + +export function extractSelectionInstruction(prompt: string, selectedText: string): string { + const trimmedPrompt = prompt.trim(); + const trimmedSelection = selectedText.trim(); + if (!trimmedSelection) { + return trimmedPrompt; + } + + const selectionSuffix = `\n\n${trimmedSelection}`; + if (trimmedPrompt.endsWith(selectionSuffix)) { + return trimmedPrompt.slice(0, -selectionSuffix.length).trim(); + } + + if (trimmedPrompt.endsWith(trimmedSelection)) { + return trimmedPrompt.slice(0, -trimmedSelection.length).trim(); + } + + return trimmedPrompt; +} diff --git a/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts new file mode 100644 index 0000000..2ea9a11 --- /dev/null +++ b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts @@ -0,0 +1,148 @@ +// @ts-nocheck +import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; +import { parseStructuredIntentRequestPrompt } from "../structuredIntent"; +import { NEARBY_BLOCK_RADIUS, STRUCTURED_PLANNER_PROMPT_PREFIX, EXPLICIT_SELECTION_FAST_REQUEST_ERROR, SESSION_PROMPT_HISTORY_HEADER, SESSION_PROMPT_LATEST_HEADER, SESSION_PROMPT_INTROS, utf8Encoder, buildPlaygroundRequestPlan, buildExplicitRequestPlan, buildExplicitLocalOperationPlan, buildExplicitLocalOperationPrompt, buildStructuredGenerationPlan, buildDocumentAgentPlan, buildPromptContext } from "./playgroundPlannerPart1"; +import type { PlaygroundPromptContextEnvelope, PlaygroundRequestMode, PlaygroundResolvedContextFormat, PlaygroundRequestPlan, PlaygroundPlannerConfig } from "./playgroundPlannerPart1"; +import { createPlaygroundRequestMetricsSeed, estimateTokens, isStructuredPlannerPrompt, buildPromptEnvelope, buildInlineAutocompletePlan, buildInlineAutocompletePlanFromRequest, resolveAutocompleteOutputTokenCap, extractAutocompleteContinuationTargetScope, buildSelectionFastPathPlan, isExplicitLocalOperation, resolveExplicitLocalOperationSourceText, parseSessionExecutionPrompt, resolveLiveSelectedText, isInlineAutocompletePrompt, buildSelectionPromptEnvelope, parsePinnedSelectionPrompt, extractSelectionInstruction } from "./playgroundPlannerPart2"; + +export function classifySelectionPrompt( + instruction: string, +): "rewrite" | "summarize" | "translate" | "expand" { + const normalizedInstruction = instruction.trim().toLowerCase(); + + if (normalizedInstruction.startsWith("summarize")) { + return "summarize"; + } + + if (normalizedInstruction.startsWith("translate")) { + return "translate"; + } + + if ( + normalizedInstruction.startsWith("expand") || + normalizedInstruction.includes("more detail") + ) { + return "expand"; + } + + if ( + normalizedInstruction.startsWith("rewrite") || + normalizedInstruction.startsWith("fix grammar") || + normalizedInstruction.startsWith("simplify") || + normalizedInstruction.startsWith("shorten") || + normalizedInstruction.startsWith("make") || + normalizedInstruction.startsWith("improve") + ) { + return "rewrite"; + } + + return "rewrite"; +} + +export function resolveSelectionOutputTokenBudget( + promptKind: "rewrite" | "summarize" | "translate" | "expand", + selectedText: string, + config: PlaygroundPlannerConfig, +): number { + const selectedTokenEstimate = estimateTokens(selectedText); + + if (promptKind === "summarize") { + return Math.min( + config.selectionSummarizeOutputTokens, + Math.max(80, Math.ceil(selectedTokenEstimate * 0.6)), + ); + } + + if (promptKind === "translate") { + return Math.min( + config.selectionTranslateOutputTokens, + Math.max(120, Math.ceil(selectedTokenEstimate * 1.35)), + ); + } + + if (promptKind === "expand") { + return Math.min( + config.selectionOutputTokenCap, + Math.max( + config.selectionExpandOutputTokens, + Math.ceil(selectedTokenEstimate * 2), + ), + ); + } + + if (promptKind === "rewrite") { + return Math.min( + 220, + Math.max(72, Math.ceil(selectedTokenEstimate * 1.1)), + ); + } + + return Math.min( + config.selectionOutputTokenCap, + Math.max( + config.selectionDefaultOutputTokens, + selectedTokenEstimate, + ), + ); +} + +export function resolveSelectionTemperature( + promptKind: "rewrite" | "summarize" | "translate" | "expand", +): number { + if (promptKind === "expand") { + return 0.3; + } + + if (promptKind === "translate") { + return 0.2; + } + + return 0; +} + +export function resolveNearbyBlocks( + blocks: Array<{ id: string; type: string; text: string; childCount: number }>, + activeBlockIndex: number, +) { + if (blocks.length === 0) { + return []; + } + + if (activeBlockIndex < 0) { + return blocks.slice(0, 5); + } + + const startIndex = Math.max(0, activeBlockIndex - 2); + const endIndex = Math.min(blocks.length, activeBlockIndex + 3); + return blocks.slice(startIndex, endIndex); +} + +export function resolveSelectionBlockId( + selection: SelectionState, +): string | null { + if (!selection) { + return null; + } + + if (selection.type === "text" && "anchor" in selection) { + return selection.anchor.blockId; + } + + if (selection.type === "cell") { + return selection.blockId; + } + + if (selection.type === "block") { + return selection.blockIds[0] ?? null; + } + + return null; +} + +export function truncateText(value: string, limit: number): string { + if (value.length <= limit) { + return value; + } + + return `${value.slice(0, limit)}...`; +} diff --git a/packages/extensions/ai/src/runtime/promptTargeting.ts b/packages/extensions/ai/src/runtime/promptTargeting.ts new file mode 100644 index 0000000..d03a8cb --- /dev/null +++ b/packages/extensions/ai/src/runtime/promptTargeting.ts @@ -0,0 +1,44 @@ +export function isClearDocumentPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + return ( + /\b(remove|delete|clear|erase|wipe)\b/.test(normalizedPrompt) && + /\b(all|entire|whole|everything)\b/.test(normalizedPrompt) && + /\b(document|content|contents|text|story|page)\b/.test(normalizedPrompt) + ); +} + +export function isWholeDocumentRewritePrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + return ( + /\b(rewrite|redo|revise|rework|replace)\s+(?:the|this|my)?\s*(?:entire|whole|full|all)?\s*(?:document|content|contents|text|story|page)\b/.test( + normalizedPrompt, + ) || /\bmake (?:it|this) about\b/.test(normalizedPrompt) + ); +} + +export function isDocumentResetPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + return /\b(start(?:ing)?\s+(?:over|again|from scratch)|begin\s+again|from scratch|restart)\b/.test( + normalizedPrompt, + ); +} + +export function isDocumentFollowUpEditPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + if ( + /\b(continue|append|add|insert|another|more|next)\b/.test( + normalizedPrompt, + ) + ) { + return false; + } + return ( + /\b(change|update|adjust|edit|fix|improve|polish|revise|rework|rename|retitle|make)\b/.test( + normalizedPrompt, + ) && + (/\b(title|heading|story|document|content|contents|text|tone|voice|ending|opening|intro|introduction|theme)\b/.test( + normalizedPrompt, + ) || + /\bmake (?:it|this)\b/.test(normalizedPrompt)) + ); +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifacts.ts b/packages/extensions/ai/src/runtime/reviewArtifacts.ts index fea14be..fb0d617 100644 --- a/packages/extensions/ai/src/runtime/reviewArtifacts.ts +++ b/packages/extensions/ai/src/runtime/reviewArtifacts.ts @@ -1,1202 +1,2 @@ -import type { Editor } from "@pen/types"; -import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; -import type { DocumentMutationPlan } from "./planTypes"; -import type { AITargetKind } from "./contracts"; - -export interface StructuralReviewItem { - id: string; - targetKind: AITargetKind | "bundle"; - planKind: DocumentMutationPlan["kind"]; - changeKind: "added" | "removed" | "updated" | "moved"; - section: "content" | "block" | "row" | "cell" | "schema" | "view"; - groupId: string; - groupLabel: string; - label: string; - summary: string; - detail?: string; - preview?: string; - before?: string; - after?: string; - comparisonRows?: StructuralReviewComparisonRow[]; - bundlePath: number[]; - stepIndex: number | null; -} - -export interface StructuralReviewComparisonRow { - label: string; - before?: string; - after?: string; - changeKind: "added" | "removed" | "updated"; - section: "schema" | "view"; -} - -interface StructuralReviewBuildContext { - virtualBlocks: Map; -} - -interface DatabaseReviewSnapshot { - columns: TableColumnSchema[]; - rows: Array<{ - id: string; - values: Record; - }>; - views: DatabaseViewState[]; - primaryViewId: string | null; -} - -export interface StructuredPreviewDatabaseState { - columns: TableColumnSchema[]; - rows: Array<{ - id: string; - values: Record; - }>; - views: DatabaseViewState[]; - primaryViewId: string | null; -} - -export interface StructuredPreviewTargetState { - blockId: string; - targetKind: "database"; - database: StructuredPreviewDatabaseState; -} - -type VirtualReviewBlock = { - type: "database"; - database: DatabaseReviewSnapshot; -}; - -export function buildStructuralReviewItems( - editor: Editor, - plan: DocumentMutationPlan, -): StructuralReviewItem[] { - return buildStructuralPreviewArtifacts(editor, plan).reviewItems; -} - -export function buildStructuredPreviewTargets( - editor: Editor, - plan: DocumentMutationPlan, -): StructuredPreviewTargetState[] { - return buildStructuralPreviewArtifacts(editor, plan).targets; -} - -function buildStructuralPreviewArtifacts( - editor: Editor, - plan: DocumentMutationPlan, -): { - reviewItems: StructuralReviewItem[]; - targets: StructuredPreviewTargetState[]; -} { - const context: StructuralReviewBuildContext = { - virtualBlocks: new Map(), - }; - const reviewItems = buildReviewItemsForPlan(editor, plan, [], context); - return { - reviewItems, - targets: serializeStructuredPreviewTargets(context.virtualBlocks), - }; -} - -export function selectStructuralReviewItemPlan( - plan: DocumentMutationPlan, - item: StructuralReviewItem, -): DocumentMutationPlan | null { - return selectPlanAtPath(plan, item.bundlePath, item.stepIndex); -} - -export function removeStructuralReviewItemPlan( - plan: DocumentMutationPlan, - item: StructuralReviewItem, -): DocumentMutationPlan | null { - return removePlanAtPath(plan, item.bundlePath, item.stepIndex); -} - -function buildReviewItemsForPlan( - editor: Editor, - plan: DocumentMutationPlan, - bundlePath: number[], - context: StructuralReviewBuildContext, -): StructuralReviewItem[] { - switch (plan.kind) { - case "text_edit": - return [ - createReviewItem(bundlePath, plan.kind, "text", { - changeKind: describeTextEditChangeKind(plan.operation), - section: "content", - groupId: `block:${plan.target.blockId}`, - groupLabel: `Block "${plan.target.blockId}"`, - label: describeTextEditLabel(plan.operation), - summary: "Updates the selected text range.", - preview: plan.text, - before: readTextEditBefore(editor, plan), - after: plan.text, - }), - ]; - case "flow_patch": - return plan.edits.map((edit, index) => - createReviewItem(bundlePath, plan.kind, "text", { - changeKind: - edit.operation === "append_text" || edit.operation === "insert_after" || edit.operation === "insert_before" - ? "added" - : edit.operation === "delete_blocks" - ? "removed" - : "updated", - section: "content", - groupId: - edit.locator.blockId != null - ? `block:${edit.locator.blockId}` - : `span:${plan.targetSpanId ?? "flow-patch"}`, - groupLabel: - edit.locator.blockId != null - ? `Block "${edit.locator.blockId}"` - : `Span "${plan.targetSpanId ?? "flow-patch"}"`, - label: `Flow patch: ${edit.operation}`, - summary: plan.instructions, - detail: edit.locator.expectedBlockType, - preview: edit.text ?? edit.markdown, - before: - edit.locator.blockId != null - ? editor.getBlock(edit.locator.blockId)?.textContent() ?? undefined - : undefined, - after: edit.text ?? edit.markdown, - stepIndex: index, - }), - ); - case "block_insert": - registerInsertedReviewBlock(context, plan); - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "added", - section: "block", - groupId: "blocks", - groupLabel: "Blocks", - label: "Insert block", - summary: `Adds a new ${plan.blockType} block.`, - detail: plan.blockType, - preview: plan.initialText, - before: "(new block)", - after: describeInsertedBlockAfter(plan), - }), - ]; - case "block_update": - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "updated", - section: "block", - groupId: `block:${plan.blockId}`, - groupLabel: `Block "${plan.blockId}"`, - label: "Update block", - summary: "Updates block properties.", - detail: `${Object.keys(plan.props).length} prop changes`, - before: readBlockPropsPreview(editor, plan.blockId), - after: stringifyReviewValue(plan.props), - }), - ]; - case "block_move": - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "moved", - section: "block", - groupId: `block:${plan.blockId}`, - groupLabel: `Block "${plan.blockId}"`, - label: "Move block", - summary: "Moves this block to a new position.", - }), - ]; - case "block_convert": - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "updated", - section: "block", - groupId: `block:${plan.blockId}`, - groupLabel: `Block "${plan.blockId}"`, - label: "Convert block", - summary: `Converts this block to ${plan.newType}.`, - detail: plan.newType, - before: readBlockTypePreview(editor, plan.blockId), - after: plan.newType, - }), - ]; - case "database_edit": - return buildDatabaseReviewItems( - editor, - plan, - bundlePath, - context, - ); - case "review_bundle": - return plan.plans.flatMap((nestedPlan, index) => - buildReviewItemsForPlan(editor, nestedPlan, [...bundlePath, index], context), - ); - } -} - -function serializeStructuredPreviewTargets( - virtualBlocks: Map, -): StructuredPreviewTargetState[] { - return [...virtualBlocks.entries()].map(([blockId, virtualBlock]) => { - return { - blockId, - targetKind: "database", - database: cloneDatabaseReviewSnapshot(virtualBlock.database), - }; - }); -} - -function buildDatabaseReviewItems( - editor: Editor, - plan: Extract, - bundlePath: number[], - context: StructuralReviewBuildContext, -): StructuralReviewItem[] { - const snapshot = getDatabaseReviewSnapshot(editor, plan.blockId, context); - const items: StructuralReviewItem[] = []; - - for (let index = 0; index < plan.steps.length; index += 1) { - const step = plan.steps[index]!; - const beforeSnapshot = snapshot ? cloneDatabaseReviewSnapshot(snapshot) : null; - items.push( - createReviewItem(bundlePath, plan.kind, "database", { - changeKind: describeDatabaseStepChangeKind(step.op), - section: describeDatabaseStepSection(step.op), - groupId: `database:${plan.blockId}`, - groupLabel: `Database "${plan.blockId}"`, - label: describeDatabaseStepLabel(step.op), - summary: describeDatabaseStepSummary(plan.blockId, step), - detail: describeDatabaseStepDetail(beforeSnapshot, step), - preview: describeDatabaseStepPreview(step), - before: describeDatabaseStepBefore(beforeSnapshot, step), - after: describeDatabaseStepAfter(beforeSnapshot, step), - comparisonRows: describeDatabaseStepComparisonRows(beforeSnapshot, step), - stepIndex: index, - }), - ); - if (snapshot) { - applyDatabaseStepToReviewSnapshot(snapshot, step); - } - } - - if (snapshot) { - context.virtualBlocks.set(plan.blockId, { - type: "database", - database: cloneDatabaseReviewSnapshot(snapshot), - }); - } - - return items; -} - -function createReviewItem( - bundlePath: number[], - planKind: DocumentMutationPlan["kind"], - targetKind: StructuralReviewItem["targetKind"], - input: { - changeKind: StructuralReviewItem["changeKind"]; - section: StructuralReviewItem["section"]; - groupId: string; - groupLabel: string; - label: string; - summary: string; - detail?: string; - preview?: string; - before?: string; - after?: string; - comparisonRows?: StructuralReviewComparisonRow[]; - stepIndex?: number; - }, -): StructuralReviewItem { - const stepIndex = input.stepIndex ?? null; - return { - id: createReviewItemId(planKind, bundlePath, stepIndex), - targetKind, - planKind, - changeKind: input.changeKind, - section: input.section, - groupId: input.groupId, - groupLabel: input.groupLabel, - label: input.label, - summary: input.summary, - detail: input.detail, - preview: input.preview, - before: input.before, - after: input.after, - comparisonRows: input.comparisonRows, - bundlePath, - stepIndex, - }; -} - -function createReviewItemId( - planKind: DocumentMutationPlan["kind"], - bundlePath: number[], - stepIndex: number | null, -): string { - const pathPart = bundlePath.length > 0 ? bundlePath.join(".") : "root"; - const stepPart = stepIndex == null ? "plan" : `step-${stepIndex}`; - return `plan:${planKind}:${pathPart}:${stepPart}`; -} - -function selectPlanAtPath( - plan: DocumentMutationPlan, - bundlePath: number[], - stepIndex: number | null, -): DocumentMutationPlan | null { - if (bundlePath.length > 0) { - if (plan.kind !== "review_bundle") { - return null; - } - const [head, ...tail] = bundlePath; - const nestedPlan = plan.plans[head]; - if (!nestedPlan) { - return null; - } - return selectPlanAtPath(nestedPlan, tail, stepIndex); - } - - if (stepIndex == null) { - return plan; - } - - if (plan.kind === "database_edit") { - const step = plan.steps[stepIndex]; - return step ? { ...plan, steps: [step] } : null; - } - if (plan.kind === "flow_patch") { - const edit = plan.edits[stepIndex]; - return edit ? { ...plan, edits: [edit] } : null; - } - - return null; -} - -function removePlanAtPath( - plan: DocumentMutationPlan, - bundlePath: number[], - stepIndex: number | null, -): DocumentMutationPlan | null { - if (bundlePath.length > 0) { - if (plan.kind !== "review_bundle") { - return null; - } - const [head, ...tail] = bundlePath; - const nestedPlan = plan.plans[head]; - if (!nestedPlan) { - return plan; - } - const nextNestedPlan = removePlanAtPath(nestedPlan, tail, stepIndex); - const nextPlans = plan.plans.flatMap((entry, index) => { - if (index !== head) { - return [entry]; - } - return nextNestedPlan ? [nextNestedPlan] : []; - }); - if (nextPlans.length === 0) { - return null; - } - if (nextPlans.length === 1) { - return nextPlans[0] ?? null; - } - return { ...plan, plans: nextPlans }; - } - - if (stepIndex == null) { - return null; - } - - if (plan.kind === "database_edit") { - const nextSteps = plan.steps.filter((_, index) => index !== stepIndex); - return nextSteps.length > 0 ? { ...plan, steps: nextSteps } : null; - } - if (plan.kind === "flow_patch") { - const nextEdits = plan.edits.filter((_, index) => index !== stepIndex); - return nextEdits.length > 0 ? { ...plan, edits: nextEdits } : null; - } - - return null; -} - -function describeTextEditLabel( - operation: "replace" | "insert" | "append", -): string { - if (operation === "replace") { - return "Replace text"; - } - if (operation === "insert") { - return "Insert text"; - } - return "Append text"; -} - -function describeTextEditChangeKind( - operation: "replace" | "insert" | "append", -): StructuralReviewItem["changeKind"] { - return operation === "replace" ? "updated" : "added"; -} - -function describeDatabaseStepLabel(step: string): string { - switch (step) { - case "add_column": - return "Add column"; - case "update_column": - return "Update column"; - case "insert_row": - return "Insert row"; - case "update_cell": - return "Update cell"; - case "add_view": - return "Add view"; - case "set_active_view": - return "Set active view"; - default: - return "Database change"; - } -} - -function describeDatabaseStepChangeKind( - step: string, -): StructuralReviewItem["changeKind"] { - switch (step) { - case "add_column": - case "insert_row": - case "add_view": - return "added"; - case "update_column": - case "update_cell": - case "set_active_view": - default: - return "updated"; - } -} - -function describeDatabaseStepSection( - step: string, -): StructuralReviewItem["section"] { - switch (step) { - case "add_column": - case "update_column": - return "schema"; - case "insert_row": - return "row"; - case "update_cell": - return "cell"; - case "add_view": - case "set_active_view": - default: - return "view"; - } -} - -function describeDatabaseStepSummary( - blockId: string, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string { - switch (step.op) { - case "add_column": - return `Adds a column to database "${blockId}".`; - case "update_column": - return `Updates a column in database "${blockId}".`; - case "insert_row": - return `Adds a row to database "${blockId}".`; - case "update_cell": - return `Updates a database cell in "${blockId}".`; - case "add_view": - return `Adds a view to database "${blockId}".`; - case "set_active_view": - return `Changes the active view for database "${blockId}".`; - } -} - -function describeDatabaseStepDetail( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "add_column": - return resolveColumnLabel(step.column); - case "update_column": - return resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId); - case "insert_row": - return formatDatabaseValueKeys(snapshot?.columns ?? [], step.values); - case "update_cell": - return `${resolveDatabaseRowLabel(snapshot, step.rowId)} · ${resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId)}`; - case "add_view": - return resolveViewLabel(step.view); - case "set_active_view": - return ( - snapshot?.views.find((view) => view.id === step.viewId)?.title ?? - snapshot?.views.find((view) => view.id === step.viewId)?.id ?? - step.viewId - ); - } -} - -function describeDatabaseStepPreview( - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "update_cell": - return stringifyReviewValue(step.value); - case "insert_row": - return stringifyReviewValue(step.values); - default: - return undefined; - } -} - -function describeDatabaseStepBefore( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "add_column": - return summarizeColumns(snapshot?.columns ?? []); - case "update_column": - return formatColumnSchema( - snapshot?.columns.find((column) => column.id === step.columnId), - ); - case "insert_row": - return snapshot ? `${snapshot.rows.length} rows` : undefined; - case "update_cell": { - if (!snapshot) { - return undefined; - } - const rowIndex = findDatabaseReviewRowIndex(snapshot, step.rowId); - const colIndex = findColumnIndex(snapshot.columns, step.columnId); - if (rowIndex === -1 || colIndex === -1) { - return undefined; - } - const columnId = snapshot.columns[colIndex]?.id; - return columnId ? snapshot.rows[rowIndex]?.values[columnId] ?? "" : undefined; - } - case "add_view": - return summarizeViews(snapshot?.views ?? []); - case "set_active_view": - return snapshot ? resolveViewLabel(resolveDatabaseActiveViewSnapshot(snapshot)) : undefined; - } -} - -function describeDatabaseStepAfter( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "add_column": - return formatColumnSchema(step.column); - case "update_column": { - const column = snapshot?.columns.find((entry) => entry.id === step.columnId); - return formatColumnSchema(column ? { ...column, ...step.patch } : undefined); - } - case "insert_row": - return snapshot ? `${snapshot.rows.length + 1} rows` : undefined; - case "update_cell": - return stringifyReviewValue(step.value); - case "add_view": - return formatViewState(step.view, snapshot?.columns ?? []); - case "set_active_view": { - const nextView = snapshot?.views.find((view) => view.id === step.viewId); - return resolveViewLabel(nextView) ?? step.viewId; - } - } -} - -function describeDatabaseStepComparisonRows( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): StructuralReviewComparisonRow[] | undefined { - switch (step.op) { - case "add_column": - return [ - { - label: "Column", - before: undefined, - after: formatColumnSchema(step.column), - changeKind: "added", - section: "schema", - }, - ]; - case "update_column": { - const column = snapshot?.columns.find((entry) => entry.id === step.columnId); - const nextColumn = column ? { ...column, ...step.patch } : undefined; - if (!column && !nextColumn) { - return undefined; - } - return buildColumnSchemaComparisonRows(column, nextColumn); - } - case "add_view": - return buildViewComparisonRows(undefined, step.view, snapshot?.columns ?? []); - case "set_active_view": - return buildViewComparisonRows( - resolveDatabaseActiveViewSnapshot(snapshot) ?? undefined, - snapshot?.views.find((view) => view.id === step.viewId), - snapshot?.columns ?? [], - ); - default: - return undefined; - } -} - -function stringifyReviewValue(value: unknown): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function readTextEditBefore( - editor: Editor, - plan: Extract, -): string | undefined { - const block = editor.getBlock(plan.target.blockId); - if (!block) { - return undefined; - } - const text = block.textContent(); - if (plan.target.range) { - return text.slice( - plan.target.range.startOffset, - plan.target.range.endOffset, - ); - } - return text; -} - -function readBlockPropsPreview(editor: Editor, blockId: string): string | undefined { - const block = editor.getBlock(blockId); - return block ? stringifyReviewValue(block.props) : undefined; -} - -function readBlockTypePreview(editor: Editor, blockId: string): string | undefined { - const block = editor.getBlock(blockId); - return block?.type; -} - -function registerInsertedReviewBlock( - context: StructuralReviewBuildContext, - plan: Extract, -): void { - if (!plan.blockId) { - return; - } - if (plan.blockType === "database") { - context.virtualBlocks.set(plan.blockId, { - type: "database", - database: createDefaultDatabaseReviewSnapshot(), - }); - } -} - -function describeInsertedBlockAfter( - plan: Extract, -): string | undefined { - if (plan.initialText) { - return plan.initialText; - } - if (plan.blockType === "database") { - return "3 columns, 0 rows, 1 view"; - } - return plan.blockType; -} - -function getDatabaseReviewSnapshot( - editor: Editor, - blockId: string, - context: StructuralReviewBuildContext, -): DatabaseReviewSnapshot | null { - const virtualBlock = context.virtualBlocks.get(blockId); - if (virtualBlock?.type === "database") { - return cloneDatabaseReviewSnapshot(virtualBlock.database); - } - const block = editor.getBlock(blockId); - if (!block || block.type !== "database") { - return null; - } - const columns = [...block.tableColumns()]; - const rows = Array.from({ length: block.tableRowCount() }, (_, rowIndex) => { - const rowId = block.tableRow(rowIndex)?.id ?? `row-${rowIndex + 1}`; - return { - id: rowId, - values: Object.fromEntries( - columns.map((column, colIndex) => [ - column.id, - block.tableCell(rowIndex, colIndex)?.textContent() ?? "", - ]), - ), - }; - }); - return { - columns, - rows, - views: [...block.databaseViews()], - primaryViewId: block.databasePrimaryViewId(), - }; -} - -function cloneDatabaseReviewSnapshot( - snapshot: DatabaseReviewSnapshot, -): DatabaseReviewSnapshot { - return { - columns: snapshot.columns.map((column) => ({ ...column })), - rows: snapshot.rows.map((row) => ({ - id: row.id, - values: { ...row.values }, - })), - views: snapshot.views.map((view) => ({ - ...view, - visibleColumnIds: view.visibleColumnIds ? [...view.visibleColumnIds] : undefined, - columnOrder: view.columnOrder ? [...view.columnOrder] : undefined, - sort: view.sort ? [...view.sort] : undefined, - rowPinning: view.rowPinning ? { ...view.rowPinning } : undefined, - })), - primaryViewId: snapshot.primaryViewId, - }; -} - -function createDefaultDatabaseReviewSnapshot(): DatabaseReviewSnapshot { - const columns: TableColumnSchema[] = [ - { id: "name", title: "Name", type: "text" }, - { id: "tags", title: "Tags", type: "select" }, - { id: "done", title: "Done", type: "checkbox" }, - ]; - const primaryViewId = "view-table"; - return { - columns, - rows: [], - views: [ - { - id: primaryViewId, - title: "Table", - type: "table", - visibleColumnIds: columns.map((column) => column.id), - columnOrder: columns.map((column) => column.id), - }, - ], - primaryViewId, - }; -} - -function applyDatabaseStepToReviewSnapshot( - snapshot: DatabaseReviewSnapshot, - step: Extract["steps"][number], -): void { - switch (step.op) { - case "add_column": - snapshot.columns.push({ ...step.column }); - for (const row of snapshot.rows) { - row.values[step.column.id] = ""; - } - return; - case "update_column": { - const columnIndex = snapshot.columns.findIndex( - (column) => column.id === step.columnId, - ); - if (columnIndex !== -1) { - snapshot.columns[columnIndex] = { - ...snapshot.columns[columnIndex]!, - ...step.patch, - }; - } - return; - } - case "insert_row": - snapshot.rows.push({ - id: step.rowId ?? `row-${snapshot.rows.length + 1}`, - values: stringifyRecord(step.values), - }); - return; - case "update_cell": { - const row = snapshot.rows.find((entry) => entry.id === step.rowId); - if (row) { - row.values[step.columnId] = stringifyDatabaseValue(step.value); - } - return; - } - case "add_view": - snapshot.views.push({ - ...step.view, - visibleColumnIds: step.view.visibleColumnIds - ? [...step.view.visibleColumnIds] - : undefined, - columnOrder: step.view.columnOrder ? [...step.view.columnOrder] : undefined, - sort: step.view.sort ? [...step.view.sort] : undefined, - rowPinning: step.view.rowPinning ? { ...step.view.rowPinning } : undefined, - }); - return; - case "set_active_view": - snapshot.primaryViewId = step.viewId; - return; - } -} - -function summarizeColumns(columns: readonly TableColumnSchema[]): string | undefined { - if (columns.length === 0) { - return undefined; - } - return columns.map(formatColumnSchema).filter(Boolean).join(", "); -} - -function summarizeViews(views: readonly DatabaseViewState[]): string | undefined { - if (views.length === 0) { - return undefined; - } - return views.map((view) => resolveViewLabel(view)).filter(Boolean).join(", "); -} - -function findDatabaseReviewRowIndex( - snapshot: DatabaseReviewSnapshot, - rowId: string, -): number { - for (let index = 0; index < snapshot.rows.length; index += 1) { - if (snapshot.rows[index]?.id === rowId) { - return index; - } - } - return -1; -} - -function findColumnIndex( - columns: readonly TableColumnSchema[], - columnId: string, -): number { - return columns.findIndex((column) => column.id === columnId); -} - -function resolveColumnLabel(column: TableColumnSchema | undefined): string { - return column?.title || column?.id || "Column"; -} - -function resolveDatabaseColumnLabel( - columns: readonly TableColumnSchema[], - columnId: string, -): string { - const column = columns.find((entry) => entry.id === columnId); - return column ? resolveColumnLabel(column) : columnId; -} - -function resolveDatabaseRowLabel( - snapshot: DatabaseReviewSnapshot | null, - rowId: string, -): string { - if (!snapshot) { - return rowId; - } - const rowIndex = findDatabaseReviewRowIndex(snapshot, rowId); - if (rowIndex === -1) { - return rowId; - } - - const columns = snapshot.columns; - const preferredColumnIds = [ - columns.find((column) => column.title.toLowerCase() === "name")?.id, - columns[0]?.id, - ].filter(Boolean) as string[]; - - for (const columnId of preferredColumnIds) { - const value = snapshot.rows[rowIndex]?.values[columnId]?.trim(); - if (value) { - return value; - } - } - - return `Row ${rowIndex + 1}`; -} - -function resolveDatabaseActiveViewSnapshot( - snapshot: DatabaseReviewSnapshot | null, -): DatabaseViewState | null { - if (!snapshot) { - return null; - } - if (!snapshot.primaryViewId) { - return snapshot.views[0] ?? null; - } - return ( - snapshot.views.find((view) => view.id === snapshot.primaryViewId) ?? - snapshot.views[0] ?? - null - ); -} - -function resolveViewLabel(view: DatabaseViewState | null | undefined): string | undefined { - if (!view) { - return undefined; - } - return view.title ?? view.id; -} - -function formatColumnSchema( - column: TableColumnSchema | undefined, -): string | undefined { - if (!column) { - return undefined; - } - - const parts = [`${resolveColumnLabel(column)} [${column.type}]`]; - if (column.width != null) { - parts.push(`w:${column.width}`); - } - if (column.hidden) { - parts.push("hidden"); - } - if (column.pinned) { - parts.push(`pinned:${column.pinned}`); - } - return parts.join(" "); -} - -function formatViewState( - view: DatabaseViewState | undefined, - columns: readonly TableColumnSchema[], -): string | undefined { - if (!view) { - return undefined; - } - - const parts = [`${resolveViewLabel(view)} [${view.type}]`]; - if (view.groupBy) { - parts.push(`group:${resolveDatabaseColumnLabel(columns, view.groupBy)}`); - } - if (view.visibleColumnIds && view.visibleColumnIds.length > 0) { - parts.push( - `visible:${view.visibleColumnIds - .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) - .join(", ")}`, - ); - } - return parts.join(" "); -} - -function formatDatabaseValueKeys( - columns: readonly TableColumnSchema[], - values: Record, -): string | undefined { - const keys = Object.keys(values); - if (keys.length === 0) { - return undefined; - } - return keys - .map((key) => resolveDatabaseColumnLabel(columns, key)) - .join(", "); -} - -function stringifyRecord( - value: Record, -): Record { - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [ - key, - stringifyDatabaseValue(entryValue), - ]), - ); -} - -function stringifyDatabaseValue(value: unknown): string { - if (value == null) { - return ""; - } - if (typeof value === "string") { - return value; - } - if ( - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function buildColumnComparisonRows( - beforeColumns: readonly TableColumnSchema[], - afterColumns: readonly TableColumnSchema[], -): StructuralReviewComparisonRow[] | undefined { - const rows: StructuralReviewComparisonRow[] = []; - const beforeOrder = beforeColumns.map((column) => resolveColumnLabel(column)).join(", "); - const afterOrder = afterColumns.map((column) => resolveColumnLabel(column)).join(", "); - if (beforeOrder !== afterOrder) { - rows.push({ - label: "Order", - before: beforeOrder || undefined, - after: afterOrder || undefined, - changeKind: "updated", - section: "schema", - }); - } - - const beforeById = new Map(beforeColumns.map((column) => [column.id, column])); - const afterById = new Map(afterColumns.map((column) => [column.id, column])); - const allIds = [...new Set([...beforeById.keys(), ...afterById.keys()])]; - - for (const id of allIds) { - const beforeColumn = beforeById.get(id); - const afterColumn = afterById.get(id); - if (!beforeColumn && afterColumn) { - rows.push({ - label: `Added ${resolveColumnLabel(afterColumn)}`, - after: formatColumnSchema(afterColumn), - changeKind: "added", - section: "schema", - }); - continue; - } - if (beforeColumn && !afterColumn) { - rows.push({ - label: `Removed ${resolveColumnLabel(beforeColumn)}`, - before: formatColumnSchema(beforeColumn), - changeKind: "removed", - section: "schema", - }); - continue; - } - if (!beforeColumn || !afterColumn) { - continue; - } - if (!areColumnSchemasEqual(beforeColumn, afterColumn)) { - rows.push({ - label: resolveColumnLabel(afterColumn), - before: formatColumnSchema(beforeColumn), - after: formatColumnSchema(afterColumn), - changeKind: "updated", - section: "schema", - }); - } - } - - return rows.length > 0 ? rows : undefined; -} - -function buildColumnSchemaComparisonRows( - beforeColumn: TableColumnSchema | undefined, - afterColumn: TableColumnSchema | undefined, -): StructuralReviewComparisonRow[] | undefined { - if (!beforeColumn && !afterColumn) { - return undefined; - } - - const rows: StructuralReviewComparisonRow[] = []; - const label = resolveColumnLabel(afterColumn ?? beforeColumn); - rows.push({ - label, - before: formatColumnSchema(beforeColumn), - after: formatColumnSchema(afterColumn), - changeKind: - beforeColumn == null ? "added" : afterColumn == null ? "removed" : "updated", - section: "schema", - }); - - return rows; -} - -function buildViewComparisonRows( - beforeView: DatabaseViewState | undefined, - afterView: DatabaseViewState | undefined, - columns: readonly TableColumnSchema[], -): StructuralReviewComparisonRow[] | undefined { - if (!beforeView && !afterView) { - return undefined; - } - - const rows: StructuralReviewComparisonRow[] = [ - { - label: "View", - before: resolveViewLabel(beforeView), - after: resolveViewLabel(afterView), - changeKind: - beforeView == null ? "added" : afterView == null ? "removed" : "updated", - section: "view", - }, - { - label: "Type", - before: beforeView?.type, - after: afterView?.type, - changeKind: "updated", - section: "view", - }, - { - label: "Group by", - before: beforeView?.groupBy - ? resolveDatabaseColumnLabel(columns, beforeView.groupBy) - : undefined, - after: afterView?.groupBy - ? resolveDatabaseColumnLabel(columns, afterView.groupBy) - : undefined, - changeKind: "updated", - section: "view", - }, - { - label: "Visible columns", - before: beforeView?.visibleColumnIds?.length - ? beforeView.visibleColumnIds - .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) - .join(", ") - : undefined, - after: afterView?.visibleColumnIds?.length - ? afterView.visibleColumnIds - .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) - .join(", ") - : undefined, - changeKind: "updated", - section: "view", - }, - { - label: "Sort", - before: formatViewSort(beforeView, columns), - after: formatViewSort(afterView, columns), - changeKind: "updated", - section: "view", - }, - ]; - - const meaningfulRows = rows.filter((row) => row.before !== row.after); - return meaningfulRows.length > 0 ? meaningfulRows : undefined; -} - -function areColumnSchemasEqual( - left: TableColumnSchema, - right: TableColumnSchema, -): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -function formatViewSort( - view: DatabaseViewState | undefined, - columns: readonly TableColumnSchema[], -): string | undefined { - if (!view?.sort || view.sort.length === 0) { - return undefined; - } - return view.sort - .map( - (sortEntry) => - `${resolveDatabaseColumnLabel(columns, sortEntry.columnId)} ${sortEntry.direction}`, - ) - .join(", "); -} +export { buildStructuralReviewItems, buildStructuredPreviewTargets, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan } from "./reviewArtifactsParts/reviewArtifactsPart1"; +export type { StructuralReviewItem, StructuralReviewComparisonRow, StructuredPreviewDatabaseState, StructuredPreviewTargetState } from "./reviewArtifactsParts/reviewArtifactsPart1"; diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts new file mode 100644 index 0000000..16a780f --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts @@ -0,0 +1,340 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { selectPlanAtPath, removePlanAtPath, describeTextEditLabel, describeTextEditChangeKind, describeDatabaseStepLabel, describeDatabaseStepChangeKind, describeDatabaseStepSection, describeDatabaseStepSummary, describeDatabaseStepDetail, describeDatabaseStepPreview, describeDatabaseStepBefore, describeDatabaseStepAfter, describeDatabaseStepComparisonRows, stringifyReviewValue, readTextEditBefore, readBlockPropsPreview, readBlockTypePreview } from "./reviewArtifactsPart2"; +import { registerInsertedReviewBlock, describeInsertedBlockAfter, getDatabaseReviewSnapshot, cloneDatabaseReviewSnapshot, createDefaultDatabaseReviewSnapshot, applyDatabaseStepToReviewSnapshot, summarizeColumns, summarizeViews, findDatabaseReviewRowIndex, findColumnIndex, resolveColumnLabel, resolveDatabaseColumnLabel, resolveDatabaseRowLabel, resolveDatabaseActiveViewSnapshot, resolveViewLabel, formatColumnSchema, formatViewState, formatDatabaseValueKeys, stringifyRecord, stringifyDatabaseValue } from "./reviewArtifactsPart3"; +import { buildColumnComparisonRows, buildColumnSchemaComparisonRows, buildViewComparisonRows, areColumnSchemasEqual, formatViewSort } from "./reviewArtifactsPart4"; + +export interface StructuralReviewItem { + id: string; + targetKind: AITargetKind | "bundle"; + planKind: DocumentMutationPlan["kind"]; + changeKind: "added" | "removed" | "updated" | "moved"; + section: "content" | "block" | "row" | "cell" | "schema" | "view"; + groupId: string; + groupLabel: string; + label: string; + summary: string; + detail?: string; + preview?: string; + before?: string; + after?: string; + comparisonRows?: StructuralReviewComparisonRow[]; + bundlePath: number[]; + stepIndex: number | null; +} + +export interface StructuralReviewComparisonRow { + label: string; + before?: string; + after?: string; + changeKind: "added" | "removed" | "updated"; + section: "schema" | "view"; +} + +export interface StructuralReviewBuildContext { + virtualBlocks: Map; +} + +export interface DatabaseReviewSnapshot { + columns: TableColumnSchema[]; + rows: Array<{ + id: string; + values: Record; + }>; + views: DatabaseViewState[]; + primaryViewId: string | null; +} + +export interface StructuredPreviewDatabaseState { + columns: TableColumnSchema[]; + rows: Array<{ + id: string; + values: Record; + }>; + views: DatabaseViewState[]; + primaryViewId: string | null; +} + +export interface StructuredPreviewTargetState { + blockId: string; + targetKind: "database"; + database: StructuredPreviewDatabaseState; +} + +export type VirtualReviewBlock = { + type: "database"; + database: DatabaseReviewSnapshot; +}; + +export function buildStructuralReviewItems( + editor: Editor, + plan: DocumentMutationPlan, +): StructuralReviewItem[] { + return buildStructuralPreviewArtifacts(editor, plan).reviewItems; +} + +export function buildStructuredPreviewTargets( + editor: Editor, + plan: DocumentMutationPlan, +): StructuredPreviewTargetState[] { + return buildStructuralPreviewArtifacts(editor, plan).targets; +} + +export function buildStructuralPreviewArtifacts( + editor: Editor, + plan: DocumentMutationPlan, +): { + reviewItems: StructuralReviewItem[]; + targets: StructuredPreviewTargetState[]; +} { + const context: StructuralReviewBuildContext = { + virtualBlocks: new Map(), + }; + const reviewItems = buildReviewItemsForPlan(editor, plan, [], context); + return { + reviewItems, + targets: serializeStructuredPreviewTargets(context.virtualBlocks), + }; +} + +export function selectStructuralReviewItemPlan( + plan: DocumentMutationPlan, + item: StructuralReviewItem, +): DocumentMutationPlan | null { + return selectPlanAtPath(plan, item.bundlePath, item.stepIndex); +} + +export function removeStructuralReviewItemPlan( + plan: DocumentMutationPlan, + item: StructuralReviewItem, +): DocumentMutationPlan | null { + return removePlanAtPath(plan, item.bundlePath, item.stepIndex); +} + +export function buildReviewItemsForPlan( + editor: Editor, + plan: DocumentMutationPlan, + bundlePath: number[], + context: StructuralReviewBuildContext, +): StructuralReviewItem[] { + switch (plan.kind) { + case "text_edit": + return [ + createReviewItem(bundlePath, plan.kind, "text", { + changeKind: describeTextEditChangeKind(plan.operation), + section: "content", + groupId: `block:${plan.target.blockId}`, + groupLabel: `Block "${plan.target.blockId}"`, + label: describeTextEditLabel(plan.operation), + summary: "Updates the selected text range.", + preview: plan.text, + before: readTextEditBefore(editor, plan), + after: plan.text, + }), + ]; + case "flow_patch": + return plan.edits.map((edit, index) => + createReviewItem(bundlePath, plan.kind, "text", { + changeKind: + edit.operation === "append_text" || edit.operation === "insert_after" || edit.operation === "insert_before" + ? "added" + : edit.operation === "delete_blocks" + ? "removed" + : "updated", + section: "content", + groupId: + edit.locator.blockId != null + ? `block:${edit.locator.blockId}` + : `span:${plan.targetSpanId ?? "flow-patch"}`, + groupLabel: + edit.locator.blockId != null + ? `Block "${edit.locator.blockId}"` + : `Span "${plan.targetSpanId ?? "flow-patch"}"`, + label: `Flow patch: ${edit.operation}`, + summary: plan.instructions, + detail: edit.locator.expectedBlockType, + preview: edit.text ?? edit.markdown, + before: + edit.locator.blockId != null + ? editor.getBlock(edit.locator.blockId)?.textContent() ?? undefined + : undefined, + after: edit.text ?? edit.markdown, + stepIndex: index, + }), + ); + case "block_insert": + registerInsertedReviewBlock(context, plan); + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "added", + section: "block", + groupId: "blocks", + groupLabel: "Blocks", + label: "Insert block", + summary: `Adds a new ${plan.blockType} block.`, + detail: plan.blockType, + preview: plan.initialText, + before: "(new block)", + after: describeInsertedBlockAfter(plan), + }), + ]; + case "block_update": + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "updated", + section: "block", + groupId: `block:${plan.blockId}`, + groupLabel: `Block "${plan.blockId}"`, + label: "Update block", + summary: "Updates block properties.", + detail: `${Object.keys(plan.props).length} prop changes`, + before: readBlockPropsPreview(editor, plan.blockId), + after: stringifyReviewValue(plan.props), + }), + ]; + case "block_move": + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "moved", + section: "block", + groupId: `block:${plan.blockId}`, + groupLabel: `Block "${plan.blockId}"`, + label: "Move block", + summary: "Moves this block to a new position.", + }), + ]; + case "block_convert": + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "updated", + section: "block", + groupId: `block:${plan.blockId}`, + groupLabel: `Block "${plan.blockId}"`, + label: "Convert block", + summary: `Converts this block to ${plan.newType}.`, + detail: plan.newType, + before: readBlockTypePreview(editor, plan.blockId), + after: plan.newType, + }), + ]; + case "database_edit": + return buildDatabaseReviewItems( + editor, + plan, + bundlePath, + context, + ); + case "review_bundle": + return plan.plans.flatMap((nestedPlan, index) => + buildReviewItemsForPlan(editor, nestedPlan, [...bundlePath, index], context), + ); + } +} + +export function serializeStructuredPreviewTargets( + virtualBlocks: Map, +): StructuredPreviewTargetState[] { + return [...virtualBlocks.entries()].map(([blockId, virtualBlock]) => { + return { + blockId, + targetKind: "database", + database: cloneDatabaseReviewSnapshot(virtualBlock.database), + }; + }); +} + +export function buildDatabaseReviewItems( + editor: Editor, + plan: Extract, + bundlePath: number[], + context: StructuralReviewBuildContext, +): StructuralReviewItem[] { + const snapshot = getDatabaseReviewSnapshot(editor, plan.blockId, context); + const items: StructuralReviewItem[] = []; + + for (let index = 0; index < plan.steps.length; index += 1) { + const step = plan.steps[index]!; + const beforeSnapshot = snapshot ? cloneDatabaseReviewSnapshot(snapshot) : null; + items.push( + createReviewItem(bundlePath, plan.kind, "database", { + changeKind: describeDatabaseStepChangeKind(step.op), + section: describeDatabaseStepSection(step.op), + groupId: `database:${plan.blockId}`, + groupLabel: `Database "${plan.blockId}"`, + label: describeDatabaseStepLabel(step.op), + summary: describeDatabaseStepSummary(plan.blockId, step), + detail: describeDatabaseStepDetail(beforeSnapshot, step), + preview: describeDatabaseStepPreview(step), + before: describeDatabaseStepBefore(beforeSnapshot, step), + after: describeDatabaseStepAfter(beforeSnapshot, step), + comparisonRows: describeDatabaseStepComparisonRows(beforeSnapshot, step), + stepIndex: index, + }), + ); + if (snapshot) { + applyDatabaseStepToReviewSnapshot(snapshot, step); + } + } + + if (snapshot) { + context.virtualBlocks.set(plan.blockId, { + type: "database", + database: cloneDatabaseReviewSnapshot(snapshot), + }); + } + + return items; +} + +export function createReviewItem( + bundlePath: number[], + planKind: DocumentMutationPlan["kind"], + targetKind: StructuralReviewItem["targetKind"], + input: { + changeKind: StructuralReviewItem["changeKind"]; + section: StructuralReviewItem["section"]; + groupId: string; + groupLabel: string; + label: string; + summary: string; + detail?: string; + preview?: string; + before?: string; + after?: string; + comparisonRows?: StructuralReviewComparisonRow[]; + stepIndex?: number; + }, +): StructuralReviewItem { + const stepIndex = input.stepIndex ?? null; + return { + id: createReviewItemId(planKind, bundlePath, stepIndex), + targetKind, + planKind, + changeKind: input.changeKind, + section: input.section, + groupId: input.groupId, + groupLabel: input.groupLabel, + label: input.label, + summary: input.summary, + detail: input.detail, + preview: input.preview, + before: input.before, + after: input.after, + comparisonRows: input.comparisonRows, + bundlePath, + stepIndex, + }; +} + +export function createReviewItemId( + planKind: DocumentMutationPlan["kind"], + bundlePath: number[], + stepIndex: number | null, +): string { + const pathPart = bundlePath.length > 0 ? bundlePath.join(".") : "root"; + const stepPart = stepIndex == null ? "plan" : `step-${stepIndex}`; + return `plan:${planKind}:${pathPart}:${stepPart}`; +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts new file mode 100644 index 0000000..e206fb7 --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts @@ -0,0 +1,368 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { buildStructuralReviewItems, buildStructuredPreviewTargets, buildStructuralPreviewArtifacts, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan, buildReviewItemsForPlan, serializeStructuredPreviewTargets, buildDatabaseReviewItems, createReviewItem, createReviewItemId } from "./reviewArtifactsPart1"; +import type { StructuralReviewItem, StructuralReviewComparisonRow, StructuralReviewBuildContext, DatabaseReviewSnapshot, StructuredPreviewDatabaseState, StructuredPreviewTargetState, VirtualReviewBlock } from "./reviewArtifactsPart1"; +import { registerInsertedReviewBlock, describeInsertedBlockAfter, getDatabaseReviewSnapshot, cloneDatabaseReviewSnapshot, createDefaultDatabaseReviewSnapshot, applyDatabaseStepToReviewSnapshot, summarizeColumns, summarizeViews, findDatabaseReviewRowIndex, findColumnIndex, resolveColumnLabel, resolveDatabaseColumnLabel, resolveDatabaseRowLabel, resolveDatabaseActiveViewSnapshot, resolveViewLabel, formatColumnSchema, formatViewState, formatDatabaseValueKeys, stringifyRecord, stringifyDatabaseValue } from "./reviewArtifactsPart3"; +import { buildColumnComparisonRows, buildColumnSchemaComparisonRows, buildViewComparisonRows, areColumnSchemasEqual, formatViewSort } from "./reviewArtifactsPart4"; + +export function selectPlanAtPath( + plan: DocumentMutationPlan, + bundlePath: number[], + stepIndex: number | null, +): DocumentMutationPlan | null { + if (bundlePath.length > 0) { + if (plan.kind !== "review_bundle") { + return null; + } + const [head, ...tail] = bundlePath; + const nestedPlan = plan.plans[head]; + if (!nestedPlan) { + return null; + } + return selectPlanAtPath(nestedPlan, tail, stepIndex); + } + + if (stepIndex == null) { + return plan; + } + + if (plan.kind === "database_edit") { + const step = plan.steps[stepIndex]; + return step ? { ...plan, steps: [step] } : null; + } + if (plan.kind === "flow_patch") { + const edit = plan.edits[stepIndex]; + return edit ? { ...plan, edits: [edit] } : null; + } + + return null; +} + +export function removePlanAtPath( + plan: DocumentMutationPlan, + bundlePath: number[], + stepIndex: number | null, +): DocumentMutationPlan | null { + if (bundlePath.length > 0) { + if (plan.kind !== "review_bundle") { + return null; + } + const [head, ...tail] = bundlePath; + const nestedPlan = plan.plans[head]; + if (!nestedPlan) { + return plan; + } + const nextNestedPlan = removePlanAtPath(nestedPlan, tail, stepIndex); + const nextPlans = plan.plans.flatMap((entry, index) => { + if (index !== head) { + return [entry]; + } + return nextNestedPlan ? [nextNestedPlan] : []; + }); + if (nextPlans.length === 0) { + return null; + } + if (nextPlans.length === 1) { + return nextPlans[0] ?? null; + } + return { ...plan, plans: nextPlans }; + } + + if (stepIndex == null) { + return null; + } + + if (plan.kind === "database_edit") { + const nextSteps = plan.steps.filter((_, index) => index !== stepIndex); + return nextSteps.length > 0 ? { ...plan, steps: nextSteps } : null; + } + if (plan.kind === "flow_patch") { + const nextEdits = plan.edits.filter((_, index) => index !== stepIndex); + return nextEdits.length > 0 ? { ...plan, edits: nextEdits } : null; + } + + return null; +} + +export function describeTextEditLabel( + operation: "replace" | "insert" | "append", +): string { + if (operation === "replace") { + return "Replace text"; + } + if (operation === "insert") { + return "Insert text"; + } + return "Append text"; +} + +export function describeTextEditChangeKind( + operation: "replace" | "insert" | "append", +): StructuralReviewItem["changeKind"] { + return operation === "replace" ? "updated" : "added"; +} + +export function describeDatabaseStepLabel(step: string): string { + switch (step) { + case "add_column": + return "Add column"; + case "update_column": + return "Update column"; + case "insert_row": + return "Insert row"; + case "update_cell": + return "Update cell"; + case "add_view": + return "Add view"; + case "set_active_view": + return "Set active view"; + default: + return "Database change"; + } +} + +export function describeDatabaseStepChangeKind( + step: string, +): StructuralReviewItem["changeKind"] { + switch (step) { + case "add_column": + case "insert_row": + case "add_view": + return "added"; + case "update_column": + case "update_cell": + case "set_active_view": + default: + return "updated"; + } +} + +export function describeDatabaseStepSection( + step: string, +): StructuralReviewItem["section"] { + switch (step) { + case "add_column": + case "update_column": + return "schema"; + case "insert_row": + return "row"; + case "update_cell": + return "cell"; + case "add_view": + case "set_active_view": + default: + return "view"; + } +} + +export function describeDatabaseStepSummary( + blockId: string, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string { + switch (step.op) { + case "add_column": + return `Adds a column to database "${blockId}".`; + case "update_column": + return `Updates a column in database "${blockId}".`; + case "insert_row": + return `Adds a row to database "${blockId}".`; + case "update_cell": + return `Updates a database cell in "${blockId}".`; + case "add_view": + return `Adds a view to database "${blockId}".`; + case "set_active_view": + return `Changes the active view for database "${blockId}".`; + } +} + +export function describeDatabaseStepDetail( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "add_column": + return resolveColumnLabel(step.column); + case "update_column": + return resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId); + case "insert_row": + return formatDatabaseValueKeys(snapshot?.columns ?? [], step.values); + case "update_cell": + return `${resolveDatabaseRowLabel(snapshot, step.rowId)} · ${resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId)}`; + case "add_view": + return resolveViewLabel(step.view); + case "set_active_view": + return ( + snapshot?.views.find((view) => view.id === step.viewId)?.title ?? + snapshot?.views.find((view) => view.id === step.viewId)?.id ?? + step.viewId + ); + } +} + +export function describeDatabaseStepPreview( + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "update_cell": + return stringifyReviewValue(step.value); + case "insert_row": + return stringifyReviewValue(step.values); + default: + return undefined; + } +} + +export function describeDatabaseStepBefore( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "add_column": + return summarizeColumns(snapshot?.columns ?? []); + case "update_column": + return formatColumnSchema( + snapshot?.columns.find((column) => column.id === step.columnId), + ); + case "insert_row": + return snapshot ? `${snapshot.rows.length} rows` : undefined; + case "update_cell": { + if (!snapshot) { + return undefined; + } + const rowIndex = findDatabaseReviewRowIndex(snapshot, step.rowId); + const colIndex = findColumnIndex(snapshot.columns, step.columnId); + if (rowIndex === -1 || colIndex === -1) { + return undefined; + } + const columnId = snapshot.columns[colIndex]?.id; + return columnId ? snapshot.rows[rowIndex]?.values[columnId] ?? "" : undefined; + } + case "add_view": + return summarizeViews(snapshot?.views ?? []); + case "set_active_view": + return snapshot ? resolveViewLabel(resolveDatabaseActiveViewSnapshot(snapshot)) : undefined; + } +} + +export function describeDatabaseStepAfter( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "add_column": + return formatColumnSchema(step.column); + case "update_column": { + const column = snapshot?.columns.find((entry) => entry.id === step.columnId); + return formatColumnSchema(column ? { ...column, ...step.patch } : undefined); + } + case "insert_row": + return snapshot ? `${snapshot.rows.length + 1} rows` : undefined; + case "update_cell": + return stringifyReviewValue(step.value); + case "add_view": + return formatViewState(step.view, snapshot?.columns ?? []); + case "set_active_view": { + const nextView = snapshot?.views.find((view) => view.id === step.viewId); + return resolveViewLabel(nextView) ?? step.viewId; + } + } +} + +export function describeDatabaseStepComparisonRows( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): StructuralReviewComparisonRow[] | undefined { + switch (step.op) { + case "add_column": + return [ + { + label: "Column", + before: undefined, + after: formatColumnSchema(step.column), + changeKind: "added", + section: "schema", + }, + ]; + case "update_column": { + const column = snapshot?.columns.find((entry) => entry.id === step.columnId); + const nextColumn = column ? { ...column, ...step.patch } : undefined; + if (!column && !nextColumn) { + return undefined; + } + return buildColumnSchemaComparisonRows(column, nextColumn); + } + case "add_view": + return buildViewComparisonRows(undefined, step.view, snapshot?.columns ?? []); + case "set_active_view": + return buildViewComparisonRows( + resolveDatabaseActiveViewSnapshot(snapshot) ?? undefined, + snapshot?.views.find((view) => view.id === step.viewId), + snapshot?.columns ?? [], + ); + default: + return undefined; + } +} + +export function stringifyReviewValue(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function readTextEditBefore( + editor: Editor, + plan: Extract, +): string | undefined { + const block = editor.getBlock(plan.target.blockId); + if (!block) { + return undefined; + } + const text = block.textContent(); + if (plan.target.range) { + return text.slice( + plan.target.range.startOffset, + plan.target.range.endOffset, + ); + } + return text; +} + +export function readBlockPropsPreview(editor: Editor, blockId: string): string | undefined { + const block = editor.getBlock(blockId); + return block ? stringifyReviewValue(block.props) : undefined; +} + +export function readBlockTypePreview(editor: Editor, blockId: string): string | undefined { + const block = editor.getBlock(blockId); + return block?.type; +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts new file mode 100644 index 0000000..7f5d0e5 --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts @@ -0,0 +1,349 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { buildStructuralReviewItems, buildStructuredPreviewTargets, buildStructuralPreviewArtifacts, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan, buildReviewItemsForPlan, serializeStructuredPreviewTargets, buildDatabaseReviewItems, createReviewItem, createReviewItemId } from "./reviewArtifactsPart1"; +import type { StructuralReviewItem, StructuralReviewComparisonRow, StructuralReviewBuildContext, DatabaseReviewSnapshot, StructuredPreviewDatabaseState, StructuredPreviewTargetState, VirtualReviewBlock } from "./reviewArtifactsPart1"; +import { selectPlanAtPath, removePlanAtPath, describeTextEditLabel, describeTextEditChangeKind, describeDatabaseStepLabel, describeDatabaseStepChangeKind, describeDatabaseStepSection, describeDatabaseStepSummary, describeDatabaseStepDetail, describeDatabaseStepPreview, describeDatabaseStepBefore, describeDatabaseStepAfter, describeDatabaseStepComparisonRows, stringifyReviewValue, readTextEditBefore, readBlockPropsPreview, readBlockTypePreview } from "./reviewArtifactsPart2"; +import { buildColumnComparisonRows, buildColumnSchemaComparisonRows, buildViewComparisonRows, areColumnSchemasEqual, formatViewSort } from "./reviewArtifactsPart4"; + +export function registerInsertedReviewBlock( + context: StructuralReviewBuildContext, + plan: Extract, +): void { + if (!plan.blockId) { + return; + } + if (plan.blockType === "database") { + context.virtualBlocks.set(plan.blockId, { + type: "database", + database: createDefaultDatabaseReviewSnapshot(), + }); + } +} + +export function describeInsertedBlockAfter( + plan: Extract, +): string | undefined { + if (plan.initialText) { + return plan.initialText; + } + if (plan.blockType === "database") { + return "3 columns, 0 rows, 1 view"; + } + return plan.blockType; +} + +export function getDatabaseReviewSnapshot( + editor: Editor, + blockId: string, + context: StructuralReviewBuildContext, +): DatabaseReviewSnapshot | null { + const virtualBlock = context.virtualBlocks.get(blockId); + if (virtualBlock?.type === "database") { + return cloneDatabaseReviewSnapshot(virtualBlock.database); + } + const block = editor.getBlock(blockId); + if (!block || block.type !== "database") { + return null; + } + const columns = [...block.tableColumns()]; + const rows = Array.from({ length: block.tableRowCount() }, (_, rowIndex) => { + const rowId = block.tableRow(rowIndex)?.id ?? `row-${rowIndex + 1}`; + return { + id: rowId, + values: Object.fromEntries( + columns.map((column, colIndex) => [ + column.id, + block.tableCell(rowIndex, colIndex)?.textContent() ?? "", + ]), + ), + }; + }); + return { + columns, + rows, + views: [...block.databaseViews()], + primaryViewId: block.databasePrimaryViewId(), + }; +} + +export function cloneDatabaseReviewSnapshot( + snapshot: DatabaseReviewSnapshot, +): DatabaseReviewSnapshot { + return { + columns: snapshot.columns.map((column) => ({ ...column })), + rows: snapshot.rows.map((row) => ({ + id: row.id, + values: { ...row.values }, + })), + views: snapshot.views.map((view) => ({ + ...view, + visibleColumnIds: view.visibleColumnIds ? [...view.visibleColumnIds] : undefined, + columnOrder: view.columnOrder ? [...view.columnOrder] : undefined, + sort: view.sort ? [...view.sort] : undefined, + rowPinning: view.rowPinning ? { ...view.rowPinning } : undefined, + })), + primaryViewId: snapshot.primaryViewId, + }; +} + +export function createDefaultDatabaseReviewSnapshot(): DatabaseReviewSnapshot { + const columns: TableColumnSchema[] = [ + { id: "name", title: "Name", type: "text" }, + { id: "tags", title: "Tags", type: "select" }, + { id: "done", title: "Done", type: "checkbox" }, + ]; + const primaryViewId = "view-table"; + return { + columns, + rows: [], + views: [ + { + id: primaryViewId, + title: "Table", + type: "table", + visibleColumnIds: columns.map((column) => column.id), + columnOrder: columns.map((column) => column.id), + }, + ], + primaryViewId, + }; +} + +export function applyDatabaseStepToReviewSnapshot( + snapshot: DatabaseReviewSnapshot, + step: Extract["steps"][number], +): void { + switch (step.op) { + case "add_column": + snapshot.columns.push({ ...step.column }); + for (const row of snapshot.rows) { + row.values[step.column.id] = ""; + } + return; + case "update_column": { + const columnIndex = snapshot.columns.findIndex( + (column) => column.id === step.columnId, + ); + if (columnIndex !== -1) { + snapshot.columns[columnIndex] = { + ...snapshot.columns[columnIndex]!, + ...step.patch, + }; + } + return; + } + case "insert_row": + snapshot.rows.push({ + id: step.rowId ?? `row-${snapshot.rows.length + 1}`, + values: stringifyRecord(step.values), + }); + return; + case "update_cell": { + const row = snapshot.rows.find((entry) => entry.id === step.rowId); + if (row) { + row.values[step.columnId] = stringifyDatabaseValue(step.value); + } + return; + } + case "add_view": + snapshot.views.push({ + ...step.view, + visibleColumnIds: step.view.visibleColumnIds + ? [...step.view.visibleColumnIds] + : undefined, + columnOrder: step.view.columnOrder ? [...step.view.columnOrder] : undefined, + sort: step.view.sort ? [...step.view.sort] : undefined, + rowPinning: step.view.rowPinning ? { ...step.view.rowPinning } : undefined, + }); + return; + case "set_active_view": + snapshot.primaryViewId = step.viewId; + return; + } +} + +export function summarizeColumns(columns: readonly TableColumnSchema[]): string | undefined { + if (columns.length === 0) { + return undefined; + } + return columns.map(formatColumnSchema).filter(Boolean).join(", "); +} + +export function summarizeViews(views: readonly DatabaseViewState[]): string | undefined { + if (views.length === 0) { + return undefined; + } + return views.map((view) => resolveViewLabel(view)).filter(Boolean).join(", "); +} + +export function findDatabaseReviewRowIndex( + snapshot: DatabaseReviewSnapshot, + rowId: string, +): number { + for (let index = 0; index < snapshot.rows.length; index += 1) { + if (snapshot.rows[index]?.id === rowId) { + return index; + } + } + return -1; +} + +export function findColumnIndex( + columns: readonly TableColumnSchema[], + columnId: string, +): number { + return columns.findIndex((column) => column.id === columnId); +} + +export function resolveColumnLabel(column: TableColumnSchema | undefined): string { + return column?.title || column?.id || "Column"; +} + +export function resolveDatabaseColumnLabel( + columns: readonly TableColumnSchema[], + columnId: string, +): string { + const column = columns.find((entry) => entry.id === columnId); + return column ? resolveColumnLabel(column) : columnId; +} + +export function resolveDatabaseRowLabel( + snapshot: DatabaseReviewSnapshot | null, + rowId: string, +): string { + if (!snapshot) { + return rowId; + } + const rowIndex = findDatabaseReviewRowIndex(snapshot, rowId); + if (rowIndex === -1) { + return rowId; + } + + const columns = snapshot.columns; + const preferredColumnIds = [ + columns.find((column) => column.title.toLowerCase() === "name")?.id, + columns[0]?.id, + ].filter(Boolean) as string[]; + + for (const columnId of preferredColumnIds) { + const value = snapshot.rows[rowIndex]?.values[columnId]?.trim(); + if (value) { + return value; + } + } + + return `Row ${rowIndex + 1}`; +} + +export function resolveDatabaseActiveViewSnapshot( + snapshot: DatabaseReviewSnapshot | null, +): DatabaseViewState | null { + if (!snapshot) { + return null; + } + if (!snapshot.primaryViewId) { + return snapshot.views[0] ?? null; + } + return ( + snapshot.views.find((view) => view.id === snapshot.primaryViewId) ?? + snapshot.views[0] ?? + null + ); +} + +export function resolveViewLabel(view: DatabaseViewState | null | undefined): string | undefined { + if (!view) { + return undefined; + } + return view.title ?? view.id; +} + +export function formatColumnSchema( + column: TableColumnSchema | undefined, +): string | undefined { + if (!column) { + return undefined; + } + + const parts = [`${resolveColumnLabel(column)} [${column.type}]`]; + if (column.width != null) { + parts.push(`w:${column.width}`); + } + if (column.hidden) { + parts.push("hidden"); + } + if (column.pinned) { + parts.push(`pinned:${column.pinned}`); + } + return parts.join(" "); +} + +export function formatViewState( + view: DatabaseViewState | undefined, + columns: readonly TableColumnSchema[], +): string | undefined { + if (!view) { + return undefined; + } + + const parts = [`${resolveViewLabel(view)} [${view.type}]`]; + if (view.groupBy) { + parts.push(`group:${resolveDatabaseColumnLabel(columns, view.groupBy)}`); + } + if (view.visibleColumnIds && view.visibleColumnIds.length > 0) { + parts.push( + `visible:${view.visibleColumnIds + .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) + .join(", ")}`, + ); + } + return parts.join(" "); +} + +export function formatDatabaseValueKeys( + columns: readonly TableColumnSchema[], + values: Record, +): string | undefined { + const keys = Object.keys(values); + if (keys.length === 0) { + return undefined; + } + return keys + .map((key) => resolveDatabaseColumnLabel(columns, key)) + .join(", "); +} + +export function stringifyRecord( + value: Record, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + stringifyDatabaseValue(entryValue), + ]), + ); +} + +export function stringifyDatabaseValue(value: unknown): string { + if (value == null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts new file mode 100644 index 0000000..1076d73 --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts @@ -0,0 +1,176 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { buildStructuralReviewItems, buildStructuredPreviewTargets, buildStructuralPreviewArtifacts, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan, buildReviewItemsForPlan, serializeStructuredPreviewTargets, buildDatabaseReviewItems, createReviewItem, createReviewItemId } from "./reviewArtifactsPart1"; +import type { StructuralReviewItem, StructuralReviewComparisonRow, StructuralReviewBuildContext, DatabaseReviewSnapshot, StructuredPreviewDatabaseState, StructuredPreviewTargetState, VirtualReviewBlock } from "./reviewArtifactsPart1"; +import { selectPlanAtPath, removePlanAtPath, describeTextEditLabel, describeTextEditChangeKind, describeDatabaseStepLabel, describeDatabaseStepChangeKind, describeDatabaseStepSection, describeDatabaseStepSummary, describeDatabaseStepDetail, describeDatabaseStepPreview, describeDatabaseStepBefore, describeDatabaseStepAfter, describeDatabaseStepComparisonRows, stringifyReviewValue, readTextEditBefore, readBlockPropsPreview, readBlockTypePreview } from "./reviewArtifactsPart2"; +import { registerInsertedReviewBlock, describeInsertedBlockAfter, getDatabaseReviewSnapshot, cloneDatabaseReviewSnapshot, createDefaultDatabaseReviewSnapshot, applyDatabaseStepToReviewSnapshot, summarizeColumns, summarizeViews, findDatabaseReviewRowIndex, findColumnIndex, resolveColumnLabel, resolveDatabaseColumnLabel, resolveDatabaseRowLabel, resolveDatabaseActiveViewSnapshot, resolveViewLabel, formatColumnSchema, formatViewState, formatDatabaseValueKeys, stringifyRecord, stringifyDatabaseValue } from "./reviewArtifactsPart3"; + +export function buildColumnComparisonRows( + beforeColumns: readonly TableColumnSchema[], + afterColumns: readonly TableColumnSchema[], +): StructuralReviewComparisonRow[] | undefined { + const rows: StructuralReviewComparisonRow[] = []; + const beforeOrder = beforeColumns.map((column) => resolveColumnLabel(column)).join(", "); + const afterOrder = afterColumns.map((column) => resolveColumnLabel(column)).join(", "); + if (beforeOrder !== afterOrder) { + rows.push({ + label: "Order", + before: beforeOrder || undefined, + after: afterOrder || undefined, + changeKind: "updated", + section: "schema", + }); + } + + const beforeById = new Map(beforeColumns.map((column) => [column.id, column])); + const afterById = new Map(afterColumns.map((column) => [column.id, column])); + const allIds = [...new Set([...beforeById.keys(), ...afterById.keys()])]; + + for (const id of allIds) { + const beforeColumn = beforeById.get(id); + const afterColumn = afterById.get(id); + if (!beforeColumn && afterColumn) { + rows.push({ + label: `Added ${resolveColumnLabel(afterColumn)}`, + after: formatColumnSchema(afterColumn), + changeKind: "added", + section: "schema", + }); + continue; + } + if (beforeColumn && !afterColumn) { + rows.push({ + label: `Removed ${resolveColumnLabel(beforeColumn)}`, + before: formatColumnSchema(beforeColumn), + changeKind: "removed", + section: "schema", + }); + continue; + } + if (!beforeColumn || !afterColumn) { + continue; + } + if (!areColumnSchemasEqual(beforeColumn, afterColumn)) { + rows.push({ + label: resolveColumnLabel(afterColumn), + before: formatColumnSchema(beforeColumn), + after: formatColumnSchema(afterColumn), + changeKind: "updated", + section: "schema", + }); + } + } + + return rows.length > 0 ? rows : undefined; +} + +export function buildColumnSchemaComparisonRows( + beforeColumn: TableColumnSchema | undefined, + afterColumn: TableColumnSchema | undefined, +): StructuralReviewComparisonRow[] | undefined { + if (!beforeColumn && !afterColumn) { + return undefined; + } + + const rows: StructuralReviewComparisonRow[] = []; + const label = resolveColumnLabel(afterColumn ?? beforeColumn); + rows.push({ + label, + before: formatColumnSchema(beforeColumn), + after: formatColumnSchema(afterColumn), + changeKind: + beforeColumn == null ? "added" : afterColumn == null ? "removed" : "updated", + section: "schema", + }); + + return rows; +} + +export function buildViewComparisonRows( + beforeView: DatabaseViewState | undefined, + afterView: DatabaseViewState | undefined, + columns: readonly TableColumnSchema[], +): StructuralReviewComparisonRow[] | undefined { + if (!beforeView && !afterView) { + return undefined; + } + + const rows: StructuralReviewComparisonRow[] = [ + { + label: "View", + before: resolveViewLabel(beforeView), + after: resolveViewLabel(afterView), + changeKind: + beforeView == null ? "added" : afterView == null ? "removed" : "updated", + section: "view", + }, + { + label: "Type", + before: beforeView?.type, + after: afterView?.type, + changeKind: "updated", + section: "view", + }, + { + label: "Group by", + before: beforeView?.groupBy + ? resolveDatabaseColumnLabel(columns, beforeView.groupBy) + : undefined, + after: afterView?.groupBy + ? resolveDatabaseColumnLabel(columns, afterView.groupBy) + : undefined, + changeKind: "updated", + section: "view", + }, + { + label: "Visible columns", + before: beforeView?.visibleColumnIds?.length + ? beforeView.visibleColumnIds + .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) + .join(", ") + : undefined, + after: afterView?.visibleColumnIds?.length + ? afterView.visibleColumnIds + .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) + .join(", ") + : undefined, + changeKind: "updated", + section: "view", + }, + { + label: "Sort", + before: formatViewSort(beforeView, columns), + after: formatViewSort(afterView, columns), + changeKind: "updated", + section: "view", + }, + ]; + + const meaningfulRows = rows.filter((row) => row.before !== row.after); + return meaningfulRows.length > 0 ? meaningfulRows : undefined; +} + +export function areColumnSchemasEqual( + left: TableColumnSchema, + right: TableColumnSchema, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function formatViewSort( + view: DatabaseViewState | undefined, + columns: readonly TableColumnSchema[], +): string | undefined { + if (!view?.sort || view.sort.length === 0) { + return undefined; + } + return view.sort + .map( + (sortEntry) => + `${resolveDatabaseColumnLabel(columns, sortEntry.columnId)} ${sortEntry.direction}`, + ) + .join(", "); +} diff --git a/packages/extensions/ai/src/runtime/structuredIntent.ts b/packages/extensions/ai/src/runtime/structuredIntent.ts index 6aff64c..9b3d27b 100644 --- a/packages/extensions/ai/src/runtime/structuredIntent.ts +++ b/packages/extensions/ai/src/runtime/structuredIntent.ts @@ -1,897 +1,3 @@ -import type { AIWorkingSetEnvelope } from "../types"; -import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; -import type { AITargetKind } from "./contracts"; -import type { PlanConfidence } from "./planTypes"; - -export const STRUCTURED_INTENT_REQUEST_PREFIX = - "pen:structured-intent-request/v1"; - -export type StructuredIntentKind = - | "insert_block" - | "update_block" - | "move_block" - | "convert_block" - | "text_edit" - | "database" - | "review_bundle"; - -export type StructuredInsertPosition = - | "before_active" - | "after_active" - | "start" - | "end" - | { beforeBlockId: string } - | { afterBlockId: string } - | { parentId: string; index: number }; - -export interface StructuredTableColumn { - id?: string; - title: string; - type?: TableColumnSchema["type"]; -} - -export interface StructuredDatabaseRow { - rowId?: string; - values: Record; -} - -export interface StructuredDatabaseSeed { - columns?: StructuredTableColumn[]; - rows?: StructuredDatabaseRow[]; - views?: DatabaseViewState[]; - activeViewId?: string; -} - -export interface InsertBlockIntent { - kind: "insert_block"; - blockId?: string; - blockType: string; - position: StructuredInsertPosition; - props?: Record; - initialText?: string; - database?: StructuredDatabaseSeed; - confidence?: PlanConfidence; -} - -export interface UpdateBlockIntent { - kind: "update_block"; - blockId: string; - props: Record; - confidence?: PlanConfidence; -} - -export interface MoveBlockIntent { - kind: "move_block"; - blockId: string; - position: StructuredInsertPosition; - confidence?: PlanConfidence; -} - -export interface ConvertBlockIntent { - kind: "convert_block"; - blockId: string; - newType: string; - props?: Record; - confidence?: PlanConfidence; -} - -export interface TextEditIntent { - kind: "text_edit"; - target: { - blockId: string; - range?: { - startOffset: number; - endOffset: number; - }; - }; - operation: "replace" | "insert" | "append"; - text: string; - confidence?: PlanConfidence; -} - -export interface DatabaseIntent { - kind: "database"; - blockId: string; - columns?: StructuredTableColumn[]; - rows?: StructuredDatabaseRow[]; - views?: DatabaseViewState[]; - activeViewId?: string; - confidence?: PlanConfidence; -} - -export interface ReviewBundleIntent { - kind: "review_bundle"; - label: string; - reason: string; - changes: StructuredIntent[]; - confidence?: PlanConfidence; -} - -export type StructuredIntent = - | InsertBlockIntent - | UpdateBlockIntent - | MoveBlockIntent - | ConvertBlockIntent - | TextEditIntent - | DatabaseIntent - | ReviewBundleIntent; - -export interface StructuredIntentParseIssue { - path: string; - code: "missing-field" | "invalid-shape" | "invalid-kind"; - message: string; -} - -export interface StructuredIntentParseResult { - intent: StructuredIntent | null; - intentState: "drafted" | "validated" | "rejected"; - issues: StructuredIntentParseIssue[]; -} - -export interface StructuredIntentRequestEnvelope { - version: 1; - contract: "structured-intent"; - targetKind: AITargetKind; - prompt: string; - activeBlockId: string | null; - contextSummary: unknown; -} - -export interface StructuredIntentPromptConfig { - prompt: string; - targetKind: AITargetKind; - activeBlockId: string | null; - workingSet: AIWorkingSetEnvelope | null; -} - -export function getStructuredIntentOutputSchema( - targetKind: AITargetKind, -): Record { - const structuredColumnSchema = { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string" }, - title: { type: "string" }, - type: { type: "string" }, - }, - required: ["title"], - }, - }; - const databaseSeedSchema = { - type: "object", - properties: { - columns: structuredColumnSchema, - rows: { - type: "array", - items: { - type: "object", - properties: { - rowId: { type: "string" }, - values: { - type: "object", - additionalProperties: true, - }, - }, - required: ["values"], - }, - }, - views: { - type: "array", - items: { - type: "object", - additionalProperties: true, - }, - }, - activeViewId: { type: "string" }, - }, - }; - const positionSchema = { - anyOf: [ - { type: "string", enum: ["before_active", "after_active", "start", "end"] }, - { - type: "object", - properties: { - beforeBlockId: { type: "string" }, - }, - required: ["beforeBlockId"], - }, - { - type: "object", - properties: { - afterBlockId: { type: "string" }, - }, - required: ["afterBlockId"], - }, - { - type: "object", - properties: { - parentId: { type: "string" }, - index: { type: "number" }, - }, - required: ["parentId", "index"], - }, - ], - }; - const insertBlockSchema = { - type: "object", - properties: { - kind: { const: "insert_block" }, - blockId: { type: "string" }, - blockType: { - type: "string", - enum: - targetKind === "database" - ? ["database"] - : ["paragraph", "heading", "database"], - }, - position: positionSchema, - props: { - type: "object", - additionalProperties: true, - }, - initialText: { type: "string" }, - database: databaseSeedSchema, - confidence: { - anyOf: [ - { type: "number" }, - { - type: "object", - properties: { - score: { type: "number" }, - reason: { type: "string" }, - }, - }, - ], - }, - }, - required: ["kind", "blockType", "position"], - }; - const databaseSchema = { - type: "object", - properties: { - kind: { const: "database" }, - blockId: { type: "string" }, - columns: structuredColumnSchema, - rows: databaseSeedSchema.properties.rows, - views: databaseSeedSchema.properties.views, - activeViewId: { type: "string" }, - }, - required: ["kind", "blockId"], - }; - return { - type: "object", - anyOf: [ - insertBlockSchema, - databaseSchema, - { - type: "object", - properties: { - kind: { const: "review_bundle" }, - label: { type: "string" }, - reason: { type: "string" }, - changes: { - type: "array", - items: { - anyOf: [insertBlockSchema, databaseSchema], - }, - }, - }, - required: ["kind", "label", "reason", "changes"], - }, - ], - }; -} - -export function buildStructuredIntentRequestPrompt( - config: StructuredIntentPromptConfig, -): string { - const envelope: StructuredIntentRequestEnvelope = { - version: 1, - contract: "structured-intent", - targetKind: config.targetKind, - prompt: config.prompt, - activeBlockId: config.activeBlockId, - contextSummary: config.workingSet?.context ?? null, - }; - return [ - STRUCTURED_INTENT_REQUEST_PREFIX, - JSON.stringify(envelope), - ].join("\n"); -} - -export function parseStructuredIntentRequestPrompt( - value: string, -): StructuredIntentRequestEnvelope | null { - if (!value.startsWith(`${STRUCTURED_INTENT_REQUEST_PREFIX}\n`)) { - return null; - } - const jsonPayload = value - .slice(STRUCTURED_INTENT_REQUEST_PREFIX.length) - .trimStart(); - try { - const parsed = JSON.parse(jsonPayload) as StructuredIntentRequestEnvelope; - if ( - parsed?.version === 1 && - parsed.contract === "structured-intent" && - typeof parsed.prompt === "string" && - typeof parsed.targetKind === "string" - ) { - return parsed; - } - return null; - } catch { - return null; - } -} - -export function buildStructuredIntentModelPrompt( - request: StructuredIntentRequestEnvelope, -): string { - const allowedKinds = resolveAllowedStructuredIntentKinds(request.targetKind); - return [ - "Produce one structured Pen intent object.", - "Return valid JSON only and no markdown fences or prose.", - `Target kind: ${request.targetKind}`, - `Allowed top-level intent kinds: ${allowedKinds.join(", ")}`, - "", - "Use these intent rules:", - '- always include a top-level "kind" field', - '- use "review_bundle" with a "changes" array for mixed edits', - '- use "insert_block" for new blocks with position "after_active", "before_active", "start", or "end"', - '- when creating a new database, prefer one "insert_block" with embedded "database" seed data', - '- for database rows, use "rows" with "values" keyed by column id', - '- do not emit executor-level row/col operations', - "", - "Context summary:", - stringifyContextSummary(request.contextSummary), - "", - "User request:", - request.prompt, - ].join("\n"); -} - -export function parseStructuredIntentResult( - value: unknown, - targetKind: AITargetKind, -): StructuredIntentParseResult { - const issues: StructuredIntentParseIssue[] = []; - const intent = readStructuredIntent(value, "intent", issues, { - allowPartial: false, - targetKind, - }); - return { - intent, - intentState: intent ? "validated" : "rejected", - issues, - }; -} - -export function parseStructuredIntentPreview( - value: unknown, - targetKind: AITargetKind, -): StructuredIntentParseResult | null { - const issues: StructuredIntentParseIssue[] = []; - const intent = readStructuredIntent(value, "intent", issues, { - allowPartial: true, - targetKind, - }); - if (!intent) { - return null; - } - return { - intent, - intentState: issues.length === 0 ? "validated" : "drafted", - issues, - }; -} - -function resolveAllowedStructuredIntentKinds( - targetKind: AITargetKind, -): StructuredIntentKind[] { - if (targetKind === "database") { - return ["insert_block", "database", "review_bundle"]; - } - if (targetKind === "text") { - return ["text_edit"]; - } - return [ - "insert_block", - "update_block", - "move_block", - "convert_block", - "database", - "review_bundle", - ]; -} - -function stringifyContextSummary(value: unknown): string { - try { - return JSON.stringify(value ?? null); - } catch { - return "null"; - } -} - -function readStructuredIntent( - value: unknown, - path: string, - issues: StructuredIntentParseIssue[], - options: { - allowPartial: boolean; - targetKind: AITargetKind; - }, -): StructuredIntent | null { - if (options.targetKind === "table") { - issues.push({ - path, - code: "invalid-kind", - message: - "Structured table intents are not supported. Use the markdown authoring lane for tables.", - }); - return null; - } - const record = asRecord(value); - if (!record) { - issues.push({ - path, - code: "invalid-shape", - message: "Structured intent must be an object.", - }); - return null; - } - const kind = readNonEmptyString(record.kind); - if (!kind) { - issues.push({ - path: `${path}.kind`, - code: "missing-field", - message: "Structured intent kind is required.", - }); - return null; - } - switch (kind) { - case "insert_block": - return readInsertBlockIntent(record, path, issues, options.allowPartial); - case "update_block": - return readUpdateBlockIntent(record, path, issues, options.allowPartial); - case "move_block": - return readMoveBlockIntent(record, path, issues, options.allowPartial); - case "convert_block": - return readConvertBlockIntent(record, path, issues, options.allowPartial); - case "text_edit": - return readTextEditIntent(record, path, issues, options.allowPartial); - case "database": - return readDatabaseIntent(record, path, issues, options.allowPartial); - case "review_bundle": - return readReviewBundleIntent(record, path, issues, options); - default: - issues.push({ - path: `${path}.kind`, - code: "invalid-kind", - message: `Unsupported structured intent kind "${kind}".`, - }); - return null; - } -} - -function readInsertBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): InsertBlockIntent | null { - const blockType = readRequiredString( - record.blockType, - `${path}.blockType`, - issues, - allowPartial, - ); - const position = readStructuredPosition( - record.position, - `${path}.position`, - issues, - allowPartial, - ); - if (!blockType || !position) { - return null; - } - if (blockType === "table") { - if (!allowPartial) { - issues.push({ - path: `${path}.blockType`, - code: "invalid-kind", - message: - "Structured table intents are not supported. Use the markdown authoring lane for tables.", - }); - } - return null; - } - return { - kind: "insert_block", - blockId: readNonEmptyString(record.blockId) ?? undefined, - blockType, - position, - props: asRecord(record.props) ?? undefined, - initialText: readNonEmptyString(record.initialText) ?? undefined, - database: readStructuredDatabaseSeed(record.database), - confidence: readConfidence(record.confidence), - }; -} - -function readUpdateBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): UpdateBlockIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - const props = asRecord(record.props); - if (!blockId || !props) { - if (!props && !allowPartial) { - issues.push({ - path: `${path}.props`, - code: "invalid-shape", - message: "Block update props must be an object.", - }); - } - return null; - } - return { - kind: "update_block", - blockId, - props, - confidence: readConfidence(record.confidence), - }; -} - -function readMoveBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): MoveBlockIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - const position = readStructuredPosition( - record.position, - `${path}.position`, - issues, - allowPartial, - ); - if (!blockId || !position) { - return null; - } - return { - kind: "move_block", - blockId, - position, - confidence: readConfidence(record.confidence), - }; -} - -function readConvertBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): ConvertBlockIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - const newType = readRequiredString( - record.newType, - `${path}.newType`, - issues, - allowPartial, - ); - if (!blockId || !newType) { - return null; - } - return { - kind: "convert_block", - blockId, - newType, - props: asRecord(record.props) ?? undefined, - confidence: readConfidence(record.confidence), - }; -} - -function readTextEditIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): TextEditIntent | null { - const target = asRecord(record.target); - const blockId = readRequiredString( - target?.blockId, - `${path}.target.blockId`, - issues, - allowPartial, - ); - const operation = readRequiredString( - record.operation, - `${path}.operation`, - issues, - allowPartial, - ) as TextEditIntent["operation"] | null; - const text = readRequiredString( - record.text, - `${path}.text`, - issues, - allowPartial, - ); - if (!blockId || !operation || !text) { - return null; - } - const rangeRecord = asRecord(target?.range); - return { - kind: "text_edit", - target: { - blockId, - range: - rangeRecord && - isFiniteNumber(rangeRecord.startOffset) && - isFiniteNumber(rangeRecord.endOffset) - ? { - startOffset: rangeRecord.startOffset, - endOffset: rangeRecord.endOffset, - } - : undefined, - }, - operation, - text, - confidence: readConfidence(record.confidence), - }; -} - -function readDatabaseIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): DatabaseIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - if (!blockId) { - return null; - } - return { - kind: "database", - blockId, - columns: readStructuredColumns(record.columns), - rows: readStructuredDatabaseRows(record.rows), - views: Array.isArray(record.views) - ? (record.views.filter((view): view is DatabaseViewState => { - return !!view && typeof view === "object"; - }) as DatabaseViewState[]) - : undefined, - activeViewId: readNonEmptyString(record.activeViewId) ?? undefined, - confidence: readConfidence(record.confidence), - }; -} - -function readReviewBundleIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - options: { allowPartial: boolean; targetKind: AITargetKind }, -): ReviewBundleIntent | null { - const changes = Array.isArray(record.changes) - ? record.changes - .map((entry, index) => - readStructuredIntent(entry, `${path}.changes[${index}]`, issues, options), - ) - .filter((entry): entry is StructuredIntent => entry !== null) - : []; - if (changes.length === 0 && !options.allowPartial) { - issues.push({ - path: `${path}.changes`, - code: "missing-field", - message: "Review bundle changes are required.", - }); - return null; - } - return { - kind: "review_bundle", - label: - readNonEmptyString(record.label) ?? - (options.allowPartial ? "Streaming structured changes" : ""), - reason: - readNonEmptyString(record.reason) ?? - (options.allowPartial ? "Streaming structured preview." : ""), - changes, - confidence: readConfidence(record.confidence), - }; -} - -function readStructuredPosition( - value: unknown, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): StructuredInsertPosition | null { - if ( - value === "before_active" || - value === "after_active" || - value === "start" || - value === "end" - ) { - return value; - } - const record = asRecord(value); - if (!record) { - if (!allowPartial) { - issues.push({ - path, - code: "invalid-shape", - message: "Structured position is required.", - }); - } - return null; - } - const beforeBlockId = readNonEmptyString(record.beforeBlockId); - if (beforeBlockId) { - return { beforeBlockId }; - } - const afterBlockId = readNonEmptyString(record.afterBlockId); - if (afterBlockId) { - return { afterBlockId }; - } - const parentId = readNonEmptyString(record.parentId); - if (parentId && isFiniteNumber(record.index)) { - return { parentId, index: record.index }; - } - if (!allowPartial) { - issues.push({ - path, - code: "invalid-shape", - message: "Structured position is invalid.", - }); - } - return null; -} - -function readStructuredColumns( - value: unknown, -): StructuredTableColumn[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const columns = value.flatMap((column) => { - const record = asRecord(column); - const title = - readNonEmptyString(record?.title) ?? readNonEmptyString(record?.header); - if (!title) { - return []; - } - const normalizedColumn: StructuredTableColumn = { - id: readNonEmptyString(record?.id) ?? undefined, - title, - type: - (readNonEmptyString(record?.type) as TableColumnSchema["type"] | null) ?? - "text", - }; - return [normalizedColumn]; - }); - return columns.length > 0 ? columns : undefined; -} - -function readStructuredDatabaseRows( - value: unknown, -): StructuredDatabaseRow[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const rows = value.flatMap((row) => { - const record = asRecord(row); - const values = asRecord(record?.values); - if (!values) { - return []; - } - const normalizedRow: StructuredDatabaseRow = { - rowId: readNonEmptyString(record?.rowId) ?? undefined, - values, - }; - return [normalizedRow]; - }); - return rows.length > 0 ? rows : undefined; -} - -function readStructuredDatabaseSeed( - value: unknown, -): StructuredDatabaseSeed | undefined { - const record = asRecord(value); - if (!record) { - return undefined; - } - const columns = readStructuredColumns(record.columns); - const rows = readStructuredDatabaseRows(record.rows); - const views = Array.isArray(record.views) - ? (record.views.filter((view): view is DatabaseViewState => { - return !!view && typeof view === "object"; - }) as DatabaseViewState[]) - : undefined; - const activeViewId = readNonEmptyString(record.activeViewId) ?? undefined; - if (!columns && !rows && !views && !activeViewId) { - return undefined; - } - return { - columns, - rows, - views, - activeViewId, - }; -} - -function readConfidence(value: unknown): PlanConfidence | undefined { - if (value == null) { - return undefined; - } - if (isFiniteNumber(value)) { - return { score: value }; - } - const record = asRecord(value); - if (!record) { - return undefined; - } - const confidence: PlanConfidence = {}; - if (isFiniteNumber(record.score)) { - confidence.score = record.score; - } - if (readNonEmptyString(record.reason)) { - confidence.reason = record.reason as string; - } - return Object.keys(confidence).length > 0 ? confidence : undefined; -} - -function readRequiredString( - value: unknown, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): string | null { - const stringValue = readNonEmptyString(value); - if (stringValue) { - return stringValue; - } - if (!allowPartial) { - issues.push({ - path, - code: "missing-field", - message: "Field is required.", - }); - } - return null; -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} +export { STRUCTURED_INTENT_REQUEST_PREFIX, getStructuredIntentOutputSchema, buildStructuredIntentRequestPrompt, parseStructuredIntentRequestPrompt, buildStructuredIntentModelPrompt } from "./structuredIntentParts/structuredIntentPart1"; +export type { StructuredIntentKind, StructuredInsertPosition, StructuredTableColumn, StructuredDatabaseRow, StructuredDatabaseSeed, InsertBlockIntent, UpdateBlockIntent, MoveBlockIntent, ConvertBlockIntent, TextEditIntent, DatabaseIntent, ReviewBundleIntent, StructuredIntent, StructuredIntentParseIssue, StructuredIntentParseResult, StructuredIntentRequestEnvelope, StructuredIntentPromptConfig } from "./structuredIntentParts/structuredIntentPart1"; +export { parseStructuredIntentResult, parseStructuredIntentPreview } from "./structuredIntentParts/structuredIntentPart2"; diff --git a/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts new file mode 100644 index 0000000..ffcd888 --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts @@ -0,0 +1,356 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import type { PlanConfidence } from "../planTypes"; +import { parseStructuredIntentResult, parseStructuredIntentPreview, resolveAllowedStructuredIntentKinds, stringifyContextSummary, readStructuredIntent, readInsertBlockIntent, readUpdateBlockIntent, readMoveBlockIntent, readConvertBlockIntent, readTextEditIntent, readDatabaseIntent } from "./structuredIntentPart2"; +import { readReviewBundleIntent, readStructuredPosition, readStructuredColumns, readStructuredDatabaseRows, readStructuredDatabaseSeed, readConfidence, readRequiredString, asRecord, readNonEmptyString, isFiniteNumber } from "./structuredIntentPart3"; + +export const STRUCTURED_INTENT_REQUEST_PREFIX = + "pen:structured-intent-request/v1"; + +export type StructuredIntentKind = + | "insert_block" + | "update_block" + | "move_block" + | "convert_block" + | "text_edit" + | "database" + | "review_bundle"; + +export type StructuredInsertPosition = + | "before_active" + | "after_active" + | "start" + | "end" + | { beforeBlockId: string } + | { afterBlockId: string } + | { parentId: string; index: number }; + +export interface StructuredTableColumn { + id?: string; + title: string; + type?: TableColumnSchema["type"]; +} + +export interface StructuredDatabaseRow { + rowId?: string; + values: Record; +} + +export interface StructuredDatabaseSeed { + columns?: StructuredTableColumn[]; + rows?: StructuredDatabaseRow[]; + views?: DatabaseViewState[]; + activeViewId?: string; +} + +export interface InsertBlockIntent { + kind: "insert_block"; + blockId?: string; + blockType: string; + position: StructuredInsertPosition; + props?: Record; + initialText?: string; + database?: StructuredDatabaseSeed; + confidence?: PlanConfidence; +} + +export interface UpdateBlockIntent { + kind: "update_block"; + blockId: string; + props: Record; + confidence?: PlanConfidence; +} + +export interface MoveBlockIntent { + kind: "move_block"; + blockId: string; + position: StructuredInsertPosition; + confidence?: PlanConfidence; +} + +export interface ConvertBlockIntent { + kind: "convert_block"; + blockId: string; + newType: string; + props?: Record; + confidence?: PlanConfidence; +} + +export interface TextEditIntent { + kind: "text_edit"; + target: { + blockId: string; + range?: { + startOffset: number; + endOffset: number; + }; + }; + operation: "replace" | "insert" | "append"; + text: string; + confidence?: PlanConfidence; +} + +export interface DatabaseIntent { + kind: "database"; + blockId: string; + columns?: StructuredTableColumn[]; + rows?: StructuredDatabaseRow[]; + views?: DatabaseViewState[]; + activeViewId?: string; + confidence?: PlanConfidence; +} + +export interface ReviewBundleIntent { + kind: "review_bundle"; + label: string; + reason: string; + changes: StructuredIntent[]; + confidence?: PlanConfidence; +} + +export type StructuredIntent = + | InsertBlockIntent + | UpdateBlockIntent + | MoveBlockIntent + | ConvertBlockIntent + | TextEditIntent + | DatabaseIntent + | ReviewBundleIntent; + +export interface StructuredIntentParseIssue { + path: string; + code: "missing-field" | "invalid-shape" | "invalid-kind"; + message: string; +} + +export interface StructuredIntentParseResult { + intent: StructuredIntent | null; + intentState: "drafted" | "validated" | "rejected"; + issues: StructuredIntentParseIssue[]; +} + +export interface StructuredIntentRequestEnvelope { + version: 1; + contract: "structured-intent"; + targetKind: AITargetKind; + prompt: string; + activeBlockId: string | null; + contextSummary: unknown; +} + +export interface StructuredIntentPromptConfig { + prompt: string; + targetKind: AITargetKind; + activeBlockId: string | null; + workingSet: AIWorkingSetEnvelope | null; +} + +export function getStructuredIntentOutputSchema( + targetKind: AITargetKind, +): Record { + const structuredColumnSchema = { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + type: { type: "string" }, + }, + required: ["title"], + }, + }; + const databaseSeedSchema = { + type: "object", + properties: { + columns: structuredColumnSchema, + rows: { + type: "array", + items: { + type: "object", + properties: { + rowId: { type: "string" }, + values: { + type: "object", + additionalProperties: true, + }, + }, + required: ["values"], + }, + }, + views: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + }, + activeViewId: { type: "string" }, + }, + }; + const positionSchema = { + anyOf: [ + { type: "string", enum: ["before_active", "after_active", "start", "end"] }, + { + type: "object", + properties: { + beforeBlockId: { type: "string" }, + }, + required: ["beforeBlockId"], + }, + { + type: "object", + properties: { + afterBlockId: { type: "string" }, + }, + required: ["afterBlockId"], + }, + { + type: "object", + properties: { + parentId: { type: "string" }, + index: { type: "number" }, + }, + required: ["parentId", "index"], + }, + ], + }; + const insertBlockSchema = { + type: "object", + properties: { + kind: { const: "insert_block" }, + blockId: { type: "string" }, + blockType: { + type: "string", + enum: + targetKind === "database" + ? ["database"] + : ["paragraph", "heading", "database"], + }, + position: positionSchema, + props: { + type: "object", + additionalProperties: true, + }, + initialText: { type: "string" }, + database: databaseSeedSchema, + confidence: { + anyOf: [ + { type: "number" }, + { + type: "object", + properties: { + score: { type: "number" }, + reason: { type: "string" }, + }, + }, + ], + }, + }, + required: ["kind", "blockType", "position"], + }; + const databaseSchema = { + type: "object", + properties: { + kind: { const: "database" }, + blockId: { type: "string" }, + columns: structuredColumnSchema, + rows: databaseSeedSchema.properties.rows, + views: databaseSeedSchema.properties.views, + activeViewId: { type: "string" }, + }, + required: ["kind", "blockId"], + }; + return { + type: "object", + anyOf: [ + insertBlockSchema, + databaseSchema, + { + type: "object", + properties: { + kind: { const: "review_bundle" }, + label: { type: "string" }, + reason: { type: "string" }, + changes: { + type: "array", + items: { + anyOf: [insertBlockSchema, databaseSchema], + }, + }, + }, + required: ["kind", "label", "reason", "changes"], + }, + ], + }; +} + +export function buildStructuredIntentRequestPrompt( + config: StructuredIntentPromptConfig, +): string { + const envelope: StructuredIntentRequestEnvelope = { + version: 1, + contract: "structured-intent", + targetKind: config.targetKind, + prompt: config.prompt, + activeBlockId: config.activeBlockId, + contextSummary: config.workingSet?.context ?? null, + }; + return [ + STRUCTURED_INTENT_REQUEST_PREFIX, + JSON.stringify(envelope), + ].join("\n"); +} + +export function parseStructuredIntentRequestPrompt( + value: string, +): StructuredIntentRequestEnvelope | null { + if (!value.startsWith(`${STRUCTURED_INTENT_REQUEST_PREFIX}\n`)) { + return null; + } + const jsonPayload = value + .slice(STRUCTURED_INTENT_REQUEST_PREFIX.length) + .trimStart(); + try { + const parsed = JSON.parse(jsonPayload) as StructuredIntentRequestEnvelope; + if ( + parsed?.version === 1 && + parsed.contract === "structured-intent" && + typeof parsed.prompt === "string" && + typeof parsed.targetKind === "string" + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +export function buildStructuredIntentModelPrompt( + request: StructuredIntentRequestEnvelope, +): string { + const allowedKinds = resolveAllowedStructuredIntentKinds(request.targetKind); + return [ + "Produce one structured Pen intent object.", + "Return valid JSON only and no markdown fences or prose.", + `Target kind: ${request.targetKind}`, + `Allowed top-level intent kinds: ${allowedKinds.join(", ")}`, + "", + "Use these intent rules:", + '- always include a top-level "kind" field', + '- use "review_bundle" with a "changes" array for mixed edits', + '- use "insert_block" for new blocks with position "after_active", "before_active", "start", or "end"', + '- when creating a new database, prefer one "insert_block" with embedded "database" seed data', + '- for database rows, use "rows" with "values" keyed by column id', + '- do not emit executor-level row/col operations', + "", + "Context summary:", + stringifyContextSummary(request.contextSummary), + "", + "User request:", + request.prompt, + ].join("\n"); +} diff --git a/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts new file mode 100644 index 0000000..cefd081 --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts @@ -0,0 +1,344 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import type { PlanConfidence } from "../planTypes"; +import { STRUCTURED_INTENT_REQUEST_PREFIX, getStructuredIntentOutputSchema, buildStructuredIntentRequestPrompt, parseStructuredIntentRequestPrompt, buildStructuredIntentModelPrompt } from "./structuredIntentPart1"; +import type { StructuredIntentKind, StructuredInsertPosition, StructuredTableColumn, StructuredDatabaseRow, StructuredDatabaseSeed, InsertBlockIntent, UpdateBlockIntent, MoveBlockIntent, ConvertBlockIntent, TextEditIntent, DatabaseIntent, ReviewBundleIntent, StructuredIntent, StructuredIntentParseIssue, StructuredIntentParseResult, StructuredIntentRequestEnvelope, StructuredIntentPromptConfig } from "./structuredIntentPart1"; +import { readReviewBundleIntent, readStructuredPosition, readStructuredColumns, readStructuredDatabaseRows, readStructuredDatabaseSeed, readConfidence, readRequiredString, asRecord, readNonEmptyString, isFiniteNumber } from "./structuredIntentPart3"; + +export function parseStructuredIntentResult( + value: unknown, + targetKind: AITargetKind, +): StructuredIntentParseResult { + const issues: StructuredIntentParseIssue[] = []; + const intent = readStructuredIntent(value, "intent", issues, { + allowPartial: false, + targetKind, + }); + return { + intent, + intentState: intent ? "validated" : "rejected", + issues, + }; +} + +export function parseStructuredIntentPreview( + value: unknown, + targetKind: AITargetKind, +): StructuredIntentParseResult | null { + const issues: StructuredIntentParseIssue[] = []; + const intent = readStructuredIntent(value, "intent", issues, { + allowPartial: true, + targetKind, + }); + if (!intent) { + return null; + } + return { + intent, + intentState: issues.length === 0 ? "validated" : "drafted", + issues, + }; +} + +export function resolveAllowedStructuredIntentKinds( + targetKind: AITargetKind, +): StructuredIntentKind[] { + if (targetKind === "database") { + return ["insert_block", "database", "review_bundle"]; + } + if (targetKind === "text") { + return ["text_edit"]; + } + return [ + "insert_block", + "update_block", + "move_block", + "convert_block", + "database", + "review_bundle", + ]; +} + +export function stringifyContextSummary(value: unknown): string { + try { + return JSON.stringify(value ?? null); + } catch { + return "null"; + } +} + +export function readStructuredIntent( + value: unknown, + path: string, + issues: StructuredIntentParseIssue[], + options: { + allowPartial: boolean; + targetKind: AITargetKind; + }, +): StructuredIntent | null { + if (options.targetKind === "table") { + issues.push({ + path, + code: "invalid-kind", + message: + "Structured table intents are not supported. Use the markdown authoring lane for tables.", + }); + return null; + } + const record = asRecord(value); + if (!record) { + issues.push({ + path, + code: "invalid-shape", + message: "Structured intent must be an object.", + }); + return null; + } + const kind = readNonEmptyString(record.kind); + if (!kind) { + issues.push({ + path: `${path}.kind`, + code: "missing-field", + message: "Structured intent kind is required.", + }); + return null; + } + switch (kind) { + case "insert_block": + return readInsertBlockIntent(record, path, issues, options.allowPartial); + case "update_block": + return readUpdateBlockIntent(record, path, issues, options.allowPartial); + case "move_block": + return readMoveBlockIntent(record, path, issues, options.allowPartial); + case "convert_block": + return readConvertBlockIntent(record, path, issues, options.allowPartial); + case "text_edit": + return readTextEditIntent(record, path, issues, options.allowPartial); + case "database": + return readDatabaseIntent(record, path, issues, options.allowPartial); + case "review_bundle": + return readReviewBundleIntent(record, path, issues, options); + default: + issues.push({ + path: `${path}.kind`, + code: "invalid-kind", + message: `Unsupported structured intent kind "${kind}".`, + }); + return null; + } +} + +export function readInsertBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): InsertBlockIntent | null { + const blockType = readRequiredString( + record.blockType, + `${path}.blockType`, + issues, + allowPartial, + ); + const position = readStructuredPosition( + record.position, + `${path}.position`, + issues, + allowPartial, + ); + if (!blockType || !position) { + return null; + } + if (blockType === "table") { + if (!allowPartial) { + issues.push({ + path: `${path}.blockType`, + code: "invalid-kind", + message: + "Structured table intents are not supported. Use the markdown authoring lane for tables.", + }); + } + return null; + } + return { + kind: "insert_block", + blockId: readNonEmptyString(record.blockId) ?? undefined, + blockType, + position, + props: asRecord(record.props) ?? undefined, + initialText: readNonEmptyString(record.initialText) ?? undefined, + database: readStructuredDatabaseSeed(record.database), + confidence: readConfidence(record.confidence), + }; +} + +export function readUpdateBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): UpdateBlockIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + const props = asRecord(record.props); + if (!blockId || !props) { + if (!props && !allowPartial) { + issues.push({ + path: `${path}.props`, + code: "invalid-shape", + message: "Block update props must be an object.", + }); + } + return null; + } + return { + kind: "update_block", + blockId, + props, + confidence: readConfidence(record.confidence), + }; +} + +export function readMoveBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): MoveBlockIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + const position = readStructuredPosition( + record.position, + `${path}.position`, + issues, + allowPartial, + ); + if (!blockId || !position) { + return null; + } + return { + kind: "move_block", + blockId, + position, + confidence: readConfidence(record.confidence), + }; +} + +export function readConvertBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): ConvertBlockIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + const newType = readRequiredString( + record.newType, + `${path}.newType`, + issues, + allowPartial, + ); + if (!blockId || !newType) { + return null; + } + return { + kind: "convert_block", + blockId, + newType, + props: asRecord(record.props) ?? undefined, + confidence: readConfidence(record.confidence), + }; +} + +export function readTextEditIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): TextEditIntent | null { + const target = asRecord(record.target); + const blockId = readRequiredString( + target?.blockId, + `${path}.target.blockId`, + issues, + allowPartial, + ); + const operation = readRequiredString( + record.operation, + `${path}.operation`, + issues, + allowPartial, + ) as TextEditIntent["operation"] | null; + const text = readRequiredString( + record.text, + `${path}.text`, + issues, + allowPartial, + ); + if (!blockId || !operation || !text) { + return null; + } + const rangeRecord = asRecord(target?.range); + return { + kind: "text_edit", + target: { + blockId, + range: + rangeRecord && + isFiniteNumber(rangeRecord.startOffset) && + isFiniteNumber(rangeRecord.endOffset) + ? { + startOffset: rangeRecord.startOffset, + endOffset: rangeRecord.endOffset, + } + : undefined, + }, + operation, + text, + confidence: readConfidence(record.confidence), + }; +} + +export function readDatabaseIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): DatabaseIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + if (!blockId) { + return null; + } + return { + kind: "database", + blockId, + columns: readStructuredColumns(record.columns), + rows: readStructuredDatabaseRows(record.rows), + views: Array.isArray(record.views) + ? (record.views.filter((view): view is DatabaseViewState => { + return !!view && typeof view === "object"; + }) as DatabaseViewState[]) + : undefined, + activeViewId: readNonEmptyString(record.activeViewId) ?? undefined, + confidence: readConfidence(record.confidence), + }; +} diff --git a/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts new file mode 100644 index 0000000..c1634dc --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts @@ -0,0 +1,216 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import type { PlanConfidence } from "../planTypes"; +import { STRUCTURED_INTENT_REQUEST_PREFIX, getStructuredIntentOutputSchema, buildStructuredIntentRequestPrompt, parseStructuredIntentRequestPrompt, buildStructuredIntentModelPrompt } from "./structuredIntentPart1"; +import type { StructuredIntentKind, StructuredInsertPosition, StructuredTableColumn, StructuredDatabaseRow, StructuredDatabaseSeed, InsertBlockIntent, UpdateBlockIntent, MoveBlockIntent, ConvertBlockIntent, TextEditIntent, DatabaseIntent, ReviewBundleIntent, StructuredIntent, StructuredIntentParseIssue, StructuredIntentParseResult, StructuredIntentRequestEnvelope, StructuredIntentPromptConfig } from "./structuredIntentPart1"; +import { parseStructuredIntentResult, parseStructuredIntentPreview, resolveAllowedStructuredIntentKinds, stringifyContextSummary, readStructuredIntent, readInsertBlockIntent, readUpdateBlockIntent, readMoveBlockIntent, readConvertBlockIntent, readTextEditIntent, readDatabaseIntent } from "./structuredIntentPart2"; + +export function readReviewBundleIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + options: { allowPartial: boolean; targetKind: AITargetKind }, +): ReviewBundleIntent | null { + const changes = Array.isArray(record.changes) + ? record.changes + .map((entry, index) => + readStructuredIntent(entry, `${path}.changes[${index}]`, issues, options), + ) + .filter((entry): entry is StructuredIntent => entry !== null) + : []; + if (changes.length === 0 && !options.allowPartial) { + issues.push({ + path: `${path}.changes`, + code: "missing-field", + message: "Review bundle changes are required.", + }); + return null; + } + return { + kind: "review_bundle", + label: + readNonEmptyString(record.label) ?? + (options.allowPartial ? "Streaming structured changes" : ""), + reason: + readNonEmptyString(record.reason) ?? + (options.allowPartial ? "Streaming structured preview." : ""), + changes, + confidence: readConfidence(record.confidence), + }; +} + +export function readStructuredPosition( + value: unknown, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): StructuredInsertPosition | null { + if ( + value === "before_active" || + value === "after_active" || + value === "start" || + value === "end" + ) { + return value; + } + const record = asRecord(value); + if (!record) { + if (!allowPartial) { + issues.push({ + path, + code: "invalid-shape", + message: "Structured position is required.", + }); + } + return null; + } + const beforeBlockId = readNonEmptyString(record.beforeBlockId); + if (beforeBlockId) { + return { beforeBlockId }; + } + const afterBlockId = readNonEmptyString(record.afterBlockId); + if (afterBlockId) { + return { afterBlockId }; + } + const parentId = readNonEmptyString(record.parentId); + if (parentId && isFiniteNumber(record.index)) { + return { parentId, index: record.index }; + } + if (!allowPartial) { + issues.push({ + path, + code: "invalid-shape", + message: "Structured position is invalid.", + }); + } + return null; +} + +export function readStructuredColumns( + value: unknown, +): StructuredTableColumn[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const columns = value.flatMap((column) => { + const record = asRecord(column); + const title = + readNonEmptyString(record?.title) ?? readNonEmptyString(record?.header); + if (!title) { + return []; + } + const normalizedColumn: StructuredTableColumn = { + id: readNonEmptyString(record?.id) ?? undefined, + title, + type: + (readNonEmptyString(record?.type) as TableColumnSchema["type"] | null) ?? + "text", + }; + return [normalizedColumn]; + }); + return columns.length > 0 ? columns : undefined; +} + +export function readStructuredDatabaseRows( + value: unknown, +): StructuredDatabaseRow[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const rows = value.flatMap((row) => { + const record = asRecord(row); + const values = asRecord(record?.values); + if (!values) { + return []; + } + const normalizedRow: StructuredDatabaseRow = { + rowId: readNonEmptyString(record?.rowId) ?? undefined, + values, + }; + return [normalizedRow]; + }); + return rows.length > 0 ? rows : undefined; +} + +export function readStructuredDatabaseSeed( + value: unknown, +): StructuredDatabaseSeed | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const columns = readStructuredColumns(record.columns); + const rows = readStructuredDatabaseRows(record.rows); + const views = Array.isArray(record.views) + ? (record.views.filter((view): view is DatabaseViewState => { + return !!view && typeof view === "object"; + }) as DatabaseViewState[]) + : undefined; + const activeViewId = readNonEmptyString(record.activeViewId) ?? undefined; + if (!columns && !rows && !views && !activeViewId) { + return undefined; + } + return { + columns, + rows, + views, + activeViewId, + }; +} + +export function readConfidence(value: unknown): PlanConfidence | undefined { + if (value == null) { + return undefined; + } + if (isFiniteNumber(value)) { + return { score: value }; + } + const record = asRecord(value); + if (!record) { + return undefined; + } + const confidence: PlanConfidence = {}; + if (isFiniteNumber(record.score)) { + confidence.score = record.score; + } + if (readNonEmptyString(record.reason)) { + confidence.reason = record.reason as string; + } + return Object.keys(confidence).length > 0 ? confidence : undefined; +} + +export function readRequiredString( + value: unknown, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): string | null { + const stringValue = readNonEmptyString(value); + if (stringValue) { + return stringValue; + } + if (!allowPartial) { + issues.push({ + path, + code: "missing-field", + message: "Field is required.", + }); + } + return null; +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/packages/extensions/ai/src/runtime/structuredPlanner.ts b/packages/extensions/ai/src/runtime/structuredPlanner.ts index 1c6cf6a..b636a68 100644 --- a/packages/extensions/ai/src/runtime/structuredPlanner.ts +++ b/packages/extensions/ai/src/runtime/structuredPlanner.ts @@ -1,699 +1,2 @@ -import type { AIWorkingSetEnvelope } from "../types"; -import type { - AIExecutionMode, - AIPlannerMode, - AITargetKind, - AIMutationMode, -} from "./contracts"; -import type { DocumentMutationPlan } from "./planTypes"; -import { - validateDocumentMutationPlanShape, - type PlanValidationIssue, -} from "./planValidation"; - -export interface StructuredPlannerConfig { - prompt: string; - targetKind: AITargetKind; - workingSet: AIWorkingSetEnvelope | null; -} - -export interface StructuredPlannerParseResult { - plan: DocumentMutationPlan | null; - planState: "drafted" | "validated" | "rejected"; - issues: PlanValidationIssue[]; -} - -export function resolvePlannerMode(options: { - targetKind: AITargetKind; - intent: "rewrite" | "continue" | "local-edit" | "structural" | "search" | "review" | "unknown"; - target: "selection" | "block"; -}): AIPlannerMode { - if (options.target === "selection") { - return "text"; - } - if (options.targetKind === "database") { - return "structured"; - } - if (options.targetKind === "table") { - return "text"; - } - if (options.intent === "structural" || options.intent === "review") { - return "structured"; - } - return "text"; -} - -export function resolveGenerationTargetKind(options: { - target: "selection" | "block"; - blockType: string | null; - workingSet: AIWorkingSetEnvelope | null; -}): AITargetKind { - if (options.target === "selection") { - return "text"; - } - - const structuredKind = readStructuredTargetKind(options.workingSet); - if (structuredKind) { - return structuredKind; - } - - if (options.blockType === "table") { - return "table"; - } - if (options.blockType === "database") { - return "database"; - } - return "block"; -} - -export function buildPlannerPrompt( - config: StructuredPlannerConfig, -): string { - const allowedPlanKinds = resolveAllowedPlanKinds(config.targetKind); - const targetSummary = buildTargetSummary(config.workingSet); - - return [ - "Produce a structured Pen document mutation plan.", - "Return exactly one JSON object and no markdown fences or prose.", - `Target kind: ${config.targetKind}`, - `Allowed top-level plan kinds: ${allowedPlanKinds.join(", ")}`, - "", - "Use these JSON-shape rules:", - '- include a top-level "kind" string', - "- include all required object properties for the chosen plan kind", - "- use arrays only for ordered step lists", - "- use nested objects for target, position, confidence, and patch payloads", - "- for review bundles, return a review_bundle with nested plans", - "- if later plans need to target a newly inserted block, include blockId on the block_insert plan and reuse that same blockId in later plans", - "- for new databases, prefer a review_bundle that inserts the block first and then applies database_edit steps to that inserted block", - "", - "Context summary:", - targetSummary, - "", - "User request:", - config.prompt, - ].join("\n"); -} - -export function parseStructuredPlanResult( - value: string, - targetKind: AITargetKind, -): StructuredPlannerParseResult { - if (targetKind === "table") { - return { - plan: null, - planState: "rejected", - issues: [ - { - path: "plan", - code: "invalid-kind", - severity: "error", - message: - "Structured table plans are not supported. Use the markdown authoring lane for tables.", - }, - ], - }; - } - const jsonPayload = extractJsonObject(value); - if (!jsonPayload) { - return { - plan: null, - planState: "rejected", - issues: [ - { - path: "plan", - code: "invalid-shape", - severity: "error", - message: "Planner response did not contain a JSON object.", - }, - ], - }; - } - - try { - const parsed = normalizeStructuredPlanCandidate( - JSON.parse(jsonPayload) as unknown, - targetKind, - ); - const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); - return { - plan: validation.valid ? (parsed as DocumentMutationPlan) : null, - planState: validation.valid ? "validated" : "rejected", - issues: validation.issues, - }; - } catch (error) { - return { - plan: null, - planState: "rejected", - issues: [ - { - path: "plan", - code: "invalid-shape", - severity: "error", - message: - error instanceof Error - ? error.message - : "Planner response could not be parsed as JSON.", - }, - ], - }; - } -} - -export function parseStructuredPlanPreview( - value: string, - targetKind: AITargetKind, -): StructuredPlannerParseResult | null { - if (targetKind === "table") { - return null; - } - const jsonPayload = extractJsonObject(value); - if (jsonPayload) { - try { - const parsed = normalizeStructuredPlanCandidate( - JSON.parse(jsonPayload) as unknown, - targetKind, - ); - const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); - if (!validation.valid) { - return null; - } - return { - plan: parsed as DocumentMutationPlan, - planState: "validated", - issues: validation.issues, - }; - } catch { - // Fall through to tolerant parsing. - } - } - - const partialPlan = parsePartialStructuredPlan(value, targetKind); - if (!partialPlan) { - return null; - } - const validation = validateDocumentMutationPlanShape(partialPlan, { targetKind }); - if (!validation.valid) { - return null; - } - return { - plan: partialPlan, - planState: "drafted", - issues: validation.issues, - }; -} - -export function resolveExecutionMode( - mutationMode: AIMutationMode, -): AIExecutionMode { - if (mutationMode === "staged-review") { - return "staged-review"; - } - if (mutationMode === "persistent-suggestions") { - return "persistent-suggestions"; - } - return "direct-stream"; -} - -function resolveAllowedPlanKinds(targetKind: AITargetKind): string[] { - if (targetKind === "database") { - return ["database_edit", "review_bundle"]; - } - if (targetKind === "text") { - return ["text_edit", "review_bundle"]; - } - return [ - "text_edit", - "block_insert", - "block_update", - "block_move", - "block_convert", - "review_bundle", - ]; -} - -function buildTargetSummary(workingSet: AIWorkingSetEnvelope | null): string { - if (!workingSet) { - return "No working set available."; - } - - try { - return JSON.stringify(workingSet.context ?? null); - } catch { - return "Working set context could not be serialized."; - } -} - -function readStructuredTargetKind( - workingSet: AIWorkingSetEnvelope | null, -): AITargetKind | null { - if (!workingSet?.context || typeof workingSet.context !== "object") { - return null; - } - - const context = workingSet.context as { - structuredTarget?: { - target?: { - kind?: unknown; - }; - } | null; - }; - - const kind = context.structuredTarget?.target?.kind; - return kind === "block" || kind === "table" || kind === "database" - ? kind - : null; -} - -function normalizeStructuredPlanCandidate( - value: unknown, - targetKind: AITargetKind, -): unknown { - const record = asRecord(value); - if (!record || typeof record.kind !== "string") { - return value; - } - - switch (record.kind) { - case "review_bundle": - return normalizeReviewBundlePlan(record, targetKind); - case "block_insert": - return normalizeBlockInsertPlan(record, targetKind); - case "database_edit": - return normalizeDatabaseEditPlan(record); - default: - return value; - } -} - -function normalizeReviewBundlePlan( - record: Record, - targetKind: AITargetKind, -): Record { - const plans = Array.isArray(record.plans) - ? record.plans.map((plan) => normalizeStructuredPlanCandidate(plan, targetKind)) - : record.plans; - return { - ...record, - label: readNonEmptyString(record.label) ?? "Structured changes", - reason: - readNonEmptyString(record.reason) ?? - "Apply the requested structured changes.", - confidence: normalizeConfidence(record.confidence), - plans, - }; -} - -function normalizeBlockInsertPlan( - record: Record, - targetKind: AITargetKind, -): Record { - const block = asRecord(record.block); - const blockType = - readNonEmptyString(record.blockType) ?? - readNonEmptyString(block?.type) ?? - readNonEmptyString(block?.kind) ?? - (targetKind === "database" ? targetKind : null); - - return { - ...record, - ...(blockType ? { blockType } : {}), - confidence: normalizeConfidence(record.confidence), - position: normalizePosition(record.position), - }; -} - -function normalizeDatabaseEditPlan( - record: Record, -): Record { - const target = asRecord(record.target); - const blockId = - readNonEmptyString(record.blockId) ?? readNonEmptyString(target?.blockId); - return { - ...record, - ...(blockId ? { blockId } : {}), - confidence: normalizeConfidence(record.confidence), - }; -} - -function normalizePosition(value: unknown): unknown { - const position = asRecord(value); - if (!position) { - return value; - } - const parentId = readNonEmptyString(position.parentId); - if (parentId && isFiniteNumber(position.index)) { - if (parentId === "root") { - return position.index <= 0 ? "first" : "last"; - } - return { - parent: parentId, - index: position.index, - }; - } - if ( - parentId === "root" && - ("after" in position || "before" in position) && - !isFiniteNumber(position.index) - ) { - return "last"; - } - const relativeTo = readNonEmptyString(position.relativeTo); - const placement = readNonEmptyString(position.placement); - if (relativeTo === "active") { - if (placement === "before") { - return "first"; - } - if (placement === "after") { - return "last"; - } - } - return value; -} - -function normalizeConfidence(value: unknown): unknown { - if (isFiniteNumber(value)) { - return { score: value }; - } - return value; -} - -function extractJsonObject(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); - const candidate = fencedMatch?.[1]?.trim() ?? trimmed; - return extractBalancedJsonObject(candidate); -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function parsePartialStructuredPlan( - value: string, - targetKind: AITargetKind, -): DocumentMutationPlan | null { - const kind = readStringField(value, "kind"); - if (!kind) { - return null; - } - - if (kind === "review_bundle") { - const nestedPlans = readPartialObjectArray(value, "plans").filter((plan) => - validateDocumentMutationPlanShape(plan, { targetKind }).valid, - ) as DocumentMutationPlan[]; - if (nestedPlans.length === 0) { - return null; - } - return { - kind, - label: readStringField(value, "label") ?? "Streaming review bundle", - reason: - readStringField(value, "reason") ?? - "Previewing mixed structural changes while the plan streams.", - plans: nestedPlans, - }; - } - - if (kind === "text_edit" && targetKind === "text") { - const blockId = readStringField(value, "blockId"); - const operation = readStringField(value, "operation"); - const text = readStringField(value, "text"); - if (!blockId || !operation || text == null) { - return null; - } - return { - kind, - target: { blockId }, - operation: operation as "replace" | "insert" | "append", - text, - }; - } - - if (targetKind === "block") { - if (kind === "block_insert") { - const blockId = readStringField(value, "blockId"); - const blockType = readStringField(value, "blockType"); - const position = readPositionField(value, "position"); - if (!blockType || !position) { - return null; - } - return { - kind, - blockId: blockId ?? undefined, - blockType, - position, - props: - (readObjectField(value, "props") as Record | null) ?? - undefined, - initialText: readStringField(value, "initialText") ?? undefined, - }; - } - - if (kind === "block_update") { - const blockId = readStringField(value, "blockId"); - const props = readObjectField(value, "props"); - if (!blockId || !isRecordValue(props)) { - return null; - } - return { - kind, - blockId, - props, - }; - } - - if (kind === "block_move") { - const blockId = readStringField(value, "blockId"); - const position = readPositionField(value, "position"); - if (!blockId || !position) { - return null; - } - return { - kind, - blockId, - position, - }; - } - - if (kind === "block_convert") { - const blockId = readStringField(value, "blockId"); - const newType = readStringField(value, "newType"); - if (!blockId || !newType) { - return null; - } - return { - kind, - blockId, - newType, - props: - (readObjectField(value, "props") as Record | null) ?? - undefined, - }; - } - } - - if (kind === "database_edit" && targetKind === "database") { - const blockId = readStringField(value, "blockId"); - if (!blockId) { - return null; - } - const steps = readPartialObjectArray(value, "steps"); - if (steps.length === 0) { - return null; - } - return { - kind, - blockId, - steps, - } as DocumentMutationPlan; - } - - return null; -} - -function readStringField(value: string, fieldName: string): string | null { - const fieldMatch = value.match( - new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"`, "m"), - ); - if (!fieldMatch?.[1]) { - return null; - } - try { - return JSON.parse(`"${fieldMatch[1]}"`) as string; - } catch { - return fieldMatch[1]; - } -} - -function readPartialObjectArray(value: string, fieldName: string): unknown[] { - const fieldMatch = value.match( - new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\[`, "m"), - ); - if (!fieldMatch || fieldMatch.index == null) { - return []; - } - - const arrayStart = fieldMatch.index + fieldMatch[0].length - 1; - return readBalancedObjectsFromArray(value.slice(arrayStart)); -} - -function readObjectField(value: string, fieldName: string): unknown | null { - const fieldMatch = value.match( - new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\{`, "m"), - ); - if (!fieldMatch || fieldMatch.index == null) { - return null; - } - - const objectStart = fieldMatch.index + fieldMatch[0].length - 1; - const objectText = extractBalancedJsonObject(value.slice(objectStart)); - if (!objectText) { - return null; - } - try { - return JSON.parse(objectText) as unknown; - } catch { - return null; - } -} - -function readPositionField( - value: string, - fieldName: string, -): "first" | "last" | { before: string } | { after: string } | { parent: string; index: number } | null { - const stringValue = readStringField(value, fieldName); - if (stringValue === "first" || stringValue === "last") { - return stringValue; - } - - const objectValue = readObjectField(value, fieldName); - if (!isRecordValue(objectValue)) { - return null; - } - if (typeof objectValue.before === "string" && objectValue.before.length > 0) { - return { before: objectValue.before }; - } - if (typeof objectValue.after === "string" && objectValue.after.length > 0) { - return { after: objectValue.after }; - } - if ( - typeof objectValue.parent === "string" && - objectValue.parent.length > 0 && - typeof objectValue.index === "number" - ) { - return { parent: objectValue.parent, index: objectValue.index }; - } - return null; -} - -function readBalancedObjectsFromArray(value: string): unknown[] { - const parsedValues: unknown[] = []; - let depth = 0; - let inString = false; - let isEscaped = false; - let objectStart = -1; - - for (let index = 0; index < value.length; index += 1) { - const character = value[index]!; - if (isEscaped) { - isEscaped = false; - continue; - } - if (character === "\\") { - isEscaped = true; - continue; - } - if (character === "\"") { - inString = !inString; - continue; - } - if (inString) { - continue; - } - if (character === "{") { - if (depth === 0) { - objectStart = index; - } - depth += 1; - continue; - } - if (character === "}") { - depth -= 1; - if (depth === 0 && objectStart >= 0) { - const objectText = value.slice(objectStart, index + 1); - try { - parsedValues.push(JSON.parse(objectText) as unknown); - } catch { - return parsedValues; - } - objectStart = -1; - } - } - } - - return parsedValues; -} - -function extractBalancedJsonObject(value: string): string | null { - const startIndex = value.indexOf("{"); - if (startIndex === -1) { - return null; - } - - let depth = 0; - let inString = false; - let isEscaped = false; - for (let index = startIndex; index < value.length; index += 1) { - const character = value[index]!; - if (isEscaped) { - isEscaped = false; - continue; - } - if (character === "\\") { - isEscaped = true; - continue; - } - if (character === "\"") { - inString = !inString; - continue; - } - if (inString) { - continue; - } - if (character === "{") { - depth += 1; - continue; - } - if (character === "}") { - depth -= 1; - if (depth === 0) { - return value.slice(startIndex, index + 1); - } - } - } - - return null; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function isRecordValue(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export { resolvePlannerMode, resolveGenerationTargetKind, buildPlannerPrompt, parseStructuredPlanResult, parseStructuredPlanPreview, resolveExecutionMode } from "./structuredPlannerParts/structuredPlannerPart1"; +export type { StructuredPlannerConfig, StructuredPlannerParseResult } from "./structuredPlannerParts/structuredPlannerPart1"; diff --git a/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts new file mode 100644 index 0000000..f6076e8 --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts @@ -0,0 +1,374 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { + AIExecutionMode, + AIPlannerMode, + AITargetKind, + AIMutationMode, +} from "../contracts"; +import type { DocumentMutationPlan } from "../planTypes"; +import { + validateDocumentMutationPlanShape, + type PlanValidationIssue, +} from "../planValidation"; +import { normalizeConfidence, extractJsonObject, asRecord, readNonEmptyString, isFiniteNumber, parsePartialStructuredPlan, readStringField, readPartialObjectArray, readObjectField, readPositionField, readBalancedObjectsFromArray, extractBalancedJsonObject, escapeRegExp, isRecordValue } from "./structuredPlannerPart2"; + +export interface StructuredPlannerConfig { + prompt: string; + targetKind: AITargetKind; + workingSet: AIWorkingSetEnvelope | null; +} + +export interface StructuredPlannerParseResult { + plan: DocumentMutationPlan | null; + planState: "drafted" | "validated" | "rejected"; + issues: PlanValidationIssue[]; +} + +export function resolvePlannerMode(options: { + targetKind: AITargetKind; + intent: "rewrite" | "continue" | "local-edit" | "structural" | "search" | "review" | "unknown"; + target: "selection" | "block"; +}): AIPlannerMode { + if (options.target === "selection") { + return "text"; + } + if (options.targetKind === "database") { + return "structured"; + } + if (options.targetKind === "table") { + return "text"; + } + if (options.intent === "structural" || options.intent === "review") { + return "structured"; + } + return "text"; +} + +export function resolveGenerationTargetKind(options: { + target: "selection" | "block"; + blockType: string | null; + workingSet: AIWorkingSetEnvelope | null; +}): AITargetKind { + if (options.target === "selection") { + return "text"; + } + + const structuredKind = readStructuredTargetKind(options.workingSet); + if (structuredKind) { + return structuredKind; + } + + if (options.blockType === "table") { + return "table"; + } + if (options.blockType === "database") { + return "database"; + } + return "block"; +} + +export function buildPlannerPrompt( + config: StructuredPlannerConfig, +): string { + const allowedPlanKinds = resolveAllowedPlanKinds(config.targetKind); + const targetSummary = buildTargetSummary(config.workingSet); + + return [ + "Produce a structured Pen document mutation plan.", + "Return exactly one JSON object and no markdown fences or prose.", + `Target kind: ${config.targetKind}`, + `Allowed top-level plan kinds: ${allowedPlanKinds.join(", ")}`, + "", + "Use these JSON-shape rules:", + '- include a top-level "kind" string', + "- include all required object properties for the chosen plan kind", + "- use arrays only for ordered step lists", + "- use nested objects for target, position, confidence, and patch payloads", + "- for review bundles, return a review_bundle with nested plans", + "- if later plans need to target a newly inserted block, include blockId on the block_insert plan and reuse that same blockId in later plans", + "- for new databases, prefer a review_bundle that inserts the block first and then applies database_edit steps to that inserted block", + "", + "Context summary:", + targetSummary, + "", + "User request:", + config.prompt, + ].join("\n"); +} + +export function parseStructuredPlanResult( + value: string, + targetKind: AITargetKind, +): StructuredPlannerParseResult { + if (targetKind === "table") { + return { + plan: null, + planState: "rejected", + issues: [ + { + path: "plan", + code: "invalid-kind", + severity: "error", + message: + "Structured table plans are not supported. Use the markdown authoring lane for tables.", + }, + ], + }; + } + const jsonPayload = extractJsonObject(value); + if (!jsonPayload) { + return { + plan: null, + planState: "rejected", + issues: [ + { + path: "plan", + code: "invalid-shape", + severity: "error", + message: "Planner response did not contain a JSON object.", + }, + ], + }; + } + + try { + const parsed = normalizeStructuredPlanCandidate( + JSON.parse(jsonPayload) as unknown, + targetKind, + ); + const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); + return { + plan: validation.valid ? (parsed as DocumentMutationPlan) : null, + planState: validation.valid ? "validated" : "rejected", + issues: validation.issues, + }; + } catch (error) { + return { + plan: null, + planState: "rejected", + issues: [ + { + path: "plan", + code: "invalid-shape", + severity: "error", + message: + error instanceof Error + ? error.message + : "Planner response could not be parsed as JSON.", + }, + ], + }; + } +} + +export function parseStructuredPlanPreview( + value: string, + targetKind: AITargetKind, +): StructuredPlannerParseResult | null { + if (targetKind === "table") { + return null; + } + const jsonPayload = extractJsonObject(value); + if (jsonPayload) { + try { + const parsed = normalizeStructuredPlanCandidate( + JSON.parse(jsonPayload) as unknown, + targetKind, + ); + const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); + if (!validation.valid) { + return null; + } + return { + plan: parsed as DocumentMutationPlan, + planState: "validated", + issues: validation.issues, + }; + } catch { + // Fall through to tolerant parsing. + } + } + + const partialPlan = parsePartialStructuredPlan(value, targetKind); + if (!partialPlan) { + return null; + } + const validation = validateDocumentMutationPlanShape(partialPlan, { targetKind }); + if (!validation.valid) { + return null; + } + return { + plan: partialPlan, + planState: "drafted", + issues: validation.issues, + }; +} + +export function resolveExecutionMode( + mutationMode: AIMutationMode, +): AIExecutionMode { + if (mutationMode === "staged-review") { + return "staged-review"; + } + if (mutationMode === "persistent-suggestions") { + return "persistent-suggestions"; + } + return "direct-stream"; +} + +export function resolveAllowedPlanKinds(targetKind: AITargetKind): string[] { + if (targetKind === "database") { + return ["database_edit", "review_bundle"]; + } + if (targetKind === "text") { + return ["text_edit", "review_bundle"]; + } + return [ + "text_edit", + "block_insert", + "block_update", + "block_move", + "block_convert", + "review_bundle", + ]; +} + +export function buildTargetSummary(workingSet: AIWorkingSetEnvelope | null): string { + if (!workingSet) { + return "No working set available."; + } + + try { + return JSON.stringify(workingSet.context ?? null); + } catch { + return "Working set context could not be serialized."; + } +} + +export function readStructuredTargetKind( + workingSet: AIWorkingSetEnvelope | null, +): AITargetKind | null { + if (!workingSet?.context || typeof workingSet.context !== "object") { + return null; + } + + const context = workingSet.context as { + structuredTarget?: { + target?: { + kind?: unknown; + }; + } | null; + }; + + const kind = context.structuredTarget?.target?.kind; + return kind === "block" || kind === "table" || kind === "database" + ? kind + : null; +} + +export function normalizeStructuredPlanCandidate( + value: unknown, + targetKind: AITargetKind, +): unknown { + const record = asRecord(value); + if (!record || typeof record.kind !== "string") { + return value; + } + + switch (record.kind) { + case "review_bundle": + return normalizeReviewBundlePlan(record, targetKind); + case "block_insert": + return normalizeBlockInsertPlan(record, targetKind); + case "database_edit": + return normalizeDatabaseEditPlan(record); + default: + return value; + } +} + +export function normalizeReviewBundlePlan( + record: Record, + targetKind: AITargetKind, +): Record { + const plans = Array.isArray(record.plans) + ? record.plans.map((plan) => normalizeStructuredPlanCandidate(plan, targetKind)) + : record.plans; + return { + ...record, + label: readNonEmptyString(record.label) ?? "Structured changes", + reason: + readNonEmptyString(record.reason) ?? + "Apply the requested structured changes.", + confidence: normalizeConfidence(record.confidence), + plans, + }; +} + +export function normalizeBlockInsertPlan( + record: Record, + targetKind: AITargetKind, +): Record { + const block = asRecord(record.block); + const blockType = + readNonEmptyString(record.blockType) ?? + readNonEmptyString(block?.type) ?? + readNonEmptyString(block?.kind) ?? + (targetKind === "database" ? targetKind : null); + + return { + ...record, + ...(blockType ? { blockType } : {}), + confidence: normalizeConfidence(record.confidence), + position: normalizePosition(record.position), + }; +} + +export function normalizeDatabaseEditPlan( + record: Record, +): Record { + const target = asRecord(record.target); + const blockId = + readNonEmptyString(record.blockId) ?? readNonEmptyString(target?.blockId); + return { + ...record, + ...(blockId ? { blockId } : {}), + confidence: normalizeConfidence(record.confidence), + }; +} + +export function normalizePosition(value: unknown): unknown { + const position = asRecord(value); + if (!position) { + return value; + } + const parentId = readNonEmptyString(position.parentId); + if (parentId && isFiniteNumber(position.index)) { + if (parentId === "root") { + return position.index <= 0 ? "first" : "last"; + } + return { + parent: parentId, + index: position.index, + }; + } + if ( + parentId === "root" && + ("after" in position || "before" in position) && + !isFiniteNumber(position.index) + ) { + return "last"; + } + const relativeTo = readNonEmptyString(position.relativeTo); + const placement = readNonEmptyString(position.placement); + if (relativeTo === "active") { + if (placement === "before") { + return "first"; + } + if (placement === "after") { + return "last"; + } + } + return value; +} diff --git a/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts new file mode 100644 index 0000000..07d408c --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts @@ -0,0 +1,342 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { + AIExecutionMode, + AIPlannerMode, + AITargetKind, + AIMutationMode, +} from "../contracts"; +import type { DocumentMutationPlan } from "../planTypes"; +import { + validateDocumentMutationPlanShape, + type PlanValidationIssue, +} from "../planValidation"; +import { resolvePlannerMode, resolveGenerationTargetKind, buildPlannerPrompt, parseStructuredPlanResult, parseStructuredPlanPreview, resolveExecutionMode, resolveAllowedPlanKinds, buildTargetSummary, readStructuredTargetKind, normalizeStructuredPlanCandidate, normalizeReviewBundlePlan, normalizeBlockInsertPlan, normalizeDatabaseEditPlan, normalizePosition } from "./structuredPlannerPart1"; +import type { StructuredPlannerConfig, StructuredPlannerParseResult } from "./structuredPlannerPart1"; + +export function normalizeConfidence(value: unknown): unknown { + if (isFiniteNumber(value)) { + return { score: value }; + } + return value; +} + +export function extractJsonObject(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fencedMatch?.[1]?.trim() ?? trimmed; + return extractBalancedJsonObject(candidate); +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function parsePartialStructuredPlan( + value: string, + targetKind: AITargetKind, +): DocumentMutationPlan | null { + const kind = readStringField(value, "kind"); + if (!kind) { + return null; + } + + if (kind === "review_bundle") { + const nestedPlans = readPartialObjectArray(value, "plans").filter((plan) => + validateDocumentMutationPlanShape(plan, { targetKind }).valid, + ) as DocumentMutationPlan[]; + if (nestedPlans.length === 0) { + return null; + } + return { + kind, + label: readStringField(value, "label") ?? "Streaming review bundle", + reason: + readStringField(value, "reason") ?? + "Previewing mixed structural changes while the plan streams.", + plans: nestedPlans, + }; + } + + if (kind === "text_edit" && targetKind === "text") { + const blockId = readStringField(value, "blockId"); + const operation = readStringField(value, "operation"); + const text = readStringField(value, "text"); + if (!blockId || !operation || text == null) { + return null; + } + return { + kind, + target: { blockId }, + operation: operation as "replace" | "insert" | "append", + text, + }; + } + + if (targetKind === "block") { + if (kind === "block_insert") { + const blockId = readStringField(value, "blockId"); + const blockType = readStringField(value, "blockType"); + const position = readPositionField(value, "position"); + if (!blockType || !position) { + return null; + } + return { + kind, + blockId: blockId ?? undefined, + blockType, + position, + props: + (readObjectField(value, "props") as Record | null) ?? + undefined, + initialText: readStringField(value, "initialText") ?? undefined, + }; + } + + if (kind === "block_update") { + const blockId = readStringField(value, "blockId"); + const props = readObjectField(value, "props"); + if (!blockId || !isRecordValue(props)) { + return null; + } + return { + kind, + blockId, + props, + }; + } + + if (kind === "block_move") { + const blockId = readStringField(value, "blockId"); + const position = readPositionField(value, "position"); + if (!blockId || !position) { + return null; + } + return { + kind, + blockId, + position, + }; + } + + if (kind === "block_convert") { + const blockId = readStringField(value, "blockId"); + const newType = readStringField(value, "newType"); + if (!blockId || !newType) { + return null; + } + return { + kind, + blockId, + newType, + props: + (readObjectField(value, "props") as Record | null) ?? + undefined, + }; + } + } + + if (kind === "database_edit" && targetKind === "database") { + const blockId = readStringField(value, "blockId"); + if (!blockId) { + return null; + } + const steps = readPartialObjectArray(value, "steps"); + if (steps.length === 0) { + return null; + } + return { + kind, + blockId, + steps, + } as DocumentMutationPlan; + } + + return null; +} + +export function readStringField(value: string, fieldName: string): string | null { + const fieldMatch = value.match( + new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"`, "m"), + ); + if (!fieldMatch?.[1]) { + return null; + } + try { + return JSON.parse(`"${fieldMatch[1]}"`) as string; + } catch { + return fieldMatch[1]; + } +} + +export function readPartialObjectArray(value: string, fieldName: string): unknown[] { + const fieldMatch = value.match( + new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\[`, "m"), + ); + if (!fieldMatch || fieldMatch.index == null) { + return []; + } + + const arrayStart = fieldMatch.index + fieldMatch[0].length - 1; + return readBalancedObjectsFromArray(value.slice(arrayStart)); +} + +export function readObjectField(value: string, fieldName: string): unknown | null { + const fieldMatch = value.match( + new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\{`, "m"), + ); + if (!fieldMatch || fieldMatch.index == null) { + return null; + } + + const objectStart = fieldMatch.index + fieldMatch[0].length - 1; + const objectText = extractBalancedJsonObject(value.slice(objectStart)); + if (!objectText) { + return null; + } + try { + return JSON.parse(objectText) as unknown; + } catch { + return null; + } +} + +export function readPositionField( + value: string, + fieldName: string, +): "first" | "last" | { before: string } | { after: string } | { parent: string; index: number } | null { + const stringValue = readStringField(value, fieldName); + if (stringValue === "first" || stringValue === "last") { + return stringValue; + } + + const objectValue = readObjectField(value, fieldName); + if (!isRecordValue(objectValue)) { + return null; + } + if (typeof objectValue.before === "string" && objectValue.before.length > 0) { + return { before: objectValue.before }; + } + if (typeof objectValue.after === "string" && objectValue.after.length > 0) { + return { after: objectValue.after }; + } + if ( + typeof objectValue.parent === "string" && + objectValue.parent.length > 0 && + typeof objectValue.index === "number" + ) { + return { parent: objectValue.parent, index: objectValue.index }; + } + return null; +} + +export function readBalancedObjectsFromArray(value: string): unknown[] { + const parsedValues: unknown[] = []; + let depth = 0; + let inString = false; + let isEscaped = false; + let objectStart = -1; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]!; + if (isEscaped) { + isEscaped = false; + continue; + } + if (character === "\\") { + isEscaped = true; + continue; + } + if (character === "\"") { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (character === "{") { + if (depth === 0) { + objectStart = index; + } + depth += 1; + continue; + } + if (character === "}") { + depth -= 1; + if (depth === 0 && objectStart >= 0) { + const objectText = value.slice(objectStart, index + 1); + try { + parsedValues.push(JSON.parse(objectText) as unknown); + } catch { + return parsedValues; + } + objectStart = -1; + } + } + } + + return parsedValues; +} + +export function extractBalancedJsonObject(value: string): string | null { + const startIndex = value.indexOf("{"); + if (startIndex === -1) { + return null; + } + + let depth = 0; + let inString = false; + let isEscaped = false; + for (let index = startIndex; index < value.length; index += 1) { + const character = value[index]!; + if (isEscaped) { + isEscaped = false; + continue; + } + if (character === "\\") { + isEscaped = true; + continue; + } + if (character === "\"") { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (character === "{") { + depth += 1; + continue; + } + if (character === "}") { + depth -= 1; + if (depth === 0) { + return value.slice(startIndex, index + 1); + } + } + } + + return null; +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function isRecordValue(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/extensions/ai/src/runtime/suggestedOperationRunner.ts b/packages/extensions/ai/src/runtime/suggestedOperationRunner.ts new file mode 100644 index 0000000..0f4f646 --- /dev/null +++ b/packages/extensions/ai/src/runtime/suggestedOperationRunner.ts @@ -0,0 +1,44 @@ +import type { Editor } from "@pen/types"; +import type { DocumentOp } from "@pen/types"; +import type { GenerationState, AISession } from "../types"; +import { applySuggestedAIOperations } from "../suggestions/applySuggestedAIOperations"; +import { AI_SESSION_SUGGESTION_ORIGIN } from "../suggestions/suggestMode"; + +export interface SuggestedAIOperationRunnerOptions { + editor: Editor; + author: string; + model?: string; + getSession: (sessionId: string) => AISession | null; + getActiveGeneration: () => GenerationState | null; +} + +export class SuggestedAIOperationRunner { + constructor(private readonly options: SuggestedAIOperationRunnerOptions) {} + + apply( + operations: DocumentOp[], + sessionId?: string, + options?: { undoGroupId?: string }, + ): void { + const session = + sessionId != null ? this.options.getSession(sessionId) : null; + const activeGeneration = this.options.getActiveGeneration(); + const undoGroupId = + options?.undoGroupId ?? + (session?.surface === "bottom-chat" && + activeGeneration != null && + activeGeneration.sessionId === sessionId + ? activeGeneration.undoGroupId + : undefined); + + applySuggestedAIOperations(this.options.editor, { + operations, + author: this.options.author, + authorType: "ai", + model: this.options.model, + sessionId, + origin: sessionId ? AI_SESSION_SUGGESTION_ORIGIN : "extension", + undoGroupId, + }); + } +} diff --git a/packages/extensions/ai/src/suggestions/persistent.ts b/packages/extensions/ai/src/suggestions/persistent.ts index 6b7b1ee..fac7507 100644 --- a/packages/extensions/ai/src/suggestions/persistent.ts +++ b/packages/extensions/ai/src/suggestions/persistent.ts @@ -1,6 +1,9 @@ import type { BlockHandle, Editor, Position } from "@pen/types"; import type { BlockSuggestionMeta, PersistentSuggestion } from "../types"; +export type BlockSuggestionMetaPayload = BlockSuggestionMeta & + Record; + export type SuggestionCreationOptions = { suggestionId?: string; requestId?: string; @@ -88,18 +91,43 @@ export function readBlockSuggestionMeta( ): BlockSuggestionMeta | null { if (!block) return null; const meta = block.meta("suggestion"); - if (!meta) return null; + return parseBlockSuggestionMeta(meta); +} + +export function serializeBlockSuggestionMeta( + meta: BlockSuggestionMeta, +): BlockSuggestionMetaPayload { + return { + id: meta.id, + action: meta.action, + author: meta.author, + authorType: meta.authorType, + createdAt: meta.createdAt, + model: meta.model, + sessionId: meta.sessionId, + requestId: meta.requestId, + turnId: meta.turnId, + generationId: meta.generationId, + previousState: meta.previousState, + }; +} + +export function parseBlockSuggestionMeta( + meta: unknown, +): BlockSuggestionMeta | null { + if (!meta || typeof meta !== "object") return null; + const record = meta as Record; if ( - typeof meta.id !== "string" || - typeof meta.action !== "string" || - typeof meta.author !== "string" || - typeof meta.authorType !== "string" || - typeof meta.createdAt !== "number" + typeof record.id !== "string" || + typeof record.action !== "string" || + typeof record.author !== "string" || + typeof record.authorType !== "string" || + typeof record.createdAt !== "number" ) { return null; } - const action = meta.action; + const action = record.action; if ( action !== "insert-block" && action !== "delete-block" && @@ -110,22 +138,22 @@ export function readBlockSuggestionMeta( } return { - id: meta.id, + id: record.id, action, - author: meta.author, - authorType: meta.authorType === "ai" ? "ai" : "user", - createdAt: meta.createdAt, - model: typeof meta.model === "string" ? meta.model : undefined, + author: record.author, + authorType: record.authorType === "ai" ? "ai" : "user", + createdAt: record.createdAt, + model: typeof record.model === "string" ? record.model : undefined, sessionId: - typeof meta.sessionId === "string" ? meta.sessionId : undefined, + typeof record.sessionId === "string" ? record.sessionId : undefined, requestId: - typeof meta.requestId === "string" ? meta.requestId : undefined, - turnId: typeof meta.turnId === "string" ? meta.turnId : undefined, + typeof record.requestId === "string" ? record.requestId : undefined, + turnId: typeof record.turnId === "string" ? record.turnId : undefined, generationId: - typeof meta.generationId === "string" - ? meta.generationId + typeof record.generationId === "string" + ? record.generationId : undefined, - previousState: readPreviousState(meta.previousState), + previousState: readPreviousState(record.previousState), }; } diff --git a/packages/extensions/ai/src/suggestions/suggestMode.ts b/packages/extensions/ai/src/suggestions/suggestMode.ts index 9e190a7..4c1e315 100644 --- a/packages/extensions/ai/src/suggestions/suggestMode.ts +++ b/packages/extensions/ai/src/suggestions/suggestMode.ts @@ -2,6 +2,8 @@ import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; import { getOpOriginType } from "@pen/types"; import { createSuggestionMark, + serializeBlockSuggestionMeta, + type BlockSuggestionMetaPayload, type SuggestionCreationOptions, } from "./persistent"; import type { BlockSuggestionMeta, PersistentSuggestion } from "../types"; @@ -383,9 +385,9 @@ function createBlockSuggestionMeta( previousState?: BlockSuggestionMeta["previousState"], sessionId?: string, options: SuggestionCreationOptions = {}, -): Record { +): BlockSuggestionMetaPayload { const resolvedSessionId = options.sessionId ?? sessionId; - return { + const meta: BlockSuggestionMeta = { id: options.suggestionId ?? crypto.randomUUID(), action, author, @@ -398,4 +400,5 @@ function createBlockSuggestionMeta( turnId: options.turnId, generationId: options.generationId, }; + return serializeBlockSuggestionMeta(meta); } diff --git a/packages/extensions/ai/src/typeParts/typesPart1.ts b/packages/extensions/ai/src/typeParts/typesPart1.ts new file mode 100644 index 0000000..3b92886 --- /dev/null +++ b/packages/extensions/ai/src/typeParts/typesPart1.ts @@ -0,0 +1,391 @@ +import type { + Editor, + InlineCompletionController as CoreInlineCompletionController, + InlineCompletionState as CoreInlineCompletionState, + ModelAdapter, + ModelMessage, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + ModelRequestedOperation, + SelectionState, + TextSelection, + ToolRuntime, +} from "@pen/types"; +import type { + AIApplyStrategy, + AIMutationMode, + AIRouteLane, + AIContentFormat, + AIBlockAdapterId, + AIBlockClass, + AIExecutionMode, + AIPlannerMode, + AIQualityMetricId, + AITargetKind, + AITransportKind, + AIWorkingSetViewMode, +} from "../runtime/contracts"; +import type { DocumentMutationPlan } from "../runtime/planTypes"; +import type { FlowPatchAlignmentMetrics } from "../runtime/planExecutor"; +import type { + StructuralReviewItem, + StructuredPreviewTargetState, +} from "../runtime/reviewArtifacts"; +import type { StructuredIntent } from "../runtime/structuredIntent"; +import type { PersistentBlockSuggestion, PersistentSuggestion, BlockSuggestionMeta, AIAwarenessState, AICommandContext, AICommandGuard, AICommandBinding, AIControllerState, AIPromptTarget, AISessionResolution, AIInlineHistoryDirection, AIInlineHistoryController, AIReviewController, AICommandExecutionOptions, AIRequestedOperation, AIController, AgenticLoopOptions, AIWorkingSetEnvelope, AIWorkingSetRetrievedSpan, GenerationDebugState, StructuredGenerationDebugState, FastApplyDebugState, FastApplyFallbackMetrics, AIMutationReceiptStatus, AIMutationReceiptEvidence, AIMutationReceipt } from "./typesPart2"; + +export interface AIExtensionConfig { + model?: ModelAdapter; + suggestMode?: boolean; + commands?: AICommandBinding[]; + maxAgenticSteps?: number; + author?: string; + contentFormat?: AIContentFormatOptions; +} + +export interface AIContentFormatOptions { + blockGeneration?: AIContentFormat; + selectionRewrite?: AIContentFormat; +} + +export type ResolvedEditTarget = + | ModelOperationSelectionTarget + | ModelOperationScopedRangeTarget; + +export interface ResolvedEditProposal { + promptIntent: string; + target: ResolvedEditTarget; +} + +export type AIStatus = + | "idle" + | "reading" + | "thinking" + | "writing" + | "tool-calling"; + +export type AISurface = "inline-edit" | "bottom-chat"; + +export type AISessionStatus = + | "idle" + | "streaming" + | "paused" + | "complete" + | "cancelled" + | "error"; + +export type AISessionTarget = + | { + kind: "selection"; + selection: TextSelection; + blockId: string | null; + } + | { + kind: "block"; + blockId: string; + } + | { + kind: "document"; + }; + +export interface AISessionPrompt { + id: string; + prompt: string; + createdAt: number; + generationId?: string; + operation?: AIRequestedOperation; +} + +export interface AISessionSelectionSnapshot { + anchor: { blockId: string; offset: number }; + focus: { blockId: string; offset: number }; + blockRange: string[]; + isMultiBlock: boolean; +} + +export interface AIContextualPromptRect { + top: number; + left: number; + width: number; + height: number; +} + +export type AIContextualPromptAnchorKind = "text-range" | "block" | "document"; + +export type AIContextualPromptAnchorStatus = "valid" | "shifted" | "invalid"; + +export interface AIContextualPromptAnchor { + kind: AIContextualPromptAnchorKind; + selectionSnapshot?: AISessionSelectionSnapshot; + focusBlockId: string | null; + status: AIContextualPromptAnchorStatus; + lastResolvedRect: AIContextualPromptRect | null; +} + +export interface AIContextualPromptComposerState { + draftPrompt: string; + isOpen: boolean; + isSubmitting: boolean; + canSubmitFollowUp: boolean; + openReason?: "user" | "history"; +} + +export interface AIContextualPromptState { + anchor: AIContextualPromptAnchor; + composer: AIContextualPromptComposerState; +} + +export type AISessionTurnStatus = + | "streaming" + | "review" + | "accepted" + | "rejected" + | "complete" + | "cancelled" + | "error"; + +export interface AISessionTurn { + id: string; + prompt: string; + createdAt: number; + undoGroupId?: string; + generationId?: string; + target: Exclude; + status: AISessionTurnStatus; + suggestionIds: string[]; + reviewItemIds: string[]; + generatedBlockIds: string[]; + operation?: AIRequestedOperation; + structuredPreview?: GenerationStructuredPreviewState | null; + anchor?: AISessionAnchor; + selection?: AISessionSelectionSnapshot; +} + +export interface AISessionMetrics { + firstTokenMs?: number; + totalMs?: number; + toolMs?: number; + streamEventCount: number; + patchCount: number; + fastApply: AISessionFastApplyMetrics; +} + +export interface AISessionFastApplyMetrics { + attemptCount: number; + nativeFastApplyCount: number; + scopedReplacementCount: number; + plainMarkdownCount: number; + failedCount: number; +} + +export interface AISessionAnchor { + blockId?: string; + from?: number; + to?: number; +} + +export interface AISession { + id: string; + surface: AISurface; + status: AISessionStatus; + target: AISessionTarget; + operation?: AIRequestedOperation | null; + contextualPrompt?: AIContextualPromptState; + turns: AISessionTurn[]; + activeTurnId?: string; + promptHistory: AISessionPrompt[]; + generationIds: string[]; + pendingSuggestionIds: string[]; + pendingReviewItemIds: string[]; + createdAt: number; + updatedAt: number; + metrics: AISessionMetrics; + anchor?: AISessionAnchor; +} + +export interface AIInlineHistorySnapshot { + id: string; + sessionId: string | null; + sessions: readonly AISession[]; + activeSessionId: string | null; + documentVersion: number; + kind: "document-coupled" | "ui-local"; +} + +export interface AgenticStep { + index: number; + type: "text" | "tool-call" | "tool-result"; + toolName?: string; + toolCallId?: string; + input?: unknown; + output?: unknown; + status: "pending" | "running" | "complete" | "error"; +} + +export type AIStreamEventType = + | "generation-start" + | "status" + | "text-delta" + | "operation" + | "app-partial" + | "tool-call" + | "tool-output" + | "tool-result" + | "structured-preview" + | "generation-finish"; + +export interface AIStreamEventBase { + type: AIStreamEventType; + generationId: string; + sessionId?: string; + zoneId: string; + blockId: string; + timestamp: number; +} + +export type AIStreamEvent = + | (AIStreamEventBase & { + type: "generation-start"; + prompt: string; + target: GenerationState["target"]; + }) + | (AIStreamEventBase & { + type: "status"; + status: AIStatus; + }) + | (AIStreamEventBase & { + type: "text-delta"; + delta: string; + text: string; + }) + | (AIStreamEventBase & { + type: "app-partial"; + data: unknown; + final: boolean; + }) + | (AIStreamEventBase & { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + }) + | (AIStreamEventBase & { + type: "tool-output"; + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + }) + | (AIStreamEventBase & { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + }) + | (AIStreamEventBase & { + type: "structured-preview"; + preview: GenerationStructuredPreviewState; + patches: readonly StructuredPreviewPatchOperation[]; + }) + | (AIStreamEventBase & { + type: "operation"; + operation: AIRequestedOperation; + phase: "preview" | "final" | "conflict"; + text?: string; + reason?: string; + }) + | (AIStreamEventBase & { + type: "generation-finish"; + status: GenerationState["status"]; + text: string; + }); + +export interface StructuredPreviewPatchOperation { + op: "add" | "remove" | "replace"; + path: string; + value?: unknown; +} + +export interface GenerationStructuredPreviewState { + planState: "drafted" | "validated"; + plan: DocumentMutationPlan; + reviewItems: StructuralReviewItem[]; + targets: StructuredPreviewTargetState[]; +} + +export interface GenerationState { + id: string; + zoneId: string; + blockId: string; + target: "selection" | "block"; + sessionId?: string; + turnId?: string; + surface?: AISurface; + prompt: string; + operation?: AIRequestedOperation | null; + status: "streaming" | "complete" | "cancelled" | "error"; + tokenCount: number; + steps: AgenticStep[]; + undoGroupId: string; + text: string; + commandId?: string; + suggestionIds?: string[]; + route?: AIRouteLane; + mutationMode?: AIMutationMode; + contentFormat?: AIContentFormat; + applyStrategy?: AIApplyStrategy; + planState?: GenerationPlanState; + plan?: DocumentMutationPlan | null; + structuredIntent?: StructuredIntent | null; + reviewItems?: StructuralReviewItem[]; + structuredPreview?: GenerationStructuredPreviewState | null; + targetKind?: GenerationTargetKind; + blockClass?: AIBlockClass; + adapterId?: AIBlockAdapterId; + transportKind?: AITransportKind; + mutationReceipt?: AIMutationReceipt | null; + debug?: GenerationDebugState; +} + +export type GenerationPlanState = + | "none" + | "drafted" + | "validated" + | "rejected"; + +export type GenerationTargetKind = AITargetKind; + +export interface EphemeralSuggestion { + id: string; + blockId: string; + offset: number; + text: string; + type: "inline" | "block"; + blockType?: string; + props?: Record; +} + +export type AIInlineCompletionState = CoreInlineCompletionState; + +export type AIInlineCompletionController = CoreInlineCompletionController; + +export interface PersistentSuggestionBase { + id: string; + author: string; + authorType: "user" | "ai"; + createdAt: number; + model?: string; + sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; + blockId: string; +} + +export interface PersistentTextSuggestion extends PersistentSuggestionBase { + kind: "text"; + action: "insert" | "delete"; + offset: number; + length: number; +} diff --git a/packages/extensions/ai/src/typeParts/typesPart2.ts b/packages/extensions/ai/src/typeParts/typesPart2.ts new file mode 100644 index 0000000..f096590 --- /dev/null +++ b/packages/extensions/ai/src/typeParts/typesPart2.ts @@ -0,0 +1,359 @@ +import type { + Editor, + InlineCompletionController as CoreInlineCompletionController, + InlineCompletionState as CoreInlineCompletionState, + ModelAdapter, + ModelMessage, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + ModelRequestedOperation, + SelectionState, + TextSelection, + ToolRuntime, +} from "@pen/types"; +import type { + AIApplyStrategy, + AIMutationMode, + AIRouteLane, + AIContentFormat, + AIBlockAdapterId, + AIBlockClass, + AIExecutionMode, + AIPlannerMode, + AIQualityMetricId, + AITargetKind, + AITransportKind, + AIWorkingSetViewMode, +} from "../runtime/contracts"; +import type { DocumentMutationPlan } from "../runtime/planTypes"; +import type { FlowPatchAlignmentMetrics } from "../runtime/planExecutor"; +import type { + StructuralReviewItem, + StructuredPreviewTargetState, +} from "../runtime/reviewArtifacts"; +import type { StructuredIntent } from "../runtime/structuredIntent"; +import type { AIExtensionConfig, AIContentFormatOptions, ResolvedEditTarget, ResolvedEditProposal, AIStatus, AISurface, AISessionStatus, AISessionTarget, AISessionPrompt, AISessionSelectionSnapshot, AIContextualPromptRect, AIContextualPromptAnchorKind, AIContextualPromptAnchorStatus, AIContextualPromptAnchor, AIContextualPromptComposerState, AIContextualPromptState, AISessionTurnStatus, AISessionTurn, AISessionMetrics, AISessionFastApplyMetrics, AISessionAnchor, AISession, AIInlineHistorySnapshot, AgenticStep, AIStreamEventType, AIStreamEventBase, AIStreamEvent, StructuredPreviewPatchOperation, GenerationStructuredPreviewState, GenerationState, GenerationPlanState, GenerationTargetKind, EphemeralSuggestion, AIInlineCompletionState, AIInlineCompletionController, PersistentSuggestionBase, PersistentTextSuggestion } from "./typesPart1"; + +export interface PersistentBlockSuggestion extends PersistentSuggestionBase { + kind: "block"; + action: "insert-block" | "delete-block" | "move-block" | "convert-block"; + previousState?: { + type?: string; + position?: import("@pen/types").Position; + props?: Record; + }; +} + +export type PersistentSuggestion = + | PersistentTextSuggestion + | PersistentBlockSuggestion; + +export interface BlockSuggestionMeta { + id: string; + action: "insert-block" | "delete-block" | "move-block" | "convert-block"; + author: string; + authorType: "user" | "ai"; + createdAt: number; + model?: string; + sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; + previousState?: { + type?: string; + position?: import("@pen/types").Position; + props?: Record; + }; +} + +export interface AIAwarenessState { + status: AIStatus; + activeBlockId: string | null; + activeTool?: { name: string; toolCallId: string }; + model: string; + generationZoneId?: string; +} + +export interface AICommandContext { + editor: Editor; + selection: SelectionState; + selectedText: string; + blockType: string | null; + blockId: string | null; +} + +export type AICommandGuard = (ctx: AICommandContext) => boolean; + +export interface AICommandBinding { + id: string; + label: string; + description?: string; + icon?: string; + group?: string; + prompt: string | ((ctx: AICommandContext) => string); + guard?: AICommandGuard; + shortcut?: string; + target?: "selection" | "block"; +} + +export interface AIControllerState { + status: AIStatus; + activeGeneration: GenerationState | null; + sessions: readonly AISession[]; + activeSessionId?: string | null; + suggestMode: boolean; + ephemeralSuggestion: EphemeralSuggestion | null; + commandMenuOpen: boolean; + lastRoute?: AIRouteLane; +} + +export type AIPromptTarget = "auto" | "selection" | "block" | "document"; + +export type AISessionResolution = "accept" | "reject"; + +export type AIInlineHistoryDirection = "undo" | "redo"; + +export interface AIInlineHistoryController { + canUndoInlineHistory(): boolean; + canRedoInlineHistory(): boolean; + canHandleShortcut(direction: AIInlineHistoryDirection): boolean; + handleShortcut(direction: AIInlineHistoryDirection): boolean; + undoInlineHistory(): boolean; + redoInlineHistory(): boolean; +} + +export interface AIReviewController { + getSuggestions(): readonly PersistentSuggestion[]; + acceptSuggestion(id: string): boolean; + rejectSuggestion(id: string): boolean; + acceptAllSuggestions(): void; + rejectAllSuggestions(): void; +} + +export interface AICommandExecutionOptions { + blockId?: string | null; + maxSteps?: number; + target?: AIPromptTarget; + operation?: AIRequestedOperation | null; +} + +export type AIRequestedOperation = ModelRequestedOperation; + +export interface AIController { + getState(): AIControllerState; + subscribe(listener: () => void): () => void; + getSessions(): readonly AISession[]; + getActiveSession(): AISession | null; + subscribeSessions(listener: () => void): () => void; + getStreamEvents(): readonly AIStreamEvent[]; + subscribeStreamEvents(listener: () => void): () => void; + getCommands(): readonly AICommandBinding[]; + getCommandContext(): AICommandContext; + startSession(input: { + surface: AISurface; + target?: "auto" | "selection" | "block" | "document"; + }): AISession; + openContextualPrompt(input?: { + surface?: Extract; + target?: "auto" | "selection" | "block" | "document"; + }): AISession | null; + updateContextualPromptDraft(sessionId: string, draftPrompt: string): void; + setContextualPromptAnchorRect( + sessionId: string, + rect: AIContextualPromptRect | null, + ): void; + runSessionPrompt( + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): Promise; + canReuseSessionPrompt( + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): boolean; + resolveSessionTurn( + sessionId: string, + turnId: string, + resolution: AISessionResolution, + ): boolean; + acceptSessionTurn(sessionId: string, turnId: string): boolean; + rejectSessionTurn(sessionId: string, turnId: string): boolean; + resolveSession(sessionId: string, resolution: AISessionResolution): boolean; + acceptSession(sessionId: string): boolean; + rejectSession(sessionId: string): boolean; + cancelSession(sessionId: string): void; + suspendInlineSession(sessionId: string): void; + resumeInlineSession(sessionId: string): void; + canUndoInlineHistory(): boolean; + canRedoInlineHistory(): boolean; + undoInlineHistory(): boolean; + redoInlineHistory(): boolean; + runCommand(commandId: string, options?: AICommandExecutionOptions): Promise; + runPrompt(prompt: string, options?: AICommandExecutionOptions): Promise; + retryActiveGeneration(): Promise; + acceptActiveGeneration(): boolean; + rejectActiveGeneration(): boolean; + acceptReviewItem(id: string): boolean; + rejectReviewItem(id: string): boolean; + acceptReviewItems(ids: readonly string[]): boolean; + rejectReviewItems(ids: readonly string[]): boolean; + cancelActiveGeneration(): void; + openCommandMenu(): void; + closeCommandMenu(): void; + setSuggestMode(enabled: boolean): void; + showEphemeralSuggestion(suggestion: EphemeralSuggestion): void; + dismissEphemeralSuggestion(): void; + acceptEphemeralSuggestion(): void; + getSuggestions(): readonly PersistentSuggestion[]; + acceptSuggestion(id: string): boolean; + rejectSuggestion(id: string): boolean; + acceptAllSuggestions(): void; + rejectAllSuggestions(): void; +} + +export interface AgenticLoopOptions { + model: ModelAdapter; + editor: Editor; + toolRuntime: ToolRuntime; + prompt: string; + blockId: string; + generationId?: string; + zoneId?: string; + maxSteps?: number; + signal?: AbortSignal; + requestMode?: string; + onStatusChange?: (status: AIAwarenessState["status"]) => void; + onStep?: (step: AgenticStep) => void; + onTextDelta?: (delta: string) => void; + onCompleteText?: (text: string) => void; + onToolCall?: (event: { + toolCallId: string; + toolName: string; + input: unknown; + }) => void; + onToolOutput?: (event: { + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + }) => void; + onToolResult?: (event: { + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + }) => void; + onStructuredData?: (event: { + data: unknown; + final: boolean; + }) => void; + onMessagesChange?: (messages: ModelMessage[]) => void; + onStreamingStart?: (zoneId: string, blockId: string) => void; + onStreamingEnd?: (status: "complete" | "cancelled" | "error") => void; + workingSet?: AIWorkingSetEnvelope | null; + validateWorkingSet?: ( + workingSet: AIWorkingSetEnvelope | null, + ) => { valid: boolean; canRefresh: boolean; reason?: string }; + refreshWorkingSet?: () => Promise; + onDebug?: (debug: GenerationDebugState) => void; +} + +export interface AIWorkingSetEnvelope { + documentVersion: number; + viewMode: AIWorkingSetViewMode; + source: "cursor-context" | "document-summary" | "selection"; + context: unknown; + routeConfidence?: number; + trackedBlockIds: string[]; + blockRevisions: Record; + selectionSignature: string | null; +} + +export interface AIWorkingSetRetrievedSpan { + id: string; + blockIds: string[]; + range: { + startBlockId: string; + endBlockId: string; + }; + blockTypes: string[]; + headingPath: string[]; + preview: string; + markdown: string; + score: number; + rationale: string; + neighbors: { + beforeBlockId: string | null; + afterBlockId: string | null; + }; +} + +export interface GenerationDebugState { + messageAssemblyLatencyMs: number; + firstToolStartMs: number | null; + firstToolResultMs: number | null; + firstVisibleTextMs: number | null; + toolExecutionMs: number; + qualitySignals: Partial>; + routeConfidence?: number; + structured?: StructuredGenerationDebugState; + fastApply?: FastApplyDebugState; +} + +export interface StructuredGenerationDebugState { + plannerMode?: AIPlannerMode; + executionMode?: AIExecutionMode; + targetKind?: AITargetKind; + validationIssueCount?: number; +} + +export interface FastApplyDebugState { + attempted: boolean; + succeeded: boolean; + executionPath?: + | "native-fast-apply" + | "scoped-replacement" + | "plain-markdown"; + contextChars?: number; + diffChars?: number; + confidence?: number; + fallbackReason?: string; + verificationFailureReason?: string; + untouchedBlockMutationCount?: number; + alignment?: FlowPatchAlignmentMetrics; + fallback?: FastApplyFallbackMetrics; +} + +export interface FastApplyFallbackMetrics { + kind: "scoped-replacement" | "plain-markdown"; + opsCount: number; + insertedBlockCount: number; + deletedBlockCount: number; + targetBlockCount?: number; +} + +export type AIMutationReceiptStatus = + | "applied" + | "staged_review" + | "staged_suggestions" + | "noop" + | "invalid" + | "error"; + +export interface AIMutationReceiptEvidence { + commitId: string; + opsCount: number; + affectedBlockIds: string[]; + createdBlockIds: string[]; + adapterId: AIBlockAdapterId; + blockClass: AIBlockClass; + transportKind: AITransportKind; +} + +export interface AIMutationReceipt { + id: string; + status: AIMutationReceiptStatus; + evidence: AIMutationReceiptEvidence; + issues: string[]; +} diff --git a/packages/extensions/ai/src/types.ts b/packages/extensions/ai/src/types.ts index f6ebfa0..ffd4300 100644 --- a/packages/extensions/ai/src/types.ts +++ b/packages/extensions/ai/src/types.ts @@ -1,711 +1,2 @@ -import type { - Editor, - InlineCompletionController as CoreInlineCompletionController, - InlineCompletionState as CoreInlineCompletionState, - ModelAdapter, - ModelMessage, - ModelOperationScopedRangeTarget, - ModelOperationSelectionTarget, - ModelRequestedOperation, - SelectionState, - TextSelection, - ToolRuntime, -} from "@pen/types"; -import type { - AIApplyStrategy, - AIMutationMode, - AIRouteLane, - AIContentFormat, - AIBlockAdapterId, - AIBlockClass, - AIExecutionMode, - AIPlannerMode, - AIQualityMetricId, - AITargetKind, - AITransportKind, - AIWorkingSetViewMode, -} from "./runtime/contracts"; -import type { DocumentMutationPlan } from "./runtime/planTypes"; -import type { FlowPatchAlignmentMetrics } from "./runtime/planExecutor"; -import type { - StructuralReviewItem, - StructuredPreviewTargetState, -} from "./runtime/reviewArtifacts"; -import type { StructuredIntent } from "./runtime/structuredIntent"; - -export interface AIExtensionConfig { - model?: ModelAdapter; - suggestMode?: boolean; - commands?: AICommandBinding[]; - maxAgenticSteps?: number; - author?: string; - contentFormat?: AIContentFormatOptions; -} - -export interface AIContentFormatOptions { - blockGeneration?: AIContentFormat; - selectionRewrite?: AIContentFormat; -} - -export type ResolvedEditTarget = - | ModelOperationSelectionTarget - | ModelOperationScopedRangeTarget; - -export interface ResolvedEditProposal { - promptIntent: string; - target: ResolvedEditTarget; -} - -export type AIStatus = - | "idle" - | "reading" - | "thinking" - | "writing" - | "tool-calling"; - -export type AISurface = "inline-edit" | "bottom-chat"; - -export type AISessionStatus = - | "idle" - | "streaming" - | "paused" - | "complete" - | "cancelled" - | "error"; - -export type AISessionTarget = - | { - kind: "selection"; - selection: TextSelection; - blockId: string | null; - } - | { - kind: "block"; - blockId: string; - } - | { - kind: "document"; - }; - -export interface AISessionPrompt { - id: string; - prompt: string; - createdAt: number; - generationId?: string; - operation?: AIRequestedOperation; -} - -export interface AISessionSelectionSnapshot { - anchor: { blockId: string; offset: number }; - focus: { blockId: string; offset: number }; - blockRange: string[]; - isMultiBlock: boolean; -} - -export interface AIContextualPromptRect { - top: number; - left: number; - width: number; - height: number; -} - -export type AIContextualPromptAnchorKind = "text-range" | "block" | "document"; - -export type AIContextualPromptAnchorStatus = "valid" | "shifted" | "invalid"; - -export interface AIContextualPromptAnchor { - kind: AIContextualPromptAnchorKind; - selectionSnapshot?: AISessionSelectionSnapshot; - focusBlockId: string | null; - status: AIContextualPromptAnchorStatus; - lastResolvedRect: AIContextualPromptRect | null; -} - -export interface AIContextualPromptComposerState { - draftPrompt: string; - isOpen: boolean; - isSubmitting: boolean; - canSubmitFollowUp: boolean; - openReason?: "user" | "history"; -} - -export interface AIContextualPromptState { - anchor: AIContextualPromptAnchor; - composer: AIContextualPromptComposerState; -} - -export type AISessionTurnStatus = - | "streaming" - | "review" - | "accepted" - | "rejected" - | "complete" - | "cancelled" - | "error"; - -export interface AISessionTurn { - id: string; - prompt: string; - createdAt: number; - undoGroupId?: string; - generationId?: string; - target: Exclude; - status: AISessionTurnStatus; - suggestionIds: string[]; - reviewItemIds: string[]; - generatedBlockIds: string[]; - operation?: AIRequestedOperation; - structuredPreview?: GenerationStructuredPreviewState | null; - anchor?: AISessionAnchor; - selection?: AISessionSelectionSnapshot; -} - -export interface AISessionMetrics { - firstTokenMs?: number; - totalMs?: number; - toolMs?: number; - streamEventCount: number; - patchCount: number; - fastApply: AISessionFastApplyMetrics; -} - -export interface AISessionFastApplyMetrics { - attemptCount: number; - nativeFastApplyCount: number; - scopedReplacementCount: number; - plainMarkdownCount: number; - failedCount: number; -} - -export interface AISessionAnchor { - blockId?: string; - from?: number; - to?: number; -} - -export interface AISession { - id: string; - surface: AISurface; - status: AISessionStatus; - target: AISessionTarget; - operation?: AIRequestedOperation | null; - contextualPrompt?: AIContextualPromptState; - turns: AISessionTurn[]; - activeTurnId?: string; - promptHistory: AISessionPrompt[]; - generationIds: string[]; - pendingSuggestionIds: string[]; - pendingReviewItemIds: string[]; - createdAt: number; - updatedAt: number; - metrics: AISessionMetrics; - anchor?: AISessionAnchor; -} - -export interface AIInlineHistorySnapshot { - id: string; - sessionId: string | null; - sessions: readonly AISession[]; - activeSessionId: string | null; - documentVersion: number; - kind: "document-coupled" | "ui-local"; -} - -export interface AgenticStep { - index: number; - type: "text" | "tool-call" | "tool-result"; - toolName?: string; - toolCallId?: string; - input?: unknown; - output?: unknown; - status: "pending" | "running" | "complete" | "error"; -} - -export type AIStreamEventType = - | "generation-start" - | "status" - | "text-delta" - | "operation" - | "app-partial" - | "tool-call" - | "tool-output" - | "tool-result" - | "structured-preview" - | "generation-finish"; - -export interface AIStreamEventBase { - type: AIStreamEventType; - generationId: string; - sessionId?: string; - zoneId: string; - blockId: string; - timestamp: number; -} - -export type AIStreamEvent = - | (AIStreamEventBase & { - type: "generation-start"; - prompt: string; - target: GenerationState["target"]; - }) - | (AIStreamEventBase & { - type: "status"; - status: AIStatus; - }) - | (AIStreamEventBase & { - type: "text-delta"; - delta: string; - text: string; - }) - | (AIStreamEventBase & { - type: "app-partial"; - data: unknown; - final: boolean; - }) - | (AIStreamEventBase & { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - }) - | (AIStreamEventBase & { - type: "tool-output"; - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - }) - | (AIStreamEventBase & { - type: "tool-result"; - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - }) - | (AIStreamEventBase & { - type: "structured-preview"; - preview: GenerationStructuredPreviewState; - patches: readonly StructuredPreviewPatchOperation[]; - }) - | (AIStreamEventBase & { - type: "operation"; - operation: AIRequestedOperation; - phase: "preview" | "final" | "conflict"; - text?: string; - reason?: string; - }) - | (AIStreamEventBase & { - type: "generation-finish"; - status: GenerationState["status"]; - text: string; - }); - -export interface StructuredPreviewPatchOperation { - op: "add" | "remove" | "replace"; - path: string; - value?: unknown; -} - -export interface GenerationStructuredPreviewState { - planState: "drafted" | "validated"; - plan: DocumentMutationPlan; - reviewItems: StructuralReviewItem[]; - targets: StructuredPreviewTargetState[]; -} - -export interface GenerationState { - id: string; - zoneId: string; - blockId: string; - target: "selection" | "block"; - sessionId?: string; - turnId?: string; - surface?: AISurface; - prompt: string; - operation?: AIRequestedOperation | null; - status: "streaming" | "complete" | "cancelled" | "error"; - tokenCount: number; - steps: AgenticStep[]; - undoGroupId: string; - text: string; - commandId?: string; - suggestionIds?: string[]; - route?: AIRouteLane; - mutationMode?: AIMutationMode; - contentFormat?: AIContentFormat; - applyStrategy?: AIApplyStrategy; - planState?: GenerationPlanState; - plan?: DocumentMutationPlan | null; - structuredIntent?: StructuredIntent | null; - reviewItems?: StructuralReviewItem[]; - structuredPreview?: GenerationStructuredPreviewState | null; - targetKind?: GenerationTargetKind; - blockClass?: AIBlockClass; - adapterId?: AIBlockAdapterId; - transportKind?: AITransportKind; - mutationReceipt?: AIMutationReceipt | null; - debug?: GenerationDebugState; -} - -export type GenerationPlanState = - | "none" - | "drafted" - | "validated" - | "rejected"; - -export type GenerationTargetKind = AITargetKind; - -export interface EphemeralSuggestion { - id: string; - blockId: string; - offset: number; - text: string; - type: "inline" | "block"; - blockType?: string; - props?: Record; -} - -export type AIInlineCompletionState = CoreInlineCompletionState; -export type AIInlineCompletionController = CoreInlineCompletionController; - -interface PersistentSuggestionBase { - id: string; - author: string; - authorType: "user" | "ai"; - createdAt: number; - model?: string; - sessionId?: string; - requestId?: string; - turnId?: string; - generationId?: string; - blockId: string; -} - -export interface PersistentTextSuggestion extends PersistentSuggestionBase { - kind: "text"; - action: "insert" | "delete"; - offset: number; - length: number; -} - -export interface PersistentBlockSuggestion extends PersistentSuggestionBase { - kind: "block"; - action: "insert-block" | "delete-block" | "move-block" | "convert-block"; - previousState?: { - type?: string; - position?: import("@pen/types").Position; - props?: Record; - }; -} - -export type PersistentSuggestion = - | PersistentTextSuggestion - | PersistentBlockSuggestion; - -export interface BlockSuggestionMeta { - id: string; - action: "insert-block" | "delete-block" | "move-block" | "convert-block"; - author: string; - authorType: "user" | "ai"; - createdAt: number; - model?: string; - sessionId?: string; - requestId?: string; - turnId?: string; - generationId?: string; - previousState?: { - type?: string; - position?: import("@pen/types").Position; - props?: Record; - }; -} - -export interface AIAwarenessState { - status: AIStatus; - activeBlockId: string | null; - activeTool?: { name: string; toolCallId: string }; - model: string; - generationZoneId?: string; -} - -export interface AICommandContext { - editor: Editor; - selection: SelectionState; - selectedText: string; - blockType: string | null; - blockId: string | null; -} - -export type AICommandGuard = (ctx: AICommandContext) => boolean; - -export interface AICommandBinding { - id: string; - label: string; - description?: string; - icon?: string; - group?: string; - prompt: string | ((ctx: AICommandContext) => string); - guard?: AICommandGuard; - shortcut?: string; - target?: "selection" | "block"; -} - -export interface AIControllerState { - status: AIStatus; - activeGeneration: GenerationState | null; - sessions: readonly AISession[]; - activeSessionId?: string | null; - suggestMode: boolean; - ephemeralSuggestion: EphemeralSuggestion | null; - commandMenuOpen: boolean; - lastRoute?: AIRouteLane; -} - -export type AIPromptTarget = "auto" | "selection" | "block" | "document"; -export type AISessionResolution = "accept" | "reject"; -export type AIInlineHistoryDirection = "undo" | "redo"; - -export interface AIInlineHistoryController { - canUndoInlineHistory(): boolean; - canRedoInlineHistory(): boolean; - canHandleShortcut(direction: AIInlineHistoryDirection): boolean; - handleShortcut(direction: AIInlineHistoryDirection): boolean; - undoInlineHistory(): boolean; - redoInlineHistory(): boolean; -} - -export interface AIReviewController { - getSuggestions(): readonly PersistentSuggestion[]; - acceptSuggestion(id: string): boolean; - rejectSuggestion(id: string): boolean; - acceptAllSuggestions(): void; - rejectAllSuggestions(): void; -} - -export interface AICommandExecutionOptions { - blockId?: string | null; - maxSteps?: number; - target?: AIPromptTarget; - operation?: AIRequestedOperation | null; -} - -export type AIRequestedOperation = ModelRequestedOperation; - -export interface AIController { - getState(): AIControllerState; - subscribe(listener: () => void): () => void; - getSessions(): readonly AISession[]; - getActiveSession(): AISession | null; - subscribeSessions(listener: () => void): () => void; - getStreamEvents(): readonly AIStreamEvent[]; - subscribeStreamEvents(listener: () => void): () => void; - getCommands(): readonly AICommandBinding[]; - getCommandContext(): AICommandContext; - startSession(input: { - surface: AISurface; - target?: "auto" | "selection" | "block" | "document"; - }): AISession; - openContextualPrompt(input?: { - surface?: Extract; - target?: "auto" | "selection" | "block" | "document"; - }): AISession | null; - updateContextualPromptDraft(sessionId: string, draftPrompt: string): void; - setContextualPromptAnchorRect( - sessionId: string, - rect: AIContextualPromptRect | null, - ): void; - runSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): Promise; - canReuseSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): boolean; - resolveSessionTurn( - sessionId: string, - turnId: string, - resolution: AISessionResolution, - ): boolean; - acceptSessionTurn(sessionId: string, turnId: string): boolean; - rejectSessionTurn(sessionId: string, turnId: string): boolean; - resolveSession(sessionId: string, resolution: AISessionResolution): boolean; - acceptSession(sessionId: string): boolean; - rejectSession(sessionId: string): boolean; - cancelSession(sessionId: string): void; - suspendInlineSession(sessionId: string): void; - resumeInlineSession(sessionId: string): void; - canUndoInlineHistory(): boolean; - canRedoInlineHistory(): boolean; - undoInlineHistory(): boolean; - redoInlineHistory(): boolean; - runCommand(commandId: string, options?: AICommandExecutionOptions): Promise; - runPrompt(prompt: string, options?: AICommandExecutionOptions): Promise; - retryActiveGeneration(): Promise; - acceptActiveGeneration(): boolean; - rejectActiveGeneration(): boolean; - acceptReviewItem(id: string): boolean; - rejectReviewItem(id: string): boolean; - acceptReviewItems(ids: readonly string[]): boolean; - rejectReviewItems(ids: readonly string[]): boolean; - cancelActiveGeneration(): void; - openCommandMenu(): void; - closeCommandMenu(): void; - setSuggestMode(enabled: boolean): void; - showEphemeralSuggestion(suggestion: EphemeralSuggestion): void; - dismissEphemeralSuggestion(): void; - acceptEphemeralSuggestion(): void; - getSuggestions(): readonly PersistentSuggestion[]; - acceptSuggestion(id: string): boolean; - rejectSuggestion(id: string): boolean; - acceptAllSuggestions(): void; - rejectAllSuggestions(): void; -} - -export interface AgenticLoopOptions { - model: ModelAdapter; - editor: Editor; - toolRuntime: ToolRuntime; - prompt: string; - blockId: string; - generationId?: string; - zoneId?: string; - maxSteps?: number; - signal?: AbortSignal; - requestMode?: string; - onStatusChange?: (status: AIAwarenessState["status"]) => void; - onStep?: (step: AgenticStep) => void; - onTextDelta?: (delta: string) => void; - onCompleteText?: (text: string) => void; - onToolCall?: (event: { - toolCallId: string; - toolName: string; - input: unknown; - }) => void; - onToolOutput?: (event: { - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - }) => void; - onToolResult?: (event: { - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - }) => void; - onStructuredData?: (event: { - data: unknown; - final: boolean; - }) => void; - onMessagesChange?: (messages: ModelMessage[]) => void; - onStreamingStart?: (zoneId: string, blockId: string) => void; - onStreamingEnd?: (status: "complete" | "cancelled" | "error") => void; - workingSet?: AIWorkingSetEnvelope | null; - validateWorkingSet?: ( - workingSet: AIWorkingSetEnvelope | null, - ) => { valid: boolean; canRefresh: boolean; reason?: string }; - refreshWorkingSet?: () => Promise; - onDebug?: (debug: GenerationDebugState) => void; -} - -export interface AIWorkingSetEnvelope { - documentVersion: number; - viewMode: AIWorkingSetViewMode; - source: "cursor-context" | "document-summary" | "selection"; - context: unknown; - routeConfidence?: number; - trackedBlockIds: string[]; - blockRevisions: Record; - selectionSignature: string | null; -} - -export interface AIWorkingSetRetrievedSpan { - id: string; - blockIds: string[]; - range: { - startBlockId: string; - endBlockId: string; - }; - blockTypes: string[]; - headingPath: string[]; - preview: string; - markdown: string; - score: number; - rationale: string; - neighbors: { - beforeBlockId: string | null; - afterBlockId: string | null; - }; -} - -export interface GenerationDebugState { - messageAssemblyLatencyMs: number; - firstToolStartMs: number | null; - firstToolResultMs: number | null; - firstVisibleTextMs: number | null; - toolExecutionMs: number; - qualitySignals: Partial>; - routeConfidence?: number; - structured?: StructuredGenerationDebugState; - fastApply?: FastApplyDebugState; -} - -export interface StructuredGenerationDebugState { - plannerMode?: AIPlannerMode; - executionMode?: AIExecutionMode; - targetKind?: AITargetKind; - validationIssueCount?: number; -} - -export interface FastApplyDebugState { - attempted: boolean; - succeeded: boolean; - executionPath?: - | "native-fast-apply" - | "scoped-replacement" - | "plain-markdown"; - contextChars?: number; - diffChars?: number; - confidence?: number; - fallbackReason?: string; - verificationFailureReason?: string; - untouchedBlockMutationCount?: number; - alignment?: FlowPatchAlignmentMetrics; - fallback?: FastApplyFallbackMetrics; -} - -export interface FastApplyFallbackMetrics { - kind: "scoped-replacement" | "plain-markdown"; - opsCount: number; - insertedBlockCount: number; - deletedBlockCount: number; - targetBlockCount?: number; -} - -export type AIMutationReceiptStatus = - | "applied" - | "staged_review" - | "staged_suggestions" - | "noop" - | "invalid" - | "error"; - -export interface AIMutationReceiptEvidence { - commitId: string; - opsCount: number; - affectedBlockIds: string[]; - createdBlockIds: string[]; - adapterId: AIBlockAdapterId; - blockClass: AIBlockClass; - transportKind: AITransportKind; -} - -export interface AIMutationReceipt { - id: string; - status: AIMutationReceiptStatus; - evidence: AIMutationReceiptEvidence; - issues: string[]; -} +export type { AIExtensionConfig, AIContentFormatOptions, ResolvedEditTarget, ResolvedEditProposal, AIStatus, AISurface, AISessionStatus, AISessionTarget, AISessionPrompt, AISessionSelectionSnapshot, AIContextualPromptRect, AIContextualPromptAnchorKind, AIContextualPromptAnchorStatus, AIContextualPromptAnchor, AIContextualPromptComposerState, AIContextualPromptState, AISessionTurnStatus, AISessionTurn, AISessionMetrics, AISessionFastApplyMetrics, AISessionAnchor, AISession, AIInlineHistorySnapshot, AgenticStep, AIStreamEventType, AIStreamEventBase, AIStreamEvent, StructuredPreviewPatchOperation, GenerationStructuredPreviewState, GenerationState, GenerationPlanState, GenerationTargetKind, EphemeralSuggestion, AIInlineCompletionState, AIInlineCompletionController, PersistentTextSuggestion } from "./typeParts/typesPart1"; +export type { PersistentBlockSuggestion, PersistentSuggestion, BlockSuggestionMeta, AIAwarenessState, AICommandContext, AICommandGuard, AICommandBinding, AIControllerState, AIPromptTarget, AISessionResolution, AIInlineHistoryDirection, AIInlineHistoryController, AIReviewController, AICommandExecutionOptions, AIRequestedOperation, AIController, AgenticLoopOptions, AIWorkingSetEnvelope, AIWorkingSetRetrievedSpan, GenerationDebugState, StructuredGenerationDebugState, FastApplyDebugState, FastApplyFallbackMetrics, AIMutationReceiptStatus, AIMutationReceiptEvidence, AIMutationReceipt } from "./typeParts/typesPart2"; diff --git a/packages/extensions/database/src/__tests__/engine.part2.test.ts b/packages/extensions/database/src/__tests__/engine.part2.test.ts new file mode 100644 index 0000000..cf6f0a0 --- /dev/null +++ b/packages/extensions/database/src/__tests__/engine.part2.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it, vi } from "vitest"; +import { DatabaseEngine } from "../engine"; +import type { Editor } from "@pen/types"; +import { + isContentEditableColumnType, + DEFAULT_COLUMNS, + type DatabaseRow, + type DatabaseDataProvider, +} from "../types"; + +type DatabaseEngineTestBlock = { + id: string; + type: string; + props: { title: string; dataSource: string }; + tableRowCount(): number; + tableColumnCount(): number; + tableColumns(): Array<{ + id: string; + title: string; + type: string; + width: number; + }>; + tableCell(r: number, c: number): { + id: string; + textContent(): string; + }; +}; + +type DatabaseEngineTestEditor = { + getBlock(id: string): DatabaseEngineTestBlock | null; + selection: null; + apply(): void; + selectCell(): void; + selectCellRange(): void; +}; + +function createMockEditor(rowCount = 3, colCount = 3) { + const columns = [ + { id: "col-0", title: "Name", type: "text", width: 150 }, + { id: "col-1", title: "Age", type: "number", width: 100 }, + { id: "col-2", title: "Done", type: "checkbox", width: 80 }, + ]; + + const cells: Record = { + "0-0": "Alice", + "0-1": "30", + "0-2": "true", + "1-0": "Bob", + "1-1": "25", + "1-2": "false", + "2-0": "Charlie", + "2-1": "35", + "2-2": "true", + }; + + const block = { + id: "block-1", + type: "database", + props: { title: "Test DB", dataSource: "local" }, + tableRowCount: () => rowCount, + tableColumnCount: () => colCount, + tableColumns: () => columns.slice(0, colCount), + tableCell: (r: number, c: number) => { + const key = `${r}-${c}`; + const text = cells[key] ?? ""; + return { + id: key, + textContent: () => text, + }; + }, + } satisfies DatabaseEngineTestBlock; + + const editor = { + getBlock: (id: string) => (id === "block-1" ? block : null), + selection: null, + apply: () => { }, + selectCell: () => { }, + selectCellRange: () => { }, + } satisfies DatabaseEngineTestEditor; + + return { + editor: editor as unknown as Editor, + block, + }; +} + +describe("DatabaseEngine filtering", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("filters text by contains", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { title: "Hello World" } }, + { id: "b", crdtRowIndex: 1, cells: { title: "Hello" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ columnId: "title", operator: "contains", value: "world" }], + }, + [{ id: "title", title: "Title", type: "text", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a"]); + }); + + it("filters checkbox by checked state", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { done: "true" } }, + { id: "b", crdtRowIndex: 1, cells: { done: "false" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ columnId: "done", operator: "is_checked", value: null }], + }, + [{ id: "done", title: "Done", type: "checkbox", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a"]); + }); + + it("filters select values by stored option ids", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ columnId: "status", operator: "is", value: "todo" }], + }, + [ + { + id: "status", + title: "Status", + type: "select", + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ], + ); + expect(filtered.map((row) => row.id)).toEqual(["a"]); + }); + + it("filters dates inclusively by between", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-10T09:00:00.000Z" } }, + { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-15T09:00:00.000Z" } }, + { id: "c", crdtRowIndex: 2, cells: { due: "2024-03-20T09:00:00.000Z" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ + columnId: "due", + operator: "is_between", + value: ["2024-03-10", "2024-03-15"], + }], + }, + [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); + }); + + it("filters dates by relative presets", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-03-15T12:00:00.000Z")); + try { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, + { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-11T09:00:00.000Z" } }, + { id: "c", crdtRowIndex: 2, cells: { due: "2024-02-28T09:00:00.000Z" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ + columnId: "due", + operator: "is_relative", + value: "last_7_days", + }], + }, + [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); + } finally { + vi.useRealTimers(); + } + }); + + it("does not match dates for unknown relative presets", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ + columnId: "due", + operator: "is_relative", + value: "not_a_real_preset", + }], + }, + [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], + ); + expect(filtered).toEqual([]); + }); +}); +describe("DatabaseEngine search", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("searches across visible columns using formatted display values", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { status: "todo", hidden: "Needle" } }, + { id: "b", crdtRowIndex: 1, cells: { status: "done", hidden: "" } }, + ]; + const columns = [ + { + id: "status", + title: "Status", + type: "select" as const, + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ]; + + expect(engine.searchRows(rows, "todo", columns).map((row) => row.id)).toEqual(["a"]); + expect(engine.searchRows(rows, "needle", columns)).toEqual([]); + }); +}); +describe("DatabaseEngine faceting", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("builds select facet buckets from stored option ids", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, + { id: "c", crdtRowIndex: 2, cells: { status: "todo" } }, + ]; + const columns = [ + { + id: "status", + title: "Status", + type: "select" as const, + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ]; + + expect(engine.facetColumnValues(rows, "status", columns)).toEqual([ + { value: "done", label: "Done", count: 1 }, + { value: "todo", label: "Todo", count: 2 }, + ]); + }); + + it("builds multiSelect facet buckets per selected option", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { tags: '["bug","feat"]' } }, + { id: "b", crdtRowIndex: 1, cells: { tags: '["bug"]' } }, + ]; + const columns = [ + { + id: "tags", + title: "Tags", + type: "multiSelect" as const, + columnIndex: 0, + options: [ + { id: "bug", value: "Bug" }, + { id: "feat", value: "Feature" }, + ], + }, + ]; + + expect(engine.facetColumnValues(rows, "tags", columns)).toEqual([ + { value: "bug", label: "Bug", count: 2 }, + { value: "feat", label: "Feature", count: 1 }, + ]); + }); +}); +describe("DatabaseEngine view model helpers", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("includes groupBy in remote queries", () => { + expect( + engine.createQuery({ + view: { + id: "view-1", + type: "table", + groupBy: "status", + sort: [{ columnId: "name", direction: "asc" }], + pageIndex: 2, + pageSize: 25, + }, + }), + ).toEqual({ + groupBy: "status", + sort: [{ columnId: "name", direction: "asc" }], + filter: undefined, + pageIndex: 2, + pageSize: 25, + }); + }); + + it("splits pinned rows out of the paginated middle rows", () => { + const rows: DatabaseRow[] = [ + { id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }, + { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, + { id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }, + { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, + ]; + + expect( + engine.splitPinnedRows(rows, { + top: ["row-c"], + bottom: ["row-a"], + }), + ).toEqual({ + top: [{ id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }], + rows: [ + { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, + { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, + ], + bottom: [{ id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }], + }); + }); + + it("groups rows by formatted display label", () => { + const rows: DatabaseRow[] = [ + { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }, + { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, + { id: "row-d", crdtRowIndex: 3, cells: { status: "" } }, + ]; + const columns = [ + { + id: "status", + title: "Status", + type: "select" as const, + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ]; + + expect(engine.groupRows(rows, "status", columns)).toEqual([ + { + key: "status:Todo", + label: "Todo", + rows: [ + { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, + ], + }, + { + key: "status:Done", + label: "Done", + rows: [{ id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }], + }, + { + key: "status:(empty)", + label: "(empty)", + rows: [{ id: "row-d", crdtRowIndex: 3, cells: { status: "" } }], + }, + ]); + }); +}); +describe("isContentEditableColumnType", () => { + it("returns true for text-like types", () => { + expect(isContentEditableColumnType("text")).toBe(true); + expect(isContentEditableColumnType("number")).toBe(true); + expect(isContentEditableColumnType("url")).toBe(true); + expect(isContentEditableColumnType("email")).toBe(true); + }); + + it("returns false for widget types", () => { + expect(isContentEditableColumnType("checkbox")).toBe(false); + expect(isContentEditableColumnType("select")).toBe(false); + expect(isContentEditableColumnType("multiSelect")).toBe(false); + expect(isContentEditableColumnType("date")).toBe(false); + expect(isContentEditableColumnType("relation")).toBe(false); + expect(isContentEditableColumnType("formula")).toBe(false); + }); + + it("returns true for undefined/null", () => { + expect(isContentEditableColumnType(undefined)).toBe(true); + expect(isContentEditableColumnType("")).toBe(true); + }); +}); +describe("DEFAULT_COLUMNS", () => { + it("has 3 columns with unique IDs", () => { + expect(DEFAULT_COLUMNS).toHaveLength(3); + const ids = DEFAULT_COLUMNS.map((c) => c.id); + expect(new Set(ids).size).toBe(3); + }); + + it("includes Name (text), Tags (select), Done (checkbox)", () => { + expect(DEFAULT_COLUMNS[0].title).toBe("Name"); + expect(DEFAULT_COLUMNS[0].type).toBe("text"); + expect(DEFAULT_COLUMNS[1].title).toBe("Tags"); + expect(DEFAULT_COLUMNS[1].type).toBe("select"); + expect(DEFAULT_COLUMNS[2].title).toBe("Done"); + expect(DEFAULT_COLUMNS[2].type).toBe("checkbox"); + }); +}); +describe("DatabaseEngine data provider", () => { + it("stores and retrieves data provider", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + expect(engine.dataProvider).toBeNull(); + + const provider: DatabaseDataProvider = { + fetch: async () => ({ rows: [], totalRows: 0, pageIndex: 0, pageSize: 50 }), + }; + engine.setDataProvider(provider); + expect(engine.dataProvider).toBe(provider); + }); + + it("detects remote mode from block props", () => { + const { editor, block } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + expect(engine.isRemote).toBe(false); + block.props.dataSource = "remote"; + expect(engine.isRemote).toBe(true); + block.props.dataSource = "hybrid"; + expect(engine.isRemote).toBe(true); + }); +}); diff --git a/packages/extensions/database/src/__tests__/engine.test.ts b/packages/extensions/database/src/__tests__/engine.test.ts index 57e705e..f6f376d 100644 --- a/packages/extensions/database/src/__tests__/engine.test.ts +++ b/packages/extensions/database/src/__tests__/engine.test.ts @@ -128,7 +128,6 @@ describe("DatabaseEngine", () => { expect(engine.getRowId(row)).toBe("row-42"); }); }); - describe("DatabaseEngine value parsing", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -166,7 +165,6 @@ describe("DatabaseEngine value parsing", () => { expect(engine.parseCellValue("2 + 2", "formula")).toBe("2 + 2"); }); }); - describe("DatabaseEngine value serialization", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -191,7 +189,6 @@ describe("DatabaseEngine value serialization", () => { expect(engine.serializeCellValue(null, "multiSelect")).toBe(""); }); }); - describe("DatabaseEngine validation", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -217,7 +214,6 @@ describe("DatabaseEngine validation", () => { expect(engine.validateCellValue("not a url", "url")).toBe("Invalid URL"); }); }); - describe("DatabaseEngine cell display formatting", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -258,7 +254,6 @@ describe("DatabaseEngine cell display formatting", () => { expect(engine.formatCellDisplay("", "date")).toBe(""); }); }); - describe("DatabaseEngine type coercion", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -324,7 +319,6 @@ describe("DatabaseEngine type coercion", () => { expect(engine.coerceValue("", "text", "number")).toBe(""); }); }); - describe("DatabaseEngine sorting", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -360,371 +354,3 @@ describe("DatabaseEngine sorting", () => { expect(sorted.map((row) => row.id)).toEqual(["a", "b"]); }); }); - -describe("DatabaseEngine filtering", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("filters text by contains", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { title: "Hello World" } }, - { id: "b", crdtRowIndex: 1, cells: { title: "Hello" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ columnId: "title", operator: "contains", value: "world" }], - }, - [{ id: "title", title: "Title", type: "text", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a"]); - }); - - it("filters checkbox by checked state", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { done: "true" } }, - { id: "b", crdtRowIndex: 1, cells: { done: "false" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ columnId: "done", operator: "is_checked", value: null }], - }, - [{ id: "done", title: "Done", type: "checkbox", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a"]); - }); - - it("filters select values by stored option ids", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ columnId: "status", operator: "is", value: "todo" }], - }, - [ - { - id: "status", - title: "Status", - type: "select", - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ], - ); - expect(filtered.map((row) => row.id)).toEqual(["a"]); - }); - - it("filters dates inclusively by between", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-10T09:00:00.000Z" } }, - { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-15T09:00:00.000Z" } }, - { id: "c", crdtRowIndex: 2, cells: { due: "2024-03-20T09:00:00.000Z" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ - columnId: "due", - operator: "is_between", - value: ["2024-03-10", "2024-03-15"], - }], - }, - [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); - }); - - it("filters dates by relative presets", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2024-03-15T12:00:00.000Z")); - try { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, - { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-11T09:00:00.000Z" } }, - { id: "c", crdtRowIndex: 2, cells: { due: "2024-02-28T09:00:00.000Z" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ - columnId: "due", - operator: "is_relative", - value: "last_7_days", - }], - }, - [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); - } finally { - vi.useRealTimers(); - } - }); - - it("does not match dates for unknown relative presets", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ - columnId: "due", - operator: "is_relative", - value: "not_a_real_preset", - }], - }, - [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], - ); - expect(filtered).toEqual([]); - }); -}); - -describe("DatabaseEngine search", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("searches across visible columns using formatted display values", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { status: "todo", hidden: "Needle" } }, - { id: "b", crdtRowIndex: 1, cells: { status: "done", hidden: "" } }, - ]; - const columns = [ - { - id: "status", - title: "Status", - type: "select" as const, - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ]; - - expect(engine.searchRows(rows, "todo", columns).map((row) => row.id)).toEqual(["a"]); - expect(engine.searchRows(rows, "needle", columns)).toEqual([]); - }); -}); - -describe("DatabaseEngine faceting", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("builds select facet buckets from stored option ids", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, - { id: "c", crdtRowIndex: 2, cells: { status: "todo" } }, - ]; - const columns = [ - { - id: "status", - title: "Status", - type: "select" as const, - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ]; - - expect(engine.facetColumnValues(rows, "status", columns)).toEqual([ - { value: "done", label: "Done", count: 1 }, - { value: "todo", label: "Todo", count: 2 }, - ]); - }); - - it("builds multiSelect facet buckets per selected option", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { tags: '["bug","feat"]' } }, - { id: "b", crdtRowIndex: 1, cells: { tags: '["bug"]' } }, - ]; - const columns = [ - { - id: "tags", - title: "Tags", - type: "multiSelect" as const, - columnIndex: 0, - options: [ - { id: "bug", value: "Bug" }, - { id: "feat", value: "Feature" }, - ], - }, - ]; - - expect(engine.facetColumnValues(rows, "tags", columns)).toEqual([ - { value: "bug", label: "Bug", count: 2 }, - { value: "feat", label: "Feature", count: 1 }, - ]); - }); -}); - -describe("DatabaseEngine view model helpers", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("includes groupBy in remote queries", () => { - expect( - engine.createQuery({ - view: { - id: "view-1", - type: "table", - groupBy: "status", - sort: [{ columnId: "name", direction: "asc" }], - pageIndex: 2, - pageSize: 25, - }, - }), - ).toEqual({ - groupBy: "status", - sort: [{ columnId: "name", direction: "asc" }], - filter: undefined, - pageIndex: 2, - pageSize: 25, - }); - }); - - it("splits pinned rows out of the paginated middle rows", () => { - const rows: DatabaseRow[] = [ - { id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }, - { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, - { id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }, - { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, - ]; - - expect( - engine.splitPinnedRows(rows, { - top: ["row-c"], - bottom: ["row-a"], - }), - ).toEqual({ - top: [{ id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }], - rows: [ - { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, - { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, - ], - bottom: [{ id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }], - }); - }); - - it("groups rows by formatted display label", () => { - const rows: DatabaseRow[] = [ - { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }, - { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, - { id: "row-d", crdtRowIndex: 3, cells: { status: "" } }, - ]; - const columns = [ - { - id: "status", - title: "Status", - type: "select" as const, - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ]; - - expect(engine.groupRows(rows, "status", columns)).toEqual([ - { - key: "status:Todo", - label: "Todo", - rows: [ - { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, - ], - }, - { - key: "status:Done", - label: "Done", - rows: [{ id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }], - }, - { - key: "status:(empty)", - label: "(empty)", - rows: [{ id: "row-d", crdtRowIndex: 3, cells: { status: "" } }], - }, - ]); - }); -}); - -describe("isContentEditableColumnType", () => { - it("returns true for text-like types", () => { - expect(isContentEditableColumnType("text")).toBe(true); - expect(isContentEditableColumnType("number")).toBe(true); - expect(isContentEditableColumnType("url")).toBe(true); - expect(isContentEditableColumnType("email")).toBe(true); - }); - - it("returns false for widget types", () => { - expect(isContentEditableColumnType("checkbox")).toBe(false); - expect(isContentEditableColumnType("select")).toBe(false); - expect(isContentEditableColumnType("multiSelect")).toBe(false); - expect(isContentEditableColumnType("date")).toBe(false); - expect(isContentEditableColumnType("relation")).toBe(false); - expect(isContentEditableColumnType("formula")).toBe(false); - }); - - it("returns true for undefined/null", () => { - expect(isContentEditableColumnType(undefined)).toBe(true); - expect(isContentEditableColumnType("")).toBe(true); - }); -}); - -describe("DEFAULT_COLUMNS", () => { - it("has 3 columns with unique IDs", () => { - expect(DEFAULT_COLUMNS).toHaveLength(3); - const ids = DEFAULT_COLUMNS.map((c) => c.id); - expect(new Set(ids).size).toBe(3); - }); - - it("includes Name (text), Tags (select), Done (checkbox)", () => { - expect(DEFAULT_COLUMNS[0].title).toBe("Name"); - expect(DEFAULT_COLUMNS[0].type).toBe("text"); - expect(DEFAULT_COLUMNS[1].title).toBe("Tags"); - expect(DEFAULT_COLUMNS[1].type).toBe("select"); - expect(DEFAULT_COLUMNS[2].title).toBe("Done"); - expect(DEFAULT_COLUMNS[2].type).toBe("checkbox"); - }); -}); - -describe("DatabaseEngine data provider", () => { - it("stores and retrieves data provider", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - expect(engine.dataProvider).toBeNull(); - - const provider: DatabaseDataProvider = { - fetch: async () => ({ rows: [], totalRows: 0, pageIndex: 0, pageSize: 50 }), - }; - engine.setDataProvider(provider); - expect(engine.dataProvider).toBe(provider); - }); - - it("detects remote mode from block props", () => { - const { editor, block } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - expect(engine.isRemote).toBe(false); - block.props.dataSource = "remote"; - expect(engine.isRemote).toBe(true); - block.props.dataSource = "hybrid"; - expect(engine.isRemote).toBe(true); - }); -}); diff --git a/packages/extensions/database/src/__tests__/renderer.part10.test.tsx b/packages/extensions/database/src/__tests__/renderer.part10.test.tsx new file mode 100644 index 0000000..c55087d --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part10.test.tsx @@ -0,0 +1,401 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("renders list views as stacked row cards", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-list", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-add-view", + blockId: "db-list", + view: { + id: "view-list", + title: "List view", + type: "list", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: null, + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-list", + viewId: "view-list", + }, + { + type: "database-insert-row", + blockId: "db-list", + rowId: "row-a", + values: { name: "Alpha", tags: "Todo", status: "true" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const listView = container.querySelector( + `[data-block-id="db-list"] .pen-db-list-view`, + ) as HTMLDivElement | null; + expect(listView).not.toBeNull(); + expect(container.querySelector(`[data-block-id="db-list"] table[data-pen-table]`)).toBeNull(); + + const listRow = container.querySelector( + `[data-block-id="db-list"] .pen-db-list-row[data-row="0"]`, + ) as HTMLDivElement | null; + expect(listRow).not.toBeNull(); + expect(listRow?.textContent).toContain("Name"); + expect(listRow?.textContent).toContain("Tags"); + expect(listRow?.textContent).toContain("Done"); + expect(listRow?.textContent).toContain("Alpha"); + + await unmountDatabase(root, container, editor); + }); + + it("renders board views as grouped kanban lanes", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-board", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-board", + columnId: "tags", + patch: { + title: "Status", + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + }, + { + type: "database-add-view", + blockId: "db-board", + view: { + id: "view-board", + title: "Board view", + type: "board", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: "tags", + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-board", + viewId: "view-board", + }, + { + type: "database-insert-row", + blockId: "db-board", + rowId: "row-a", + values: { name: "Alpha", tags: "todo", status: "true" }, + }, + { + type: "database-insert-row", + blockId: "db-board", + rowId: "row-b", + values: { name: "Beta", tags: "done", status: "false" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const boardView = container.querySelector( + `[data-block-id="db-board"] .pen-db-board-view`, + ) as HTMLDivElement | null; + expect(boardView).not.toBeNull(); + + const laneHeaders = Array.from( + container.querySelectorAll(`[data-block-id="db-board"] .pen-db-board-lane-header`), + ) as HTMLDivElement[]; + expect(laneHeaders).toHaveLength(2); + expect(laneHeaders[0]?.textContent).toContain("Todo (1)"); + expect(laneHeaders[1]?.textContent).toContain("Done (1)"); + + const boardCard = container.querySelector( + `[data-block-id="db-board"] .pen-db-board-card[data-row="0"]`, + ) as HTMLDivElement | null; + expect(boardCard).not.toBeNull(); + expect(boardCard?.textContent).toContain("Alpha"); + expect(boardCard?.textContent).toContain("Status"); + + await unmountDatabase(root, container, editor); + }); + + it("renders gallery views as row cards", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-gallery", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-add-view", + blockId: "db-gallery", + view: { + id: "view-gallery", + title: "Gallery view", + type: "gallery", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: null, + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-gallery", + viewId: "view-gallery", + }, + { + type: "database-insert-row", + blockId: "db-gallery", + rowId: "row-a", + values: { name: "Alpha", tags: "Todo", status: "true" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const galleryView = container.querySelector( + `[data-block-id="db-gallery"] .pen-db-gallery-view`, + ) as HTMLDivElement | null; + expect(galleryView).not.toBeNull(); + + const galleryCard = container.querySelector( + `[data-block-id="db-gallery"] .pen-db-gallery-card[data-row="0"]`, + ) as HTMLDivElement | null; + expect(galleryCard).not.toBeNull(); + expect(galleryCard?.textContent).toContain("Name"); + expect(galleryCard?.textContent).toContain("Alpha"); + expect(galleryCard?.textContent).toContain("Tags"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part11.test.tsx b/packages/extensions/database/src/__tests__/renderer.part11.test.tsx new file mode 100644 index 0000000..c2e47c1 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part11.test.tsx @@ -0,0 +1,291 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("renders calendar views from the first date column", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-calendar", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-calendar", + columnId: "tags", + patch: { + title: "Due", + }, + }, + { + type: "database-convert-column", + blockId: "db-calendar", + columnId: "tags", + toType: "date", + }, + { + type: "database-add-view", + blockId: "db-calendar", + view: { + id: "view-calendar", + title: "Calendar view", + type: "calendar", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: null, + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-calendar", + viewId: "view-calendar", + }, + { + type: "database-insert-row", + blockId: "db-calendar", + rowId: "row-a", + values: { name: "Alpha", tags: "2024-03-10T09:00:00.000Z", status: "true" }, + }, + { + type: "database-insert-row", + blockId: "db-calendar", + rowId: "row-b", + values: { name: "Beta", tags: "", status: "false" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + await act(async () => { + await flushAnimationFrames(2); + }); + + const calendarView = container.querySelector( + `[data-block-id="db-calendar"] .pen-db-calendar-view`, + ) as HTMLDivElement | null; + expect(calendarView).not.toBeNull(); + + const calendarCards = Array.from( + container.querySelectorAll( + `[data-block-id="db-calendar"] .pen-db-calendar-view .pen-db-calendar-card`, + ), + ) as HTMLDivElement[]; + expect( + calendarCards.some((card) => card.textContent?.includes("Alpha")), + ).toBe(true); + + const unscheduledSection = container.querySelector( + `[data-block-id="db-calendar"] .pen-db-calendar-unscheduled`, + ) as HTMLDivElement | null; + expect(unscheduledSection).not.toBeNull(); + expect(unscheduledSection?.textContent).toContain("Beta"); + + await unmountDatabase(root, container, editor); + }); +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part2.test.tsx b/packages/extensions/database/src/__tests__/renderer.part2.test.tsx new file mode 100644 index 0000000..4839fae --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part2.test.tsx @@ -0,0 +1,447 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("uses the block default column width for implicit and newly added columns", async () => { + const editor = createEditor({ + }); + + editor.apply([ + { + type: "insert-block", + blockId: "db-custom-width", + blockType: "database", + props: { defaultColumnWidth: 220 }, + position: "last", + }, + { + type: "database-insert-row", + blockId: "db-custom-width", + rowId: "row-1", + values: { + name: "Task", + }, + }, + ]); + + const { container, root } = await renderDatabase(editor); + + const headerCellsBeforeInsert = container.querySelectorAll( + `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, + ); + expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.minWidth).toBe("220px"); + expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.maxWidth).toBe("220px"); + + const addColumnButton = container.querySelector( + ".pen-table-add-column-control", + ) as HTMLButtonElement | null; + expect(addColumnButton).not.toBeNull(); + + await act(async () => { + addColumnButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + const headerCellsAfterInsert = container.querySelectorAll( + `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, + ); + expect(headerCellsAfterInsert).toHaveLength(4); + expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.minWidth).toBe("220px"); + expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.maxWidth).toBe("220px"); + + await unmountDatabase(root, container, editor); + }); + + it("deletes selected rows when delete is pressed from a row checkbox", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-delete-rows", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "update-table-columns", + blockId: "db-delete-rows", + columns: [ + { id: "name", title: "Name", type: "text" }, + { id: "status", title: "Status", type: "checkbox" }, + ], + }, + { + type: "database-insert-row", + blockId: "db-delete-rows", + rowId: "row-alpha", + values: { name: "Alpha", status: "true" }, + }, + { + type: "database-insert-row", + blockId: "db-delete-rows", + rowId: "row-beta", + values: { name: "Beta", status: "false" }, + }, + ]); + + const { container, root } = await renderDatabase(editor); + const tableRows = Array.from( + container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + const alphaRow = tableRows.find((row) => row.textContent?.includes("Alpha")) ?? null; + const rowCheckbox = alphaRow?.querySelector( + `input[type="checkbox"]`, + ) as HTMLInputElement | null; + expect(alphaRow).not.toBeNull(); + expect(rowCheckbox).not.toBeNull(); + + await act(async () => { + rowCheckbox?.focus(); + rowCheckbox?.click(); + await flushAnimationFrames(2); + }); + + const liveAlphaRow = Array.from( + container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), + ).find((row) => row.textContent?.includes("Alpha")) as HTMLTableRowElement | undefined; + const liveRowCheckbox = liveAlphaRow?.querySelector( + `input[type="checkbox"]`, + ) as HTMLInputElement | null; + expect(liveRowCheckbox?.checked).toBe(true); + const blockBeforeDelete = editor.getBlock("db-delete-rows"); + const rowCountBeforeDelete = blockBeforeDelete?.tableRowCount() ?? 0; + expect(rowCountBeforeDelete).toBeGreaterThan(1); + + await act(async () => { + liveRowCheckbox?.focus(); + await flushAnimationFrames(1); + }); + expect(document.activeElement).toBe(liveRowCheckbox); + + await act(async () => { + liveRowCheckbox?.dispatchEvent(createKeyEvent("Delete")); + await flushAnimationFrames(2); + }); + + const block = editor.getBlock("db-delete-rows"); + expect(block?.tableRowCount()).toBe(rowCountBeforeDelete - 1); + const renderedRowsAfterDelete = Array.from( + container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), + ).map((row) => row.textContent ?? ""); + expect(renderedRowsAfterDelete.some((text) => text.includes("Alpha"))).toBe(false); + expect(renderedRowsAfterDelete.some((text) => text.includes("Beta"))).toBe(true); + + await unmountDatabase(root, container, editor); + }); + + it("navigates visible sorted rows instead of storage order", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-visible-rows", + [ + { id: "name", title: "Name", type: "text" }, + { id: "score", title: "Score", type: "number" }, + { id: "status", title: "Status", type: "text" }, + ], + [ + ["Alpha", "2", "keep"], + ["Beta", "1", "skip"], + ["Gamma", "3", "keep"], + ], + ); + updatePrimaryView(editor, "db-nav-visible-rows", { + sort: [{ columnId: "score", direction: "desc" }], + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-nav-visible-rows"]`, + ) as HTMLElement | null; + const bodyCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-visible-rows"] tbody td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const firstBodyCell = bodyCells[0] ?? null; + expect(firstBodyCell?.textContent).toContain("Gamma"); + + await act(async () => { + firstBodyCell?.dispatchEvent(createMouseEvent("mousedown")); + firstBodyCell?.dispatchEvent(createMouseEvent("mouseup")); + databaseBlock?.focus(); + await flushAnimationFrames(2); + }); + + await act(async () => { + document.dispatchEvent(createKeyEvent("ArrowDown")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-visible-rows", + head: { row: 1, col: 0 }, + rowIds: [ + "db-nav-visible-rows-row-2", + "db-nav-visible-rows-row-0", + "db-nav-visible-rows-row-1", + ], + }); + + await unmountDatabase(root, container, editor); + }); + + it("skips hidden columns and respects pinned column order when tabbing", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-columns", + [ + { id: "name", title: "Name", type: "text" }, + { id: "hidden", title: "Hidden", type: "text" }, + { id: "pinned", title: "Pinned", type: "text", pinned: "left" }, + ], + [["Alpha", "secret", "Lead"]], + ); + updatePrimaryView(editor, "db-nav-columns", { + visibleColumnIds: ["name", "pinned"], + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-nav-columns"]`, + ) as HTMLElement | null; + const firstRowCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-columns"] tbody tr[data-row] td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const firstVisibleCell = firstRowCells[0] ?? null; + expect(firstVisibleCell?.textContent).toContain("Lead"); + + await act(async () => { + firstVisibleCell?.dispatchEvent(createMouseEvent("mousedown")); + firstVisibleCell?.dispatchEvent(createMouseEvent("mouseup")); + databaseBlock?.focus(); + await flushAnimationFrames(2); + }); + + await act(async () => { + document.dispatchEvent(createKeyEvent("Tab")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-columns", + head: { row: 0, col: 1 }, + columnIds: ["pinned", "name"], + }); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part3.test.tsx b/packages/extensions/database/src/__tests__/renderer.part3.test.tsx new file mode 100644 index 0000000..a1cc630 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part3.test.tsx @@ -0,0 +1,388 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("moves through pinned and grouped rows in rendered order", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-grouped", + [ + { id: "name", title: "Name", type: "text" }, + { id: "status", title: "Status", type: "text" }, + ], + [ + ["Pinned", "todo"], + ["Alpha", "done"], + ["Beta", "todo"], + ], + ); + updatePrimaryView(editor, "db-nav-grouped", { + groupBy: "status", + rowPinning: { + top: ["db-nav-grouped-row-0"], + bottom: [], + }, + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-nav-grouped"]`, + ) as HTMLElement | null; + const groupedCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-grouped"] tbody td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const firstGroupedCell = groupedCells[0] ?? null; + expect(firstGroupedCell?.textContent).toContain("Pinned"); + + await act(async () => { + firstGroupedCell?.dispatchEvent(createMouseEvent("mousedown")); + firstGroupedCell?.dispatchEvent(createMouseEvent("mouseup")); + databaseBlock?.focus(); + await flushAnimationFrames(2); + }); + + await act(async () => { + document.dispatchEvent(createKeyEvent("ArrowDown")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-grouped", + head: { row: 1, col: 0 }, + rowIds: [ + "db-nav-grouped-row-0", + "db-nav-grouped-row-1", + "db-nav-grouped-row-2", + ], + }); + + await unmountDatabase(root, container, editor); + }); + + it("re-normalizes cell selection to the current page", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-page", + [ + { id: "name", title: "Name", type: "text" }, + ], + [ + ["Alpha"], + ["Beta"], + ], + ); + updatePrimaryView(editor, "db-nav-page", { + pageSize: 1, + pageIndex: 1, + }); + + const { container, root } = await renderDatabase(editor); + const previousPageButton = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-page"] .pen-db-pagination button`, + ), + )[0] as HTMLButtonElement | undefined; + const pageCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-page"] tbody td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const secondPageCell = pageCells[0] ?? null; + expect(secondPageCell?.textContent).toContain("Beta"); + + await act(async () => { + secondPageCell?.dispatchEvent(createMouseEvent("mousedown")); + secondPageCell?.dispatchEvent(createMouseEvent("mouseup")); + await flushAnimationFrames(2); + }); + await act(async () => { + previousPageButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-page", + head: { row: 0, col: 0 }, + rowIds: ["db-nav-page-row-0"], + }); + + await unmountDatabase(root, container, editor); + }); + + it("keeps cmd+a block-scoped for selected databases in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db2", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const databaseBlock = container.querySelector( + `[data-block-id="db2"]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db2"); + databaseBlock?.focus(); + }); + + await act(async () => { + document.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db2"], + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part4.test.tsx b/packages/extensions/database/src/__tests__/renderer.part4.test.tsx new file mode 100644 index 0000000..6d87c7a --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part4.test.tsx @@ -0,0 +1,416 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("falls back to block selection when dragging from a database into text in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db-drag-flow", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-drag-flow"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + const docWithCaretRange = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + }; + const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; + docWithCaretRange.caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); + range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); + return range; + }; + + await act(async () => { + databaseBlock?.dispatchEvent( + createMouseEvent("mousedown", { + detail: 1, + clientX: 10, + clientY: 10, + }), + ); + paragraphInline?.dispatchEvent( + createMouseEvent("mouseup", { + detail: 1, + clientX: 60, + clientY: 40, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-drag-flow", paragraphId], + }); + + docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; + + await unmountDatabase(root, container, editor); + }); + + it("falls back to block selection when dragging from a database into text in structured documents", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-drag-structured", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-drag-structured"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + const docWithCaretRange = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + }; + const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; + docWithCaretRange.caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); + range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); + return range; + }; + + await act(async () => { + databaseBlock?.dispatchEvent( + createMouseEvent("mousedown", { + detail: 1, + clientX: 10, + clientY: 10, + }), + ); + paragraphInline?.dispatchEvent( + createMouseEvent("mouseup", { + detail: 1, + clientX: 60, + clientY: 40, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-drag-structured", paragraphId], + }); + + docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; + + await unmountDatabase(root, container, editor); + }); + + it("falls back to block selection when shift-clicking from a database into text in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db-shift-flow", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-shift-flow"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-shift-flow"); + databaseBlock?.focus(); + paragraphInline?.dispatchEvent( + createMouseEvent("click", { + detail: 1, + shiftKey: true, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-shift-flow", paragraphId], + }); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part5.test.tsx b/packages/extensions/database/src/__tests__/renderer.part5.test.tsx new file mode 100644 index 0000000..0553ac5 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part5.test.tsx @@ -0,0 +1,387 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("falls back to block selection when shift-clicking from a database into text in structured documents", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-shift-structured", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-shift-structured"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-shift-structured"); + databaseBlock?.focus(); + paragraphInline?.dispatchEvent( + createMouseEvent("click", { + detail: 1, + shiftKey: true, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-shift-structured", paragraphId], + }); + + await unmountDatabase(root, container, editor); + }); + + it("keeps block-first cmd+a copy scoped to the selected database when block-first interaction is enabled", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + const clipboardData = createClipboardData(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-copy-structured", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor, { + interactionModel: "block-first", + }); + const databaseBlock = container.querySelector( + `[data-block-id="db-copy-structured"]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-copy-structured"); + databaseBlock?.focus(); + }); + + await act(async () => { + document.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-copy-structured"], + }); + + handleCopy(editor, { clipboardData } as ClipboardEvent); + + const penBlocks = JSON.parse( + clipboardData.getData("application/x-pen-blocks"), + ) as Array<{ type: string }>; + + expect(penBlocks.map((block) => block.type)).toEqual(["database"]); + expect(clipboardData.getData("text/plain")).not.toContain("After"); + + await unmountDatabase(root, container, editor); + }); + + it("keeps cmd+a copy scoped to the selected database in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const clipboardData = createClipboardData(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db-copy-flow", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-copy-flow"]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-copy-flow"); + databaseBlock?.focus(); + }); + + await act(async () => { + document.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-copy-flow"], + }); + + handleCopy(editor, { clipboardData } as ClipboardEvent); + + const penBlocks = JSON.parse( + clipboardData.getData("application/x-pen-blocks"), + ) as Array<{ type: string }>; + + expect(penBlocks.map((block) => block.type)).toEqual(["database"]); + expect(clipboardData.getData("text/plain")).not.toContain("After"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part6.test.tsx b/packages/extensions/database/src/__tests__/renderer.part6.test.tsx new file mode 100644 index 0000000..992c9f4 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part6.test.tsx @@ -0,0 +1,441 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("promotes beforeinput backspace into a selected database that can be deleted", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-backspace", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor); + const fieldEditor = getAttachedFieldEditor(editor); + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + const databaseBlock = container.querySelector( + `[data-block-id="db-backspace"]`, + ) as HTMLElement | null; + + expect(fieldEditor).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + fieldEditor?.activateTextSelection?.(paragraphId, 0, 0); + await flushAnimationFrames(2); + }); + + await act(async () => { + paragraphInline?.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "deleteContentBackward", + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-backspace"], + }); + expect(databaseBlock?.getAttribute("data-selected")).toBe("true"); + expect( + databaseBlock + ?.querySelector("[data-pen-table-frame]") + ?.getAttribute("data-selected"), + ).toBe("true"); + + await act(async () => { + document.dispatchEvent(createKeyEvent("Backspace")); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-backspace")).toBeNull(); + expect(editor.getBlock(paragraphId)).not.toBeNull(); + + await unmountDatabase(root, container, editor); + }); + + it("supports multi-sort via shift-click on column headers", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-sort", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "tags", title: "Priority", type: "number", width: 120 }, + ], + [["A", "2"], ["B", "1"]], + ); + const { container, root } = await renderDatabase(editor); + + const nameHeader = container.querySelector( + `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="0"]`, + ) as HTMLElement | null; + const priorityHeader = container.querySelector( + `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="1"]`, + ) as HTMLElement | null; + expect(nameHeader).not.toBeNull(); + expect(priorityHeader).not.toBeNull(); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "asc" }, + ]); + + await act(async () => { + priorityHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "asc" }, + { columnId: "tags", direction: "asc" }, + ]); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "desc" }, + { columnId: "tags", direction: "asc" }, + ]); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "tags", direction: "asc" }, + ]); + + await unmountDatabase(root, container, editor); + }); + + it("keeps column header controls out of editor selection gestures", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-header-controls", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "tags", title: "Priority", type: "number", width: 120 }, + ], + [["A", "2"], ["B", "1"]], + ); + const { container, root } = await renderDatabase(editor); + + const nameHeader = container.querySelector( + `[data-block-id="db-header-controls"] [data-cell-row="0"][data-cell-col="0"]`, + ) as HTMLElement | null; + const menuButton = container.querySelector( + `[data-block-id="db-header-controls"] .pen-db-col-menu-btn`, + ) as HTMLButtonElement | null; + expect(nameHeader).not.toBeNull(); + expect(menuButton).not.toBeNull(); + expect(editor.selection).toBeNull(); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("mousedown")); + nameHeader?.dispatchEvent(createMouseEvent("mouseup")); + nameHeader?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-header-controls")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "asc" }, + ]); + expect(editor.selection).toBeNull(); + + await act(async () => { + menuButton?.dispatchEvent(createMouseEvent("mousedown")); + menuButton?.dispatchEvent(createMouseEvent("mouseup")); + menuButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const renameInput = container.querySelector( + `[data-block-id="db-header-controls"] .pen-db-col-rename-input`, + ) as HTMLInputElement | null; + expect(renameInput).not.toBeNull(); + + await act(async () => { + renameInput?.dispatchEvent(createMouseEvent("mousedown")); + renameInput?.dispatchEvent(createMouseEvent("mouseup")); + renameInput?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.selection).toBeNull(); + + await unmountDatabase(root, container, editor); + }); + + it("applies sticky left and right pin styles to pinned columns", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-pins", + [ + { id: "name", title: "Name", type: "text", width: 120, pinned: "left" }, + { id: "tags", title: "Status", type: "text", width: 120 }, + { id: "status", title: "Due", type: "text", width: 140, pinned: "right" }, + ], + [["A", "Open", "Soon"]], + ); + const { container, root } = await renderDatabase(editor); + + const leftHeader = container.querySelector( + `[data-block-id="db-pins"] th[data-cell-col="0"]`, + ) as HTMLTableCellElement | null; + const rightHeader = container.querySelector( + `[data-block-id="db-pins"] th[data-cell-col="2"]`, + ) as HTMLTableCellElement | null; + const leftCell = container.querySelector( + `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="0"]`, + ) as HTMLTableCellElement | null; + const rightCell = container.querySelector( + `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="2"]`, + ) as HTMLTableCellElement | null; + + expect(leftHeader?.style.position).toBe("sticky"); + expect(leftHeader?.style.left).toBe("44px"); + expect(rightHeader?.style.position).toBe("sticky"); + expect(rightHeader?.style.right).toBe("0px"); + expect(leftCell?.style.left).toBe("44px"); + expect(rightCell?.style.right).toBe("0px"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part7.test.tsx b/packages/extensions/database/src/__tests__/renderer.part7.test.tsx new file mode 100644 index 0000000..a5d758a --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part7.test.tsx @@ -0,0 +1,395 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("shows facet-backed autocomplete options in the filter panel", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-filter", + [ + { + id: "status", + title: "Status", + type: "select", + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ], + [["todo"], ["done"], ["todo"]], + ); + const { container, root } = await renderDatabase(editor); + + const filterButton = getButtonByText(container, "Filter"); + expect(filterButton).not.toBeNull(); + + await act(async () => { + filterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; + expect(addFilterButton).not.toBeNull(); + + await act(async () => { + addFilterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const datalist = container.querySelector('datalist[id="pen-db-filter-values-0"]'); + const todoOption = datalist?.querySelector('option[value="todo"]') as HTMLOptionElement | null; + const doneOption = datalist?.querySelector('option[value="done"]') as HTMLOptionElement | null; + expect(todoOption?.label).toBe("Todo (2)"); + expect(doneOption?.label).toBe("Done (1)"); + + await unmountDatabase(root, container, editor); + }); + + it("manages the multi-sort stack from the sort panel", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-sort-panel", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "tags", title: "Priority", type: "number", width: 120 }, + { id: "status", title: "Status", type: "text", width: 120 }, + ], + [["A", "2", "Open"], ["B", "1", "Done"]], + ); + const { container, root } = await renderDatabase(editor); + + const sortButton = getButtonByText(container, "Sort"); + expect(sortButton).not.toBeNull(); + + await act(async () => { + sortButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; + expect(addSortButton).not.toBeNull(); + + await act(async () => { + addSortButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const refreshedAddSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; + expect(refreshedAddSortButton).not.toBeNull(); + + await act(async () => { + refreshedAddSortButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const secondColumnSelect = container.querySelector('[data-sort-column="1"]') as HTMLSelectElement | null; + expect(secondColumnSelect).not.toBeNull(); + + await act(async () => { + if (secondColumnSelect) { + secondColumnSelect.value = "tags"; + secondColumnSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(1); + }); + + const refreshedSecondDirectionSelect = container.querySelector( + '[data-sort-direction="1"]', + ) as HTMLSelectElement | null; + expect(refreshedSecondDirectionSelect).not.toBeNull(); + + await act(async () => { + if (refreshedSecondDirectionSelect) { + refreshedSecondDirectionSelect.value = "desc"; + refreshedSecondDirectionSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(1); + }); + + const moveUpButton = container.querySelector('[data-sort-move-up="1"]') as HTMLButtonElement | null; + expect(moveUpButton).not.toBeNull(); + + await act(async () => { + moveUpButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-sort-panel")?.databaseActiveView()?.sort).toEqual([ + { columnId: "tags", direction: "desc" }, + { columnId: "name", direction: "asc" }, + ]); + + await unmountDatabase(root, container, editor); + }); + + it("supports nested filter groups from the filter panel", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-filter-groups", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "status", title: "Status", type: "text", width: 120 }, + ], + [["Alpha", "Open"], ["Beta", "Done"]], + ); + const { container, root } = await renderDatabase(editor); + + const filterButton = getButtonByText(container, "Filter"); + expect(filterButton).not.toBeNull(); + + await act(async () => { + filterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addGroupButton = container.querySelector('[data-filter-add-group="root"]') as HTMLButtonElement | null; + expect(addGroupButton).not.toBeNull(); + + await act(async () => { + addGroupButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const nestedValueInput = container.querySelector('[data-filter-value="0-0"]') as HTMLInputElement | null; + expect(nestedValueInput).not.toBeNull(); + + await act(async () => { + if (nestedValueInput) { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + valueSetter?.call(nestedValueInput, "Alpha"); + nestedValueInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Alpha" })); + } + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-filter-groups")?.databaseActiveView()?.filter).toEqual({ + operator: "and", + conditions: [ + { + operator: "and", + conditions: [ + { columnId: "name", operator: "contains", value: "Alpha" }, + ], + }, + ], + }); + + const renderedRows = Array.from( + container.querySelectorAll(`[data-block-id="db-filter-groups"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + expect(renderedRows).toHaveLength(1); + expect(renderedRows[0]?.textContent).toContain("Alpha"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part8.test.tsx b/packages/extensions/database/src/__tests__/renderer.part8.test.tsx new file mode 100644 index 0000000..5662b7f --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part8.test.tsx @@ -0,0 +1,409 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("filters dates with relative presets from the filter panel", async () => { + const now = new Date(); + const recentDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2, 9, 0, 0); + const oldDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 12, 9, 0, 0); + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-date-filter", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-date-filter", + columnId: "tags", + patch: { + title: "Due", + }, + }, + { + type: "database-convert-column", + blockId: "db-date-filter", + columnId: "tags", + toType: "date", + }, + { + type: "database-insert-row", + blockId: "db-date-filter", + rowId: "row-a", + values: { name: "Alpha", tags: recentDate.toISOString() }, + }, + { + type: "database-insert-row", + blockId: "db-date-filter", + rowId: "row-b", + values: { name: "Beta", tags: oldDate.toISOString() }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const filterButton = getButtonByText(container, "Filter"); + expect(filterButton).not.toBeNull(); + + await act(async () => { + filterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; + expect(addFilterButton).not.toBeNull(); + + await act(async () => { + addFilterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const columnSelect = container.querySelector('[data-filter-column="0"]') as HTMLSelectElement | null; + expect(columnSelect).not.toBeNull(); + + await act(async () => { + if (columnSelect) { + columnSelect.value = "tags"; + columnSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(2); + }); + + const operatorSelect = container.querySelector('[data-filter-operator="0"]') as HTMLSelectElement | null; + expect(operatorSelect).not.toBeNull(); + expect( + Array.from(operatorSelect?.options ?? []).some( + (option) => option.value === "is_relative", + ), + ).toBe(true); + + updatePrimaryView(editor, "db-date-filter", { + filter: { + operator: "and", + conditions: [{ + columnId: "tags", + operator: "is_relative", + value: "last_7_days", + }], + }, + }); + + await act(async () => { + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-date-filter")?.databaseActiveView()?.filter).toEqual({ + operator: "and", + conditions: [{ + columnId: "tags", + operator: "is_relative", + value: "last_7_days", + }], + }); + + await unmountDatabase(root, container, editor); + }); + + it("pins selected rows to the top and bottom through the toolbar", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-row-pins", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-insert-row", + blockId: "db-row-pins", + rowId: "row-a", + values: { name: "Alpha" }, + }, + { + type: "database-insert-row", + blockId: "db-row-pins", + rowId: "row-b", + values: { name: "Beta" }, + }, + { + type: "database-insert-row", + blockId: "db-row-pins", + rowId: "row-c", + values: { name: "Gamma" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const rowCheckboxes = Array.from( + container.querySelectorAll( + `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, + ), + ) as HTMLInputElement[]; + expect(rowCheckboxes).toHaveLength(3); + + await act(async () => { + rowCheckboxes[1]?.click(); + await flushAnimationFrames(1); + }); + + const pinTopButton = getButtonByText(container, "Pin top"); + expect(pinTopButton).not.toBeNull(); + + await act(async () => { + pinTopButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ + top: ["row-b"], + }); + + let renderedRows = Array.from( + container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + expect(renderedRows[0]?.getAttribute("data-row-section")).toBe("top"); + expect(renderedRows[0]?.textContent).toContain("Beta"); + + const refreshedRowCheckboxes = Array.from( + container.querySelectorAll( + `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, + ), + ) as HTMLInputElement[]; + + await act(async () => { + refreshedRowCheckboxes[0]?.click(); + await flushAnimationFrames(1); + }); + + await act(async () => { + refreshedRowCheckboxes[2]?.click(); + await flushAnimationFrames(1); + }); + + const pinBottomButton = getButtonByText(container, "Pin bottom"); + expect(pinBottomButton).not.toBeNull(); + + await act(async () => { + pinBottomButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ + top: ["row-b"], + bottom: ["row-c"], + }); + + renderedRows = Array.from( + container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + expect(renderedRows.at(-1)?.getAttribute("data-row-section")).toBe("bottom"); + expect(renderedRows.at(-1)?.textContent).toContain("Gamma"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part9.test.tsx b/packages/extensions/database/src/__tests__/renderer.part9.test.tsx new file mode 100644 index 0000000..04b34a8 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part9.test.tsx @@ -0,0 +1,425 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("refreshes the open column menu after adding a select option", async () => { + const editor = createEditor({ + }); + + function OptionMutationHarness() { + const db = useDatabaseController({ blockId: "db-option-menu" }); + const statusColumn = db.columnSchema.find((entry) => entry.id === "status"); + return ( + <> + + { }} + onRename={(nextTitle) => db.renameColumn("status", nextTitle)} + onChangeType={(nextType) => db.changeColumnType("status", nextType)} + onDelete={() => db.deleteColumn("status")} + onToggleVisibility={() => db.toggleColumnVisibility("status")} + onChangePin={(nextPinned) => db.changeColumnPin("status", nextPinned)} + onAddOption={(value, color) => db.addOption("status", value, color)} + onRenameOption={(optionId, value) => db.renameOption("status", optionId, value)} + onRecolorOption={(optionId, color) => db.recolorOption("status", optionId, color)} + onRemoveOption={(optionId) => db.removeOption("status", optionId)} + onMoveOption={(optionId, direction) => db.moveOption("status", optionId, direction)} + /> + + ); + } + + seedDatabase( + editor, + "db-option-menu", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "status", title: "Status", type: "select", width: 140, options: [] }, + ], + [["Alpha", ""]], + ); + const { container, root } = await renderDatabase( + editor, + { children: }, + ); + + let optionRows = Array.from( + container.querySelectorAll(`.pen-db-col-option-row input`), + ) as HTMLInputElement[]; + expect(optionRows).toHaveLength(0); + + const addOptionButton = getButtonByText(container, "Add test option"); + expect(addOptionButton).not.toBeNull(); + + await act(async () => { + addOptionButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-option-menu")?.tableColumns()[1]?.options).toEqual([ + expect.objectContaining({ + value: "Blocked", + color: "gray", + }), + ]); + + optionRows = Array.from( + container.querySelectorAll(`.pen-db-col-option-row input`), + ) as HTMLInputElement[]; + expect(optionRows).toHaveLength(1); + expect(optionRows[0]?.value).toBe("Blocked"); + + await unmountDatabase(root, container, editor); + }); + + it("renders grouped sections from the group panel", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-group", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-group", + columnId: "tags", + patch: { + title: "Status", + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + }, + { + type: "database-insert-row", + blockId: "db-group", + rowId: "row-a", + values: { name: "Alpha", tags: "todo" }, + }, + { + type: "database-insert-row", + blockId: "db-group", + rowId: "row-b", + values: { name: "Beta", tags: "done" }, + }, + { + type: "database-insert-row", + blockId: "db-group", + rowId: "row-c", + values: { name: "Gamma", tags: "todo" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const groupButton = getButtonByText(container, "Group"); + expect(groupButton).not.toBeNull(); + + await act(async () => { + groupButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const groupSelect = container.querySelector(".pen-db-col-vis-panel select") as HTMLSelectElement | null; + expect(groupSelect).not.toBeNull(); + + await act(async () => { + if (groupSelect) { + groupSelect.value = "tags"; + groupSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-group")?.databaseActiveView()?.groupBy).toBe("tags"); + + const groupRows = Array.from( + container.querySelectorAll(`[data-block-id="db-group"] .pen-db-group-row`), + ) as HTMLTableRowElement[]; + expect(groupRows).toHaveLength(2); + expect(groupRows[0]?.textContent).toContain("Todo (2)"); + expect(groupRows[1]?.textContent).toContain("Done (1)"); + + await unmountDatabase(root, container, editor); + }); + + it("adds switches and removes database views from the title bar", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-views", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "status", title: "Status", type: "text", width: 120 }, + ], + [["Alpha", "Open"], ["Beta", "Done"]], + ); + const primaryViewId = editor.getBlock("db-views")?.databasePrimaryViewId() ?? ""; + const { container, root } = await renderDatabase(editor); + + const addViewButton = getButtonByText(container, "+ View"); + expect(addViewButton).not.toBeNull(); + + await act(async () => { + addViewButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addListViewButton = getButtonByText(container, "New list view"); + const addBoardViewButton = getButtonByText(container, "New board view"); + const addCalendarViewButton = getButtonByText(container, "New calendar view"); + const addGalleryViewButton = getButtonByText(container, "New gallery view"); + expect(addListViewButton).not.toBeNull(); + expect(addBoardViewButton).not.toBeNull(); + expect(addCalendarViewButton).not.toBeNull(); + expect(addGalleryViewButton).not.toBeNull(); + + await act(async () => { + addListViewButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const blockAfterAdd = editor.getBlock("db-views"); + const listView = blockAfterAdd?.databaseViews().find((view) => view.type === "list"); + expect(listView).toBeDefined(); + expect(blockAfterAdd?.databaseViews()).toHaveLength(2); + expect(blockAfterAdd?.databaseActiveView()?.id).toBe(listView?.id); + expect(container.querySelector(`[data-block-id="db-views"] .pen-db-list-view`)).not.toBeNull(); + + const tableTab = container.querySelector( + `[data-block-id="db-views"] [data-view-id="${primaryViewId}"]`, + ) as HTMLButtonElement | null; + expect(tableTab).not.toBeNull(); + + await act(async () => { + tableTab?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-views")?.databaseActiveView()?.id).toBe(primaryViewId); + expect(container.querySelector(`[data-block-id="db-views"] table[data-pen-table]`)).not.toBeNull(); + + const removeListViewButton = container.querySelector( + `[data-block-id="db-views"] [data-remove-view-id="${listView?.id ?? ""}"]`, + ) as HTMLButtonElement | null; + expect(removeListViewButton).not.toBeNull(); + + await act(async () => { + removeListViewButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-views")?.databaseViews()).toHaveLength(1); + expect(editor.getBlock("db-views")?.databasePrimaryViewId()).toBe(primaryViewId); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.test.tsx b/packages/extensions/database/src/__tests__/renderer.test.tsx index c1a2418..6c9c1bd 100644 --- a/packages/extensions/database/src/__tests__/renderer.test.tsx +++ b/packages/extensions/database/src/__tests__/renderer.test.tsx @@ -432,1964 +432,4 @@ describe("@pen/database renderer", () => { await unmountDatabase(root, container, editor); }); - it("uses the block default column width for implicit and newly added columns", async () => { - const editor = createEditor({ - }); - - editor.apply([ - { - type: "insert-block", - blockId: "db-custom-width", - blockType: "database", - props: { defaultColumnWidth: 220 }, - position: "last", - }, - { - type: "database-insert-row", - blockId: "db-custom-width", - rowId: "row-1", - values: { - name: "Task", - }, - }, - ]); - - const { container, root } = await renderDatabase(editor); - - const headerCellsBeforeInsert = container.querySelectorAll( - `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, - ); - expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.minWidth).toBe("220px"); - expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.maxWidth).toBe("220px"); - - const addColumnButton = container.querySelector( - ".pen-table-add-column-control", - ) as HTMLButtonElement | null; - expect(addColumnButton).not.toBeNull(); - - await act(async () => { - addColumnButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - const headerCellsAfterInsert = container.querySelectorAll( - `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, - ); - expect(headerCellsAfterInsert).toHaveLength(4); - expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.minWidth).toBe("220px"); - expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.maxWidth).toBe("220px"); - - await unmountDatabase(root, container, editor); - }); - - it("deletes selected rows when delete is pressed from a row checkbox", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-delete-rows", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "update-table-columns", - blockId: "db-delete-rows", - columns: [ - { id: "name", title: "Name", type: "text" }, - { id: "status", title: "Status", type: "checkbox" }, - ], - }, - { - type: "database-insert-row", - blockId: "db-delete-rows", - rowId: "row-alpha", - values: { name: "Alpha", status: "true" }, - }, - { - type: "database-insert-row", - blockId: "db-delete-rows", - rowId: "row-beta", - values: { name: "Beta", status: "false" }, - }, - ]); - - const { container, root } = await renderDatabase(editor); - const tableRows = Array.from( - container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - const alphaRow = tableRows.find((row) => row.textContent?.includes("Alpha")) ?? null; - const rowCheckbox = alphaRow?.querySelector( - `input[type="checkbox"]`, - ) as HTMLInputElement | null; - expect(alphaRow).not.toBeNull(); - expect(rowCheckbox).not.toBeNull(); - - await act(async () => { - rowCheckbox?.focus(); - rowCheckbox?.click(); - await flushAnimationFrames(2); - }); - - const liveAlphaRow = Array.from( - container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), - ).find((row) => row.textContent?.includes("Alpha")) as HTMLTableRowElement | undefined; - const liveRowCheckbox = liveAlphaRow?.querySelector( - `input[type="checkbox"]`, - ) as HTMLInputElement | null; - expect(liveRowCheckbox?.checked).toBe(true); - const blockBeforeDelete = editor.getBlock("db-delete-rows"); - const rowCountBeforeDelete = blockBeforeDelete?.tableRowCount() ?? 0; - expect(rowCountBeforeDelete).toBeGreaterThan(1); - - await act(async () => { - liveRowCheckbox?.focus(); - await flushAnimationFrames(1); - }); - expect(document.activeElement).toBe(liveRowCheckbox); - - await act(async () => { - liveRowCheckbox?.dispatchEvent(createKeyEvent("Delete")); - await flushAnimationFrames(2); - }); - - const block = editor.getBlock("db-delete-rows"); - expect(block?.tableRowCount()).toBe(rowCountBeforeDelete - 1); - const renderedRowsAfterDelete = Array.from( - container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), - ).map((row) => row.textContent ?? ""); - expect(renderedRowsAfterDelete.some((text) => text.includes("Alpha"))).toBe(false); - expect(renderedRowsAfterDelete.some((text) => text.includes("Beta"))).toBe(true); - - await unmountDatabase(root, container, editor); - }); - - it("navigates visible sorted rows instead of storage order", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-visible-rows", - [ - { id: "name", title: "Name", type: "text" }, - { id: "score", title: "Score", type: "number" }, - { id: "status", title: "Status", type: "text" }, - ], - [ - ["Alpha", "2", "keep"], - ["Beta", "1", "skip"], - ["Gamma", "3", "keep"], - ], - ); - updatePrimaryView(editor, "db-nav-visible-rows", { - sort: [{ columnId: "score", direction: "desc" }], - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-nav-visible-rows"]`, - ) as HTMLElement | null; - const bodyCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-visible-rows"] tbody td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const firstBodyCell = bodyCells[0] ?? null; - expect(firstBodyCell?.textContent).toContain("Gamma"); - - await act(async () => { - firstBodyCell?.dispatchEvent(createMouseEvent("mousedown")); - firstBodyCell?.dispatchEvent(createMouseEvent("mouseup")); - databaseBlock?.focus(); - await flushAnimationFrames(2); - }); - - await act(async () => { - document.dispatchEvent(createKeyEvent("ArrowDown")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-visible-rows", - head: { row: 1, col: 0 }, - rowIds: [ - "db-nav-visible-rows-row-2", - "db-nav-visible-rows-row-0", - "db-nav-visible-rows-row-1", - ], - }); - - await unmountDatabase(root, container, editor); - }); - - it("skips hidden columns and respects pinned column order when tabbing", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-columns", - [ - { id: "name", title: "Name", type: "text" }, - { id: "hidden", title: "Hidden", type: "text" }, - { id: "pinned", title: "Pinned", type: "text", pinned: "left" }, - ], - [["Alpha", "secret", "Lead"]], - ); - updatePrimaryView(editor, "db-nav-columns", { - visibleColumnIds: ["name", "pinned"], - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-nav-columns"]`, - ) as HTMLElement | null; - const firstRowCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-columns"] tbody tr[data-row] td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const firstVisibleCell = firstRowCells[0] ?? null; - expect(firstVisibleCell?.textContent).toContain("Lead"); - - await act(async () => { - firstVisibleCell?.dispatchEvent(createMouseEvent("mousedown")); - firstVisibleCell?.dispatchEvent(createMouseEvent("mouseup")); - databaseBlock?.focus(); - await flushAnimationFrames(2); - }); - - await act(async () => { - document.dispatchEvent(createKeyEvent("Tab")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-columns", - head: { row: 0, col: 1 }, - columnIds: ["pinned", "name"], - }); - - await unmountDatabase(root, container, editor); - }); - - it("moves through pinned and grouped rows in rendered order", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-grouped", - [ - { id: "name", title: "Name", type: "text" }, - { id: "status", title: "Status", type: "text" }, - ], - [ - ["Pinned", "todo"], - ["Alpha", "done"], - ["Beta", "todo"], - ], - ); - updatePrimaryView(editor, "db-nav-grouped", { - groupBy: "status", - rowPinning: { - top: ["db-nav-grouped-row-0"], - bottom: [], - }, - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-nav-grouped"]`, - ) as HTMLElement | null; - const groupedCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-grouped"] tbody td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const firstGroupedCell = groupedCells[0] ?? null; - expect(firstGroupedCell?.textContent).toContain("Pinned"); - - await act(async () => { - firstGroupedCell?.dispatchEvent(createMouseEvent("mousedown")); - firstGroupedCell?.dispatchEvent(createMouseEvent("mouseup")); - databaseBlock?.focus(); - await flushAnimationFrames(2); - }); - - await act(async () => { - document.dispatchEvent(createKeyEvent("ArrowDown")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-grouped", - head: { row: 1, col: 0 }, - rowIds: [ - "db-nav-grouped-row-0", - "db-nav-grouped-row-1", - "db-nav-grouped-row-2", - ], - }); - - await unmountDatabase(root, container, editor); - }); - - it("re-normalizes cell selection to the current page", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-page", - [ - { id: "name", title: "Name", type: "text" }, - ], - [ - ["Alpha"], - ["Beta"], - ], - ); - updatePrimaryView(editor, "db-nav-page", { - pageSize: 1, - pageIndex: 1, - }); - - const { container, root } = await renderDatabase(editor); - const previousPageButton = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-page"] .pen-db-pagination button`, - ), - )[0] as HTMLButtonElement | undefined; - const pageCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-page"] tbody td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const secondPageCell = pageCells[0] ?? null; - expect(secondPageCell?.textContent).toContain("Beta"); - - await act(async () => { - secondPageCell?.dispatchEvent(createMouseEvent("mousedown")); - secondPageCell?.dispatchEvent(createMouseEvent("mouseup")); - await flushAnimationFrames(2); - }); - await act(async () => { - previousPageButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-page", - head: { row: 0, col: 0 }, - rowIds: ["db-nav-page-row-0"], - }); - - await unmountDatabase(root, container, editor); - }); - - it("keeps cmd+a block-scoped for selected databases in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db2", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render( - - - , - ); - }); - - const databaseBlock = container.querySelector( - `[data-block-id="db2"]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db2"); - databaseBlock?.focus(); - }); - - await act(async () => { - document.dispatchEvent(createSelectAllEvent()); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db2"], - }); - - await act(async () => { - root.unmount(); - }); - container.remove(); - editor.destroy(); - }); - - it("falls back to block selection when dragging from a database into text in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db-drag-flow", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-drag-flow"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - const docWithCaretRange = document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - }; - const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; - docWithCaretRange.caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); - range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); - return range; - }; - - await act(async () => { - databaseBlock?.dispatchEvent( - createMouseEvent("mousedown", { - detail: 1, - clientX: 10, - clientY: 10, - }), - ); - paragraphInline?.dispatchEvent( - createMouseEvent("mouseup", { - detail: 1, - clientX: 60, - clientY: 40, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-drag-flow", paragraphId], - }); - - docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; - - await unmountDatabase(root, container, editor); - }); - - it("falls back to block selection when dragging from a database into text in structured documents", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-drag-structured", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-drag-structured"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - const docWithCaretRange = document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - }; - const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; - docWithCaretRange.caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); - range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); - return range; - }; - - await act(async () => { - databaseBlock?.dispatchEvent( - createMouseEvent("mousedown", { - detail: 1, - clientX: 10, - clientY: 10, - }), - ); - paragraphInline?.dispatchEvent( - createMouseEvent("mouseup", { - detail: 1, - clientX: 60, - clientY: 40, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-drag-structured", paragraphId], - }); - - docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; - - await unmountDatabase(root, container, editor); - }); - - it("falls back to block selection when shift-clicking from a database into text in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db-shift-flow", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-shift-flow"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-shift-flow"); - databaseBlock?.focus(); - paragraphInline?.dispatchEvent( - createMouseEvent("click", { - detail: 1, - shiftKey: true, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-shift-flow", paragraphId], - }); - - await unmountDatabase(root, container, editor); - }); - - it("falls back to block selection when shift-clicking from a database into text in structured documents", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-shift-structured", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-shift-structured"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-shift-structured"); - databaseBlock?.focus(); - paragraphInline?.dispatchEvent( - createMouseEvent("click", { - detail: 1, - shiftKey: true, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-shift-structured", paragraphId], - }); - - await unmountDatabase(root, container, editor); - }); - - it("keeps block-first cmd+a copy scoped to the selected database when block-first interaction is enabled", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - const clipboardData = createClipboardData(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-copy-structured", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor, { - interactionModel: "block-first", - }); - const databaseBlock = container.querySelector( - `[data-block-id="db-copy-structured"]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-copy-structured"); - databaseBlock?.focus(); - }); - - await act(async () => { - document.dispatchEvent(createSelectAllEvent()); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-copy-structured"], - }); - - handleCopy(editor, { clipboardData } as ClipboardEvent); - - const penBlocks = JSON.parse( - clipboardData.getData("application/x-pen-blocks"), - ) as Array<{ type: string }>; - - expect(penBlocks.map((block) => block.type)).toEqual(["database"]); - expect(clipboardData.getData("text/plain")).not.toContain("After"); - - await unmountDatabase(root, container, editor); - }); - - it("keeps cmd+a copy scoped to the selected database in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const clipboardData = createClipboardData(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db-copy-flow", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-copy-flow"]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-copy-flow"); - databaseBlock?.focus(); - }); - - await act(async () => { - document.dispatchEvent(createSelectAllEvent()); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-copy-flow"], - }); - - handleCopy(editor, { clipboardData } as ClipboardEvent); - - const penBlocks = JSON.parse( - clipboardData.getData("application/x-pen-blocks"), - ) as Array<{ type: string }>; - - expect(penBlocks.map((block) => block.type)).toEqual(["database"]); - expect(clipboardData.getData("text/plain")).not.toContain("After"); - - await unmountDatabase(root, container, editor); - }); - - it("promotes beforeinput backspace into a selected database that can be deleted", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-backspace", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor); - const fieldEditor = getAttachedFieldEditor(editor); - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - const databaseBlock = container.querySelector( - `[data-block-id="db-backspace"]`, - ) as HTMLElement | null; - - expect(fieldEditor).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - fieldEditor?.activateTextSelection?.(paragraphId, 0, 0); - await flushAnimationFrames(2); - }); - - await act(async () => { - paragraphInline?.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: true, - inputType: "deleteContentBackward", - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-backspace"], - }); - expect(databaseBlock?.getAttribute("data-selected")).toBe("true"); - expect( - databaseBlock - ?.querySelector("[data-pen-table-frame]") - ?.getAttribute("data-selected"), - ).toBe("true"); - - await act(async () => { - document.dispatchEvent(createKeyEvent("Backspace")); - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-backspace")).toBeNull(); - expect(editor.getBlock(paragraphId)).not.toBeNull(); - - await unmountDatabase(root, container, editor); - }); - - it("supports multi-sort via shift-click on column headers", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-sort", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "tags", title: "Priority", type: "number", width: 120 }, - ], - [["A", "2"], ["B", "1"]], - ); - const { container, root } = await renderDatabase(editor); - - const nameHeader = container.querySelector( - `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="0"]`, - ) as HTMLElement | null; - const priorityHeader = container.querySelector( - `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="1"]`, - ) as HTMLElement | null; - expect(nameHeader).not.toBeNull(); - expect(priorityHeader).not.toBeNull(); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "asc" }, - ]); - - await act(async () => { - priorityHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "asc" }, - { columnId: "tags", direction: "asc" }, - ]); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "desc" }, - { columnId: "tags", direction: "asc" }, - ]); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "tags", direction: "asc" }, - ]); - - await unmountDatabase(root, container, editor); - }); - - it("keeps column header controls out of editor selection gestures", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-header-controls", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "tags", title: "Priority", type: "number", width: 120 }, - ], - [["A", "2"], ["B", "1"]], - ); - const { container, root } = await renderDatabase(editor); - - const nameHeader = container.querySelector( - `[data-block-id="db-header-controls"] [data-cell-row="0"][data-cell-col="0"]`, - ) as HTMLElement | null; - const menuButton = container.querySelector( - `[data-block-id="db-header-controls"] .pen-db-col-menu-btn`, - ) as HTMLButtonElement | null; - expect(nameHeader).not.toBeNull(); - expect(menuButton).not.toBeNull(); - expect(editor.selection).toBeNull(); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("mousedown")); - nameHeader?.dispatchEvent(createMouseEvent("mouseup")); - nameHeader?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-header-controls")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "asc" }, - ]); - expect(editor.selection).toBeNull(); - - await act(async () => { - menuButton?.dispatchEvent(createMouseEvent("mousedown")); - menuButton?.dispatchEvent(createMouseEvent("mouseup")); - menuButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const renameInput = container.querySelector( - `[data-block-id="db-header-controls"] .pen-db-col-rename-input`, - ) as HTMLInputElement | null; - expect(renameInput).not.toBeNull(); - - await act(async () => { - renameInput?.dispatchEvent(createMouseEvent("mousedown")); - renameInput?.dispatchEvent(createMouseEvent("mouseup")); - renameInput?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.selection).toBeNull(); - - await unmountDatabase(root, container, editor); - }); - - it("applies sticky left and right pin styles to pinned columns", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-pins", - [ - { id: "name", title: "Name", type: "text", width: 120, pinned: "left" }, - { id: "tags", title: "Status", type: "text", width: 120 }, - { id: "status", title: "Due", type: "text", width: 140, pinned: "right" }, - ], - [["A", "Open", "Soon"]], - ); - const { container, root } = await renderDatabase(editor); - - const leftHeader = container.querySelector( - `[data-block-id="db-pins"] th[data-cell-col="0"]`, - ) as HTMLTableCellElement | null; - const rightHeader = container.querySelector( - `[data-block-id="db-pins"] th[data-cell-col="2"]`, - ) as HTMLTableCellElement | null; - const leftCell = container.querySelector( - `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="0"]`, - ) as HTMLTableCellElement | null; - const rightCell = container.querySelector( - `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="2"]`, - ) as HTMLTableCellElement | null; - - expect(leftHeader?.style.position).toBe("sticky"); - expect(leftHeader?.style.left).toBe("44px"); - expect(rightHeader?.style.position).toBe("sticky"); - expect(rightHeader?.style.right).toBe("0px"); - expect(leftCell?.style.left).toBe("44px"); - expect(rightCell?.style.right).toBe("0px"); - - await unmountDatabase(root, container, editor); - }); - - it("shows facet-backed autocomplete options in the filter panel", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-filter", - [ - { - id: "status", - title: "Status", - type: "select", - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ], - [["todo"], ["done"], ["todo"]], - ); - const { container, root } = await renderDatabase(editor); - - const filterButton = getButtonByText(container, "Filter"); - expect(filterButton).not.toBeNull(); - - await act(async () => { - filterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; - expect(addFilterButton).not.toBeNull(); - - await act(async () => { - addFilterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const datalist = container.querySelector('datalist[id="pen-db-filter-values-0"]'); - const todoOption = datalist?.querySelector('option[value="todo"]') as HTMLOptionElement | null; - const doneOption = datalist?.querySelector('option[value="done"]') as HTMLOptionElement | null; - expect(todoOption?.label).toBe("Todo (2)"); - expect(doneOption?.label).toBe("Done (1)"); - - await unmountDatabase(root, container, editor); - }); - - it("manages the multi-sort stack from the sort panel", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-sort-panel", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "tags", title: "Priority", type: "number", width: 120 }, - { id: "status", title: "Status", type: "text", width: 120 }, - ], - [["A", "2", "Open"], ["B", "1", "Done"]], - ); - const { container, root } = await renderDatabase(editor); - - const sortButton = getButtonByText(container, "Sort"); - expect(sortButton).not.toBeNull(); - - await act(async () => { - sortButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; - expect(addSortButton).not.toBeNull(); - - await act(async () => { - addSortButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const refreshedAddSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; - expect(refreshedAddSortButton).not.toBeNull(); - - await act(async () => { - refreshedAddSortButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const secondColumnSelect = container.querySelector('[data-sort-column="1"]') as HTMLSelectElement | null; - expect(secondColumnSelect).not.toBeNull(); - - await act(async () => { - if (secondColumnSelect) { - secondColumnSelect.value = "tags"; - secondColumnSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(1); - }); - - const refreshedSecondDirectionSelect = container.querySelector( - '[data-sort-direction="1"]', - ) as HTMLSelectElement | null; - expect(refreshedSecondDirectionSelect).not.toBeNull(); - - await act(async () => { - if (refreshedSecondDirectionSelect) { - refreshedSecondDirectionSelect.value = "desc"; - refreshedSecondDirectionSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(1); - }); - - const moveUpButton = container.querySelector('[data-sort-move-up="1"]') as HTMLButtonElement | null; - expect(moveUpButton).not.toBeNull(); - - await act(async () => { - moveUpButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-sort-panel")?.databaseActiveView()?.sort).toEqual([ - { columnId: "tags", direction: "desc" }, - { columnId: "name", direction: "asc" }, - ]); - - await unmountDatabase(root, container, editor); - }); - - it("supports nested filter groups from the filter panel", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-filter-groups", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "status", title: "Status", type: "text", width: 120 }, - ], - [["Alpha", "Open"], ["Beta", "Done"]], - ); - const { container, root } = await renderDatabase(editor); - - const filterButton = getButtonByText(container, "Filter"); - expect(filterButton).not.toBeNull(); - - await act(async () => { - filterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addGroupButton = container.querySelector('[data-filter-add-group="root"]') as HTMLButtonElement | null; - expect(addGroupButton).not.toBeNull(); - - await act(async () => { - addGroupButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const nestedValueInput = container.querySelector('[data-filter-value="0-0"]') as HTMLInputElement | null; - expect(nestedValueInput).not.toBeNull(); - - await act(async () => { - if (nestedValueInput) { - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - valueSetter?.call(nestedValueInput, "Alpha"); - nestedValueInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Alpha" })); - } - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-filter-groups")?.databaseActiveView()?.filter).toEqual({ - operator: "and", - conditions: [ - { - operator: "and", - conditions: [ - { columnId: "name", operator: "contains", value: "Alpha" }, - ], - }, - ], - }); - - const renderedRows = Array.from( - container.querySelectorAll(`[data-block-id="db-filter-groups"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - expect(renderedRows).toHaveLength(1); - expect(renderedRows[0]?.textContent).toContain("Alpha"); - - await unmountDatabase(root, container, editor); - }); - - it("filters dates with relative presets from the filter panel", async () => { - const now = new Date(); - const recentDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2, 9, 0, 0); - const oldDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 12, 9, 0, 0); - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-date-filter", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-date-filter", - columnId: "tags", - patch: { - title: "Due", - }, - }, - { - type: "database-convert-column", - blockId: "db-date-filter", - columnId: "tags", - toType: "date", - }, - { - type: "database-insert-row", - blockId: "db-date-filter", - rowId: "row-a", - values: { name: "Alpha", tags: recentDate.toISOString() }, - }, - { - type: "database-insert-row", - blockId: "db-date-filter", - rowId: "row-b", - values: { name: "Beta", tags: oldDate.toISOString() }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const filterButton = getButtonByText(container, "Filter"); - expect(filterButton).not.toBeNull(); - - await act(async () => { - filterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; - expect(addFilterButton).not.toBeNull(); - - await act(async () => { - addFilterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const columnSelect = container.querySelector('[data-filter-column="0"]') as HTMLSelectElement | null; - expect(columnSelect).not.toBeNull(); - - await act(async () => { - if (columnSelect) { - columnSelect.value = "tags"; - columnSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(2); - }); - - const operatorSelect = container.querySelector('[data-filter-operator="0"]') as HTMLSelectElement | null; - expect(operatorSelect).not.toBeNull(); - expect( - Array.from(operatorSelect?.options ?? []).some( - (option) => option.value === "is_relative", - ), - ).toBe(true); - - updatePrimaryView(editor, "db-date-filter", { - filter: { - operator: "and", - conditions: [{ - columnId: "tags", - operator: "is_relative", - value: "last_7_days", - }], - }, - }); - - await act(async () => { - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-date-filter")?.databaseActiveView()?.filter).toEqual({ - operator: "and", - conditions: [{ - columnId: "tags", - operator: "is_relative", - value: "last_7_days", - }], - }); - - await unmountDatabase(root, container, editor); - }); - - it("pins selected rows to the top and bottom through the toolbar", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-row-pins", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-insert-row", - blockId: "db-row-pins", - rowId: "row-a", - values: { name: "Alpha" }, - }, - { - type: "database-insert-row", - blockId: "db-row-pins", - rowId: "row-b", - values: { name: "Beta" }, - }, - { - type: "database-insert-row", - blockId: "db-row-pins", - rowId: "row-c", - values: { name: "Gamma" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const rowCheckboxes = Array.from( - container.querySelectorAll( - `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, - ), - ) as HTMLInputElement[]; - expect(rowCheckboxes).toHaveLength(3); - - await act(async () => { - rowCheckboxes[1]?.click(); - await flushAnimationFrames(1); - }); - - const pinTopButton = getButtonByText(container, "Pin top"); - expect(pinTopButton).not.toBeNull(); - - await act(async () => { - pinTopButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ - top: ["row-b"], - }); - - let renderedRows = Array.from( - container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - expect(renderedRows[0]?.getAttribute("data-row-section")).toBe("top"); - expect(renderedRows[0]?.textContent).toContain("Beta"); - - const refreshedRowCheckboxes = Array.from( - container.querySelectorAll( - `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, - ), - ) as HTMLInputElement[]; - - await act(async () => { - refreshedRowCheckboxes[0]?.click(); - await flushAnimationFrames(1); - }); - - await act(async () => { - refreshedRowCheckboxes[2]?.click(); - await flushAnimationFrames(1); - }); - - const pinBottomButton = getButtonByText(container, "Pin bottom"); - expect(pinBottomButton).not.toBeNull(); - - await act(async () => { - pinBottomButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ - top: ["row-b"], - bottom: ["row-c"], - }); - - renderedRows = Array.from( - container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - expect(renderedRows.at(-1)?.getAttribute("data-row-section")).toBe("bottom"); - expect(renderedRows.at(-1)?.textContent).toContain("Gamma"); - - await unmountDatabase(root, container, editor); - }); - - it("refreshes the open column menu after adding a select option", async () => { - const editor = createEditor({ - }); - - function OptionMutationHarness() { - const db = useDatabaseController({ blockId: "db-option-menu" }); - const statusColumn = db.columnSchema.find((entry) => entry.id === "status"); - return ( - <> - - { }} - onRename={(nextTitle) => db.renameColumn("status", nextTitle)} - onChangeType={(nextType) => db.changeColumnType("status", nextType)} - onDelete={() => db.deleteColumn("status")} - onToggleVisibility={() => db.toggleColumnVisibility("status")} - onChangePin={(nextPinned) => db.changeColumnPin("status", nextPinned)} - onAddOption={(value, color) => db.addOption("status", value, color)} - onRenameOption={(optionId, value) => db.renameOption("status", optionId, value)} - onRecolorOption={(optionId, color) => db.recolorOption("status", optionId, color)} - onRemoveOption={(optionId) => db.removeOption("status", optionId)} - onMoveOption={(optionId, direction) => db.moveOption("status", optionId, direction)} - /> - - ); - } - - seedDatabase( - editor, - "db-option-menu", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "status", title: "Status", type: "select", width: 140, options: [] }, - ], - [["Alpha", ""]], - ); - const { container, root } = await renderDatabase( - editor, - { children: }, - ); - - let optionRows = Array.from( - container.querySelectorAll(`.pen-db-col-option-row input`), - ) as HTMLInputElement[]; - expect(optionRows).toHaveLength(0); - - const addOptionButton = getButtonByText(container, "Add test option"); - expect(addOptionButton).not.toBeNull(); - - await act(async () => { - addOptionButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-option-menu")?.tableColumns()[1]?.options).toEqual([ - expect.objectContaining({ - value: "Blocked", - color: "gray", - }), - ]); - - optionRows = Array.from( - container.querySelectorAll(`.pen-db-col-option-row input`), - ) as HTMLInputElement[]; - expect(optionRows).toHaveLength(1); - expect(optionRows[0]?.value).toBe("Blocked"); - - await unmountDatabase(root, container, editor); - }); - - it("renders grouped sections from the group panel", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-group", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-group", - columnId: "tags", - patch: { - title: "Status", - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - }, - { - type: "database-insert-row", - blockId: "db-group", - rowId: "row-a", - values: { name: "Alpha", tags: "todo" }, - }, - { - type: "database-insert-row", - blockId: "db-group", - rowId: "row-b", - values: { name: "Beta", tags: "done" }, - }, - { - type: "database-insert-row", - blockId: "db-group", - rowId: "row-c", - values: { name: "Gamma", tags: "todo" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const groupButton = getButtonByText(container, "Group"); - expect(groupButton).not.toBeNull(); - - await act(async () => { - groupButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const groupSelect = container.querySelector(".pen-db-col-vis-panel select") as HTMLSelectElement | null; - expect(groupSelect).not.toBeNull(); - - await act(async () => { - if (groupSelect) { - groupSelect.value = "tags"; - groupSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-group")?.databaseActiveView()?.groupBy).toBe("tags"); - - const groupRows = Array.from( - container.querySelectorAll(`[data-block-id="db-group"] .pen-db-group-row`), - ) as HTMLTableRowElement[]; - expect(groupRows).toHaveLength(2); - expect(groupRows[0]?.textContent).toContain("Todo (2)"); - expect(groupRows[1]?.textContent).toContain("Done (1)"); - - await unmountDatabase(root, container, editor); - }); - - it("adds switches and removes database views from the title bar", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-views", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "status", title: "Status", type: "text", width: 120 }, - ], - [["Alpha", "Open"], ["Beta", "Done"]], - ); - const primaryViewId = editor.getBlock("db-views")?.databasePrimaryViewId() ?? ""; - const { container, root } = await renderDatabase(editor); - - const addViewButton = getButtonByText(container, "+ View"); - expect(addViewButton).not.toBeNull(); - - await act(async () => { - addViewButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addListViewButton = getButtonByText(container, "New list view"); - const addBoardViewButton = getButtonByText(container, "New board view"); - const addCalendarViewButton = getButtonByText(container, "New calendar view"); - const addGalleryViewButton = getButtonByText(container, "New gallery view"); - expect(addListViewButton).not.toBeNull(); - expect(addBoardViewButton).not.toBeNull(); - expect(addCalendarViewButton).not.toBeNull(); - expect(addGalleryViewButton).not.toBeNull(); - - await act(async () => { - addListViewButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const blockAfterAdd = editor.getBlock("db-views"); - const listView = blockAfterAdd?.databaseViews().find((view) => view.type === "list"); - expect(listView).toBeDefined(); - expect(blockAfterAdd?.databaseViews()).toHaveLength(2); - expect(blockAfterAdd?.databaseActiveView()?.id).toBe(listView?.id); - expect(container.querySelector(`[data-block-id="db-views"] .pen-db-list-view`)).not.toBeNull(); - - const tableTab = container.querySelector( - `[data-block-id="db-views"] [data-view-id="${primaryViewId}"]`, - ) as HTMLButtonElement | null; - expect(tableTab).not.toBeNull(); - - await act(async () => { - tableTab?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-views")?.databaseActiveView()?.id).toBe(primaryViewId); - expect(container.querySelector(`[data-block-id="db-views"] table[data-pen-table]`)).not.toBeNull(); - - const removeListViewButton = container.querySelector( - `[data-block-id="db-views"] [data-remove-view-id="${listView?.id ?? ""}"]`, - ) as HTMLButtonElement | null; - expect(removeListViewButton).not.toBeNull(); - - await act(async () => { - removeListViewButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-views")?.databaseViews()).toHaveLength(1); - expect(editor.getBlock("db-views")?.databasePrimaryViewId()).toBe(primaryViewId); - - await unmountDatabase(root, container, editor); - }); - - it("renders list views as stacked row cards", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-list", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-add-view", - blockId: "db-list", - view: { - id: "view-list", - title: "List view", - type: "list", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: null, - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-list", - viewId: "view-list", - }, - { - type: "database-insert-row", - blockId: "db-list", - rowId: "row-a", - values: { name: "Alpha", tags: "Todo", status: "true" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const listView = container.querySelector( - `[data-block-id="db-list"] .pen-db-list-view`, - ) as HTMLDivElement | null; - expect(listView).not.toBeNull(); - expect(container.querySelector(`[data-block-id="db-list"] table[data-pen-table]`)).toBeNull(); - - const listRow = container.querySelector( - `[data-block-id="db-list"] .pen-db-list-row[data-row="0"]`, - ) as HTMLDivElement | null; - expect(listRow).not.toBeNull(); - expect(listRow?.textContent).toContain("Name"); - expect(listRow?.textContent).toContain("Tags"); - expect(listRow?.textContent).toContain("Done"); - expect(listRow?.textContent).toContain("Alpha"); - - await unmountDatabase(root, container, editor); - }); - - it("renders board views as grouped kanban lanes", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-board", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-board", - columnId: "tags", - patch: { - title: "Status", - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - }, - { - type: "database-add-view", - blockId: "db-board", - view: { - id: "view-board", - title: "Board view", - type: "board", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: "tags", - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-board", - viewId: "view-board", - }, - { - type: "database-insert-row", - blockId: "db-board", - rowId: "row-a", - values: { name: "Alpha", tags: "todo", status: "true" }, - }, - { - type: "database-insert-row", - blockId: "db-board", - rowId: "row-b", - values: { name: "Beta", tags: "done", status: "false" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const boardView = container.querySelector( - `[data-block-id="db-board"] .pen-db-board-view`, - ) as HTMLDivElement | null; - expect(boardView).not.toBeNull(); - - const laneHeaders = Array.from( - container.querySelectorAll(`[data-block-id="db-board"] .pen-db-board-lane-header`), - ) as HTMLDivElement[]; - expect(laneHeaders).toHaveLength(2); - expect(laneHeaders[0]?.textContent).toContain("Todo (1)"); - expect(laneHeaders[1]?.textContent).toContain("Done (1)"); - - const boardCard = container.querySelector( - `[data-block-id="db-board"] .pen-db-board-card[data-row="0"]`, - ) as HTMLDivElement | null; - expect(boardCard).not.toBeNull(); - expect(boardCard?.textContent).toContain("Alpha"); - expect(boardCard?.textContent).toContain("Status"); - - await unmountDatabase(root, container, editor); - }); - - it("renders gallery views as row cards", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-gallery", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-add-view", - blockId: "db-gallery", - view: { - id: "view-gallery", - title: "Gallery view", - type: "gallery", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: null, - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-gallery", - viewId: "view-gallery", - }, - { - type: "database-insert-row", - blockId: "db-gallery", - rowId: "row-a", - values: { name: "Alpha", tags: "Todo", status: "true" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const galleryView = container.querySelector( - `[data-block-id="db-gallery"] .pen-db-gallery-view`, - ) as HTMLDivElement | null; - expect(galleryView).not.toBeNull(); - - const galleryCard = container.querySelector( - `[data-block-id="db-gallery"] .pen-db-gallery-card[data-row="0"]`, - ) as HTMLDivElement | null; - expect(galleryCard).not.toBeNull(); - expect(galleryCard?.textContent).toContain("Name"); - expect(galleryCard?.textContent).toContain("Alpha"); - expect(galleryCard?.textContent).toContain("Tags"); - - await unmountDatabase(root, container, editor); - }); - - it("renders calendar views from the first date column", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-calendar", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-calendar", - columnId: "tags", - patch: { - title: "Due", - }, - }, - { - type: "database-convert-column", - blockId: "db-calendar", - columnId: "tags", - toType: "date", - }, - { - type: "database-add-view", - blockId: "db-calendar", - view: { - id: "view-calendar", - title: "Calendar view", - type: "calendar", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: null, - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-calendar", - viewId: "view-calendar", - }, - { - type: "database-insert-row", - blockId: "db-calendar", - rowId: "row-a", - values: { name: "Alpha", tags: "2024-03-10T09:00:00.000Z", status: "true" }, - }, - { - type: "database-insert-row", - blockId: "db-calendar", - rowId: "row-b", - values: { name: "Beta", tags: "", status: "false" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - await act(async () => { - await flushAnimationFrames(2); - }); - - const calendarView = container.querySelector( - `[data-block-id="db-calendar"] .pen-db-calendar-view`, - ) as HTMLDivElement | null; - expect(calendarView).not.toBeNull(); - - const calendarCards = Array.from( - container.querySelectorAll( - `[data-block-id="db-calendar"] .pen-db-calendar-view .pen-db-calendar-card`, - ), - ) as HTMLDivElement[]; - expect( - calendarCards.some((card) => card.textContent?.includes("Alpha")), - ).toBe(true); - - const unscheduledSection = container.querySelector( - `[data-block-id="db-calendar"] .pen-db-calendar-unscheduled`, - ) as HTMLDivElement | null; - expect(unscheduledSection).not.toBeNull(); - expect(unscheduledSection?.textContent).toContain("Beta"); - - await unmountDatabase(root, container, editor); - }); }); diff --git a/packages/extensions/database/src/cellEditorSpecializedCells.tsx b/packages/extensions/database/src/cellEditorSpecializedCells.tsx new file mode 100644 index 0000000..37efdbe --- /dev/null +++ b/packages/extensions/database/src/cellEditorSpecializedCells.tsx @@ -0,0 +1,130 @@ +import { useCellTextSnapshot, useEditorContext } from "@pen/react"; +import { useState } from "react"; +import type { DatabaseCellContentProps } from "./cellEditors"; +import { setCellText, widgetCellAttrs } from "./cellEditorUtils"; + +export function RelationCell(props: DatabaseCellContentProps) { + const { blockId, row, col } = props; + const { editor } = useEditorContext(); + const readonly = !!props.readonly; + const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); + const currentValue = textSnapshot.text ?? ""; + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(currentValue); + + function handleSave() { + setCellText(editor, blockId, row, col, draftValue.trim()); + setIsEditing(false); + } + + if (!isEditing) { + return ( + { + if (readonly) return; + event.stopPropagation(); + setDraftValue(currentValue); + setIsEditing(true); + }} + > + {currentValue ? ( + {currentValue} + ) : ( + Link record… + )} + + ); + } + + return ( + + setDraftValue(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleSave(); + } + if (event.key === "Escape") { + event.preventDefault(); + setIsEditing(false); + } + }} + autoFocus + /> + + + + ); +} + +export function FormulaCell(props: DatabaseCellContentProps) { + const { blockId, row, col } = props; + const { editor } = useEditorContext(); + const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); + const currentValue = textSnapshot.text ?? ""; + + return ( + + {currentValue || Computed value} + + ); +} + +export function DateCell(props: DatabaseCellContentProps) { + const { blockId, row, col, column } = props; + const { editor } = useEditorContext(); + const readonly = !!props.readonly; + const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); + const raw = textSnapshot.text ?? ""; + + let display = ""; + if (raw) { + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) { + const fmt = column.format as { includeTime?: boolean; dateStyle?: "short" | "medium" | "long" } | undefined; + const opts: Intl.DateTimeFormatOptions = { dateStyle: fmt?.dateStyle ?? "medium" }; + if (fmt?.includeTime) opts.timeStyle = "short"; + display = new Intl.DateTimeFormat(undefined, opts).format(d); + } else { + display = raw; + } + } + + function handleDateChange(event: React.ChangeEvent) { + if (readonly) return; + setCellText(editor, blockId, row, col, event.target.value ? new Date(event.target.value).toISOString() : ""); + } + + return ( + + {display || Pick date…} + {!readonly && ( + + )} + + ); +} diff --git a/packages/extensions/database/src/cellEditorUtils.ts b/packages/extensions/database/src/cellEditorUtils.ts new file mode 100644 index 0000000..f38a6d2 --- /dev/null +++ b/packages/extensions/database/src/cellEditorUtils.ts @@ -0,0 +1,82 @@ +import { DATA_ATTRS } from "@pen/react"; +import type { Editor } from "@pen/types"; + +export function setCellText(editor: Editor, blockId: string, row: number, col: number, text: string): void { + const block = editor.getBlock(blockId); + if (!block) return; + const rowHandle = block.tableRow(row); + const column = block.tableColumns()[col]; + const cell = block.tableCell(row, col); + if (!cell || !rowHandle || !column) return; + editor.apply([{ + type: "database-update-cell", + blockId, + rowId: rowHandle.id, + columnId: column.id, + value: text, + }], { origin: "user" }); +} + +export function toggleCheckbox(editor: Editor, blockId: string, row: number, col: number, isChecked: boolean): void { + setCellText(editor, blockId, row, col, isChecked ? "false" : "true"); +} + +export function isCellActive( + fieldEditorState: { activeCellCoord: { blockId: string; row: number; col: number } | null }, + blockId: string, + row: number, + col: number, +): boolean { + return ( + fieldEditorState.activeCellCoord?.blockId === blockId && + fieldEditorState.activeCellCoord.row === row && + fieldEditorState.activeCellCoord.col === col + ); +} + +export function editableCellAttrs( + isActive: boolean, + row: number, + col: number, + showPlaceholder: boolean, + placeholder?: string, +): Record { + return { + [DATA_ATTRS.inlineContent]: "", + [DATA_ATTRS.fieldEditorSurface]: "", + [DATA_ATTRS.fieldEditorActiveSurface]: isActive ? "" : undefined, + [DATA_ATTRS.ignorePointerGesture]: isActive ? "" : undefined, + [DATA_ATTRS.tableCellRow]: row, + [DATA_ATTRS.tableCellCol]: col, + [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, + "data-placeholder": showPlaceholder ? placeholder : undefined, + style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%" }, + }; +} + +export function widgetCellAttrs(row: number, col: number): Record { + return { + [DATA_ATTRS.ignorePointerGesture]: "", + [DATA_ATTRS.tableCellRow]: row, + [DATA_ATTRS.tableCellCol]: col, + style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%", cursor: "default" }, + }; +} + +const TAG_COLORS: Record = { + red: "rgba(255, 86, 86, 0.2)", + orange: "rgba(255, 163, 68, 0.2)", + yellow: "rgba(255, 220, 73, 0.2)", + green: "rgba(77, 208, 89, 0.2)", + blue: "rgba(45, 120, 255, 0.2)", + purple: "rgba(155, 89, 255, 0.2)", + pink: "rgba(255, 89, 166, 0.2)", + gray: "rgba(155, 155, 155, 0.2)", +}; + +export function tagColor(color?: string): string | undefined { + if (!color) { + return undefined; + } + return TAG_COLORS[color] ?? color; +} diff --git a/packages/extensions/database/src/cellEditors.tsx b/packages/extensions/database/src/cellEditors.tsx index 1d66b66..1a686a8 100644 --- a/packages/extensions/database/src/cellEditors.tsx +++ b/packages/extensions/database/src/cellEditors.tsx @@ -1,5 +1,4 @@ import React, { useRef, useLayoutEffect, useState } from "react"; -import type { Editor } from "@pen/types"; import { normalizeStoredMultiSelectValue, normalizeStoredSelectValue, @@ -17,6 +16,15 @@ import type { ColumnType, DatabaseColumnDef, SelectOption } from "./types"; import { isContentEditableColumnType } from "./types"; import type { CellEditorRegistry } from "./cellEditorRegistry"; +import { + editableCellAttrs, + isCellActive, + setCellText, + tagColor, + toggleCheckbox, + widgetCellAttrs, +} from "./cellEditorUtils"; +import { DateCell, FormulaCell, RelationCell } from "./cellEditorSpecializedCells"; export const DATABASE_CELL_EDITOR_REGISTRY_SLOT = "database:cell-editor-registry"; export interface DatabaseCellContentProps { @@ -413,208 +421,4 @@ function MultiSelectCell(props: DatabaseCellContentProps) { ); } -function RelationCell(props: DatabaseCellContentProps) { - const { blockId, row, col } = props; - const { editor } = useEditorContext(); - const readonly = !!props.readonly; - const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); - const currentValue = textSnapshot.text ?? ""; - const [isEditing, setIsEditing] = useState(false); - const [draftValue, setDraftValue] = useState(currentValue); - - function handleSave() { - setCellText(editor, blockId, row, col, draftValue.trim()); - setIsEditing(false); - } - if (!isEditing) { - return ( - { - if (readonly) return; - event.stopPropagation(); - setDraftValue(currentValue); - setIsEditing(true); - }} - > - {currentValue ? ( - {currentValue} - ) : ( - Link record… - )} - - ); - } - - return ( - - setDraftValue(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - handleSave(); - } - if (event.key === "Escape") { - event.preventDefault(); - setIsEditing(false); - } - }} - autoFocus - /> - - - - ); -} - -function FormulaCell(props: DatabaseCellContentProps) { - const { blockId, row, col } = props; - const { editor } = useEditorContext(); - const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); - const currentValue = textSnapshot.text ?? ""; - - return ( - - {currentValue || Computed value} - - ); -} - -function DateCell(props: DatabaseCellContentProps) { - const { blockId, row, col, column } = props; - const { editor } = useEditorContext(); - const readonly = !!props.readonly; - const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); - const raw = textSnapshot.text ?? ""; - - let display = ""; - if (raw) { - const d = new Date(raw); - if (!Number.isNaN(d.getTime())) { - const fmt = column.format as { includeTime?: boolean; dateStyle?: "short" | "medium" | "long" } | undefined; - const opts: Intl.DateTimeFormatOptions = { dateStyle: fmt?.dateStyle ?? "medium" }; - if (fmt?.includeTime) opts.timeStyle = "short"; - display = new Intl.DateTimeFormat(undefined, opts).format(d); - } else { - display = raw; - } - } - - function handleDateChange(event: React.ChangeEvent) { - if (readonly) return; - setCellText(editor, blockId, row, col, event.target.value ? new Date(event.target.value).toISOString() : ""); - } - - return ( - - {display || Pick date…} - {!readonly && ( - - )} - - ); -} - -function setCellText(editor: Editor, blockId: string, row: number, col: number, text: string): void { - const block = editor.getBlock(blockId); - if (!block) return; - const rowHandle = block.tableRow(row); - const column = block.tableColumns()[col]; - const cell = block.tableCell(row, col); - if (!cell || !rowHandle || !column) return; - editor.apply([{ - type: "database-update-cell", - blockId, - rowId: rowHandle.id, - columnId: column.id, - value: text, - }], { origin: "user" }); -} - -function toggleCheckbox(editor: Editor, blockId: string, row: number, col: number, isChecked: boolean): void { - setCellText(editor, blockId, row, col, isChecked ? "false" : "true"); -} - -function isCellActive( - fieldEditorState: { activeCellCoord: { blockId: string; row: number; col: number } | null }, - blockId: string, - row: number, - col: number, -): boolean { - return ( - fieldEditorState.activeCellCoord?.blockId === blockId && - fieldEditorState.activeCellCoord.row === row && - fieldEditorState.activeCellCoord.col === col - ); -} - -function editableCellAttrs( - isActive: boolean, - row: number, - col: number, - showPlaceholder: boolean, - placeholder?: string, -): Record { - return { - [DATA_ATTRS.inlineContent]: "", - [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: isActive ? "" : undefined, - [DATA_ATTRS.ignorePointerGesture]: isActive ? "" : undefined, - [DATA_ATTRS.tableCellRow]: row, - [DATA_ATTRS.tableCellCol]: col, - [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, - "data-placeholder": showPlaceholder ? placeholder : undefined, - style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%" }, - }; -} - -function widgetCellAttrs(row: number, col: number): Record { - return { - [DATA_ATTRS.ignorePointerGesture]: "", - [DATA_ATTRS.tableCellRow]: row, - [DATA_ATTRS.tableCellCol]: col, - style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%", cursor: "default" }, - }; -} - -const TAG_COLORS: Record = { - red: "rgba(255, 86, 86, 0.2)", - orange: "rgba(255, 163, 68, 0.2)", - yellow: "rgba(255, 220, 73, 0.2)", - green: "rgba(77, 208, 89, 0.2)", - blue: "rgba(45, 120, 255, 0.2)", - purple: "rgba(155, 89, 255, 0.2)", - pink: "rgba(255, 89, 166, 0.2)", - gray: "rgba(155, 155, 155, 0.2)", -}; - -function tagColor(color?: string): string | undefined { - if (!color) { - return undefined; - } - return TAG_COLORS[color] ?? color; -} diff --git a/packages/extensions/database/src/databaseControllerMutationHandlers.ts b/packages/extensions/database/src/databaseControllerMutationHandlers.ts new file mode 100644 index 0000000..b394cb3 --- /dev/null +++ b/packages/extensions/database/src/databaseControllerMutationHandlers.ts @@ -0,0 +1,382 @@ +import type React from "react"; +import { generateId } from "@pen/types"; +import type { ColumnType, DatabaseColumnDef, DatabaseViewModelColumn, DatabaseViewModelRow, DatabaseViewState, FilterGroup } from "./types"; +import { isCellInSelection } from "./utils"; +import { + createDatabaseViewDefinition, + getNextSortState, + shiftMonth, +} from "./utils/databaseRenderer"; + +type MutationHandlerContext = Record; + +export function createDatabaseMutationHandlers(context: MutationHandlerContext) { + const { + activeCalendarMonth, + allRows, + block, + blockId, + calendarMonth, + cellSelection, + columnSchema, + columns, + databaseViews, + editor, + engine, + globalSearch, + isDataReadonly, + isUiReadonly, + pageCount, + rowSelection, + setActiveColumnMenu, + setCalendarMonth, + setColumnSchemaRefreshToken, + setGlobalSearchRaw, + setIsEditingTitle, + setShowAddViewMenu, + setViewState, + title, + viewState, + visibleColumnIds, + visibleColumnIdSet, + visibleRows, + } = context; + + function updateViewState(patch: Partial>) { + const nextView = { + ...viewState, + ...patch, + }; + setViewState(nextView); + editor.apply([ + { + type: "database-update-view", + blockId, + viewId: block.databasePrimaryViewId() ?? undefined, + patch, + }, + ], { origin: "user" }); + } + + function handleTitleClick() { + if (isUiReadonly) return; + setIsEditingTitle(true); + } + + function handleTitleBlur(event: React.FocusEvent) { + setIsEditingTitle(false); + const nextTitle = event.currentTarget.value.trim() || "Untitled"; + if (nextTitle === title) return; + editor.apply([ + { + type: "update-block", + blockId, + props: { title: nextTitle }, + }, + ]); + } + + function handleTitleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter" || event.key === "Escape") { + event.currentTarget.blur(); + } + } + function handleHeaderClick(event: React.MouseEvent, columnId: string) { + const nextSort = getNextSortState(viewState.sort ?? [], columnId, event.shiftKey); + updateViewState({ sort: nextSort, pageIndex: 0 }); + } + + function handleAddRow() { + if (isDataReadonly) return; + editor.apply([ + { + type: "database-insert-row", + blockId, + index: block.tableRowCount(), + }, + ], { origin: "user" }); + } + + function handleAddColumn() { + if (isUiReadonly) return; + const columnId = generateId(); + const nextColumn: DatabaseColumnDef = { + id: columnId, + title: "New column", + type: "text", + }; + editor.apply([ + { + type: "database-add-column", + blockId, + column: nextColumn, + index: block.tableColumnCount(), + viewId: block.databasePrimaryViewId() ?? undefined, + }, + ], { origin: "user" }); + } + + function handleAddView(nextType: DatabaseViewState["type"]) { + if (isUiReadonly) return; + const nextViewId = generateId(); + const nextView = createDatabaseViewDefinition({ + id: nextViewId, + type: nextType, + columns: columnSchema, + existingViews: databaseViews, + }); + setViewState(nextView); + editor.apply([ + { + type: "database-add-view", + blockId, + view: nextView, + }, + { + type: "database-set-active-view", + blockId, + viewId: nextViewId, + }, + ], { origin: "user" }); + setShowAddViewMenu(false); + } + + function handleSetActiveView(viewId: string) { + const nextView = databaseViews.find((view: DatabaseViewState) => view.id === viewId); + if (nextView) { + setViewState(nextView); + } + editor.apply([ + { + type: "database-set-active-view", + blockId, + viewId, + }, + ], { origin: "user" }); + } + + function handleRemoveView(viewId: string) { + if (isUiReadonly || databaseViews.length <= 1) return; + const currentActiveViewId = block.databasePrimaryViewId() ?? viewState.id; + if (currentActiveViewId === viewId) { + const fallbackView = databaseViews.find((view: DatabaseViewState) => view.id !== viewId); + if (fallbackView) { + setViewState(fallbackView); + } + } + editor.apply([ + { + type: "database-remove-view", + blockId, + viewId, + }, + ], { origin: "user" }); + } + function handleDeleteColumn(columnId: string) { + if (isUiReadonly) return; + editor.apply([ + { type: "database-remove-column", blockId, columnId }, + ], { origin: "user" }); + setActiveColumnMenu(null); + } + + function handleRenameColumn(columnId: string, nextTitle: string) { + editor.apply([{ + type: "database-update-column", + blockId, + columnId, + patch: { title: nextTitle || "Untitled" }, + }], { origin: "user" }); + setActiveColumnMenu(null); + } + + function handleChangeColumnType(columnId: string, nextType: ColumnType) { + const targetColumn = columnSchema.find((column: DatabaseColumnDef) => column.id === columnId); + if (!targetColumn || targetColumn.type === nextType) return; + editor.apply([{ + type: "database-convert-column", + blockId, + columnId, + toType: nextType, + }], { origin: "user" }); + setActiveColumnMenu(null); + } + + function handleToggleColumnVisibility(columnId: string) { + const nextVisibleColumnIds = visibleColumnIdSet.has(columnId) + ? visibleColumnIds.filter((id: string) => id !== columnId) + : [...visibleColumnIds, columnId]; + updateViewState({ visibleColumnIds: nextVisibleColumnIds }); + } + + function handleChangeColumnPin( + columnId: string, + nextPinned: "left" | "right" | undefined, + ) { + editor.apply([{ + type: "database-update-column", + blockId, + columnId, + patch: { pinned: nextPinned }, + }], { origin: "user" }); + setActiveColumnMenu(null); + } + + function refreshColumnSchemaSoon() { + requestAnimationFrame(() => { + setColumnSchemaRefreshToken((value: number) => value + 1); + }); + } + + function handleAddOption(columnId: string, value: string, color?: string) { + const trimmedValue = value.trim(); + if (!trimmedValue) return; + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "add", + option: { + id: generateId(), + value: trimmedValue, + color, + }, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleRenameOption(columnId: string, optionId: string, value: string) { + const trimmedValue = value.trim(); + if (!trimmedValue) return; + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "rename", + optionId, + value: trimmedValue, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleRecolorOption(columnId: string, optionId: string, color: string) { + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "recolor", + optionId, + color, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleRemoveOption(columnId: string, optionId: string) { + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "remove", + optionId, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleMoveOption(columnId: string, optionId: string, direction: "up" | "down") { + const column = columnSchema.find((entry: DatabaseColumnDef) => entry.id === columnId); + const currentOptions = column?.options ?? []; + const currentIndex = currentOptions.findIndex((option: NonNullable[number]) => option.id === optionId); + if (currentIndex < 0) return; + const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; + if (targetIndex < 0 || targetIndex >= currentOptions.length) return; + const nextOrder = [...currentOptions.map((option: NonNullable[number]) => option.id)]; + const [movedOptionId] = nextOrder.splice(currentIndex, 1); + nextOrder.splice(targetIndex, 0, movedOptionId); + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "reorder", + order: nextOrder, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleFilterGroupChange(nextFilter: FilterGroup | null) { + updateViewState({ filter: nextFilter, pageIndex: 0 }); + } + + function handleSortChange(nextSort: NonNullable) { + updateViewState({ sort: nextSort, pageIndex: 0 }); + } + + function handleChangeGroupBy(nextGroupBy: string | null) { + updateViewState({ groupBy: nextGroupBy, pageIndex: 0 }); + } + + function handlePreviousPage() { + updateViewState({ pageIndex: Math.max(0, (viewState.pageIndex ?? 0) - 1) }); + } + + function handleNextPage() { + updateViewState({ pageIndex: Math.min(pageCount - 1, (viewState.pageIndex ?? 0) + 1) }); + } + + function setGlobalSearch(value: string) { + setGlobalSearchRaw(value); + updateViewState({ pageIndex: 0 }); + } + function isCellSelectedFn(row: number, column: number): boolean { + return !!( + cellSelection && + isCellInSelection(cellSelection, row, column, { + rowId: visibleRows.find((entry: DatabaseViewModelRow) => entry.crdtRowIndex === row)?.id, + columnId: columns.find((entry: DatabaseViewModelColumn) => entry.columnIndex === column)?.id, + }) + ); + } + + function formatRemoteCell(row: DatabaseViewModelRow, column: DatabaseViewModelColumn): string { + return engine.formatCellDisplay( + row.cells[column.id] ?? "", + column.type, + column.format, + column.options, + ); + } + function shiftCalendarMonthFn(amount: number) { + setCalendarMonth(shiftMonth(activeCalendarMonth, amount)); + } + + return { + updateViewState, + handleTitleClick, + handleTitleBlur, + handleTitleKeyDown, + handleHeaderClick, + handleAddRow, + handleAddColumn, + handleAddView, + handleSetActiveView, + handleRemoveView, + handleDeleteColumn, + handleRenameColumn, + handleChangeColumnType, + handleToggleColumnVisibility, + handleChangeColumnPin, + handleAddOption, + handleRenameOption, + handleRecolorOption, + handleRemoveOption, + handleMoveOption, + handleFilterGroupChange, + handleSortChange, + handleChangeGroupBy, + handlePreviousPage, + handleNextPage, + setGlobalSearch, + isCellSelectedFn, + formatRemoteCell, + shiftCalendarMonthFn, + }; +} diff --git a/packages/extensions/database/src/databaseControllerSelectionHandlers.ts b/packages/extensions/database/src/databaseControllerSelectionHandlers.ts new file mode 100644 index 0000000..714e3d8 --- /dev/null +++ b/packages/extensions/database/src/databaseControllerSelectionHandlers.ts @@ -0,0 +1,282 @@ +import type React from "react"; +import { DATA_ATTRS } from "@pen/react"; +import type { CellSelection } from "@pen/types"; +import type { DatabaseViewModelColumn, DatabaseViewModelRow } from "./types"; +import { getNextRowPinningState } from "./utils/databaseRenderer"; + +type SelectionHandlerContext = Record; + +export function createDatabaseSelectionHandlers(context: SelectionHandlerContext) { + const { + allRows, + allVisibleSelected, + blockId, + cellSelection, + columns, + editor, + fieldEditor, + fieldEditorActiveCell, + isDataReadonly, + rowSelection, + setRowSelection, + updateViewState, + viewState, + visibleRowIds, + visibleRows, + visibleSelectionColumnIds, + } = context; + + function createDatabaseCellSelection( + anchor: { row: number; col: number }, + head: { row: number; col: number } = anchor, + ): CellSelection { + return { + type: "cell", + blockId, + anchor, + head, + rowIds: visibleRowIds, + columnIds: visibleSelectionColumnIds, + }; + } + + function findVisibleCellCoordByIds( + rowId: string | null, + columnId: string | null, + ): { row: number; col: number } | null { + if (!rowId || !columnId) { + return null; + } + const row = visibleRows.findIndex((entry: DatabaseViewModelRow) => entry.id === rowId); + const col = columns.findIndex((entry: DatabaseViewModelColumn) => entry.id === columnId); + if (row < 0 || col < 0) { + return null; + } + return { row, col }; + } + + function findVisibleCellCoordByStorage( + row: number, + col: number, + ): { row: number; col: number } | null { + const rowIndex = visibleRows.findIndex( + (entry: DatabaseViewModelRow) => entry.crdtRowIndex === row, + ); + const colIndex = columns.findIndex( + (entry: DatabaseViewModelColumn) => entry.columnIndex === col, + ); + if (rowIndex < 0 || colIndex < 0) { + return null; + } + return { row: rowIndex, col: colIndex }; + } + + function normalizeDatabaseCellSelection( + selection: CellSelection, + ): CellSelection | null { + if (columns.length === 0) { + return null; + } + if (visibleRows.length === 0) { + return { + type: "cell", + blockId, + anchor: selection.anchor, + head: selection.head, + }; + } + + const firstVisibleCell = { row: 0, col: 0 }; + const anchorCoord = + findVisibleCellCoordByIds( + selection.rowIds?.[selection.anchor.row] ?? null, + selection.columnIds?.[selection.anchor.col] ?? null, + ) ?? + findVisibleCellCoordByStorage( + selection.anchor.row, + selection.anchor.col, + ) ?? + firstVisibleCell; + const headCoord = + findVisibleCellCoordByIds( + selection.rowIds?.[selection.head.row] ?? null, + selection.columnIds?.[selection.head.col] ?? null, + ) ?? + findVisibleCellCoordByStorage( + selection.head.row, + selection.head.col, + ) ?? + anchorCoord; + + return createDatabaseCellSelection(anchorCoord, headCoord); + } + + function areSelectionAxesEqual( + left: string[] | undefined, + right: string[], + ): boolean { + if (!left || left.length !== right.length) { + return false; + } + return left.every((value, index) => value === right[index]); + } + + function isDatabaseSelectionCurrent(selection: CellSelection): boolean { + if (visibleRows.length === 0) { + return !selection.rowIds && !selection.columnIds; + } + + return ( + areSelectionAxesEqual(selection.rowIds, visibleRowIds) && + areSelectionAxesEqual(selection.columnIds, visibleSelectionColumnIds) + ); + } + function handleCellMouseDown( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, + ) { + if (!fieldEditor) return; + const isEditing = + fieldEditorActiveCell?.blockId === blockId + && fieldEditorActiveCell.row === row.crdtRowIndex + && fieldEditorActiveCell.col === column.columnIndex; + if (isEditing) return; + const nextCoord = findVisibleCellCoordByIds(row.id, column.id); + if (!nextCoord) return; + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation?.(); + const isSameSingleCellSelection = + cellSelection && + cellSelection.anchor.row === nextCoord.row && + cellSelection.anchor.col === nextCoord.col && + cellSelection.head.row === nextCoord.row && + cellSelection.head.col === nextCoord.col; + if (!event.shiftKey && isSameSingleCellSelection) { + editor.selectBlock(blockId); + return; + } + if (event.shiftKey && cellSelection) { + editor.setSelection( + createDatabaseCellSelection(cellSelection.anchor, nextCoord), + ); + return; + } + editor.setSelection(createDatabaseCellSelection(nextCoord)); + } + + function handleCellDoubleClick( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, + ) { + if (isDataReadonly || !fieldEditor) return; + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation?.(); + const cellSurface = event.currentTarget.querySelector(`[${DATA_ATTRS.fieldEditorSurface}]`) as HTMLElement | null; + if (cellSurface) { + fieldEditor.activateCellFromElement?.(blockId, row.crdtRowIndex, column.columnIndex, cellSurface) + ?? fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); + return; + } + fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); + } + function handleToggleAllRows() { + if (allVisibleSelected) { + const nextSelection = { ...rowSelection }; + for (const rowId of visibleRowIds) { + delete nextSelection[rowId]; + } + setRowSelection(nextSelection); + return; + } + const nextSelection = { ...rowSelection }; + for (const rowId of visibleRowIds) { + nextSelection[rowId] = true; + } + setRowSelection(nextSelection); + } + + function handleToggleRow(rowId: string) { + setRowSelection((previous: Record) => ({ + ...previous, + [rowId]: !previous[rowId], + })); + } + + function getSelectedRowIds( + fallback?: { rowId: string; checked: boolean }, + ): string[] { + const selectedRowIds = allRows + .filter((row: DatabaseViewModelRow) => rowSelection[row.id]) + .map((row: DatabaseViewModelRow) => row.id); + if ( + fallback?.checked && + !selectedRowIds.includes(fallback.rowId) + ) { + selectedRowIds.push(fallback.rowId); + } + return selectedRowIds; + } + + function handleRowSelectionKeyDown( + event: React.KeyboardEvent, + rowId: string, + ) { + if (event.key !== "Backspace" && event.key !== "Delete") { + return; + } + event.preventDefault(); + event.stopPropagation(); + handleDeleteSelectedRows({ + rowId, + checked: event.currentTarget.checked, + }); + } + + function handleDeleteSelectedRows( + fallback?: { rowId: string; checked: boolean }, + ) { + const selectedRowIds = getSelectedRowIds(fallback); + if (selectedRowIds.length === 0 || isDataReadonly) return; + editor.apply([ + { + type: "database-delete-rows", + blockId, + rowIds: selectedRowIds, + }, + ], { origin: "user" }); + setRowSelection({}); + } + + function handlePinSelectedRows(target: "top" | "bottom" | "none") { + const selectedRowIds = getSelectedRowIds(); + if (selectedRowIds.length === 0) { + return; + } + const currentRowPinning = viewState.rowPinning; + const nextRowPinning = getNextRowPinningState( + currentRowPinning, + selectedRowIds, + target, + ); + updateViewState({ rowPinning: nextRowPinning, pageIndex: 0 }); + } + + return { + createDatabaseCellSelection, + findVisibleCellCoordByIds, + normalizeDatabaseCellSelection, + isDatabaseSelectionCurrent, + handleCellMouseDown, + handleCellDoubleClick, + handleToggleAllRows, + handleToggleRow, + getSelectedRowIds, + handleRowSelectionKeyDown, + handleDeleteSelectedRows, + handlePinSelectedRows, + }; +} diff --git a/packages/extensions/database/src/databaseControllerTypes.ts b/packages/extensions/database/src/databaseControllerTypes.ts new file mode 100644 index 0000000..4540711 --- /dev/null +++ b/packages/extensions/database/src/databaseControllerTypes.ts @@ -0,0 +1,137 @@ +import type { BlockHandle, CellSelection } from "@pen/types"; +import type React from "react"; +import type { DatabaseEngine } from "./engine"; +import type { + getColumnStickyStyle, + getFixedEdgeStyle, +} from "./utils/databaseRenderer"; +import type { + ColumnType, + DatabaseColumnDef, + DatabasePage, + DatabaseViewModel, + DatabaseViewModelColumn, + DatabaseViewModelRow, + DatabaseViewState, + FacetBucket, + FilterGroup, +} from "./types"; + +export type CellPointerHandler = ( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, +) => void; + +export interface DatabaseControllerConfig { + blockId: string; +} + +export interface DatabaseController { + block: BlockHandle; + engine: DatabaseEngine; + viewModel: DatabaseViewModel; + columnSchema: DatabaseColumnDef[]; + + viewState: DatabaseViewState; + updateViewState: (patch: Partial>) => void; + views: readonly DatabaseViewState[]; + + title: string; + isEditingTitle: boolean; + setIsEditingTitle: (editing: boolean) => void; + handleTitleClick: () => void; + handleTitleBlur: (event: React.FocusEvent) => void; + handleTitleKeyDown: (event: React.KeyboardEvent) => void; + + addRow: () => void; + addColumn: () => void; + deleteColumn: (columnId: string) => void; + renameColumn: (columnId: string, title: string) => void; + changeColumnType: (columnId: string, type: ColumnType) => void; + toggleColumnVisibility: (columnId: string) => void; + changeColumnPin: (columnId: string, pinned: "left" | "right" | undefined) => void; + addOption: (columnId: string, value: string, color?: string) => void; + renameOption: (columnId: string, optionId: string, value: string) => void; + recolorOption: (columnId: string, optionId: string, color: string) => void; + removeOption: (columnId: string, optionId: string) => void; + moveOption: (columnId: string, optionId: string, direction: "up" | "down") => void; + + addView: (type: DatabaseViewState["type"]) => void; + setActiveView: (viewId: string) => void; + removeView: (viewId: string) => void; + showAddViewMenu: boolean; + setShowAddViewMenu: (show: boolean) => void; + + rowSelection: Record; + toggleRow: (rowId: string) => void; + toggleAllRows: () => void; + deleteSelectedRows: () => void; + pinSelectedRows: (target: "top" | "bottom" | "none") => void; + handleRowSelectionKeyDown: (event: React.KeyboardEvent, rowId: string) => void; + hasSelectedRows: boolean; + selectedRowCount: number; + allVisibleSelected: boolean; + + cellSelection: CellSelection | null; + createCellSelection: (anchor: { row: number; col: number }, head?: { row: number; col: number }) => CellSelection; + handleCellMouseDown: CellPointerHandler; + handleCellDoubleClick: CellPointerHandler; + + globalSearch: string; + setGlobalSearch: (value: string) => void; + + filterGroup: FilterGroup; + handleFilterGroupChange: (filter: FilterGroup | null) => void; + facetBucketsByColumnId: Record; + showFilterPanel: boolean; + setShowFilterPanel: (show: boolean) => void; + + handleSortChange: (sort: NonNullable) => void; + handleHeaderClick: (event: React.MouseEvent, columnId: string) => void; + showSortPanel: boolean; + setShowSortPanel: (show: boolean) => void; + + handleChangeGroupBy: (groupBy: string | null) => void; + showGroupPanel: boolean; + setShowGroupPanel: (show: boolean) => void; + + showColumnVisibilityMenu: boolean; + setShowColumnVisibilityMenu: (show: boolean) => void; + + activeColumnMenu: string | null; + setActiveColumnMenu: (columnId: string | null) => void; + + handlePreviousPage: () => void; + handleNextPage: () => void; + pageCount: number; + showPagination: boolean; + + remoteLoading: boolean; + remoteError: string | null; + + isUiReadonly: boolean; + isDataReadonly: boolean; + showRowSelectionControls: boolean; + + columns: DatabaseViewModelColumn[]; + allRows: DatabaseViewModelRow[]; + rows: DatabaseViewModelRow[]; + pinnedTopRows: DatabaseViewModelRow[]; + pinnedBottomRows: DatabaseViewModelRow[]; + rowGroups: DatabaseViewModel["rowGroups"]; + visibleRows: DatabaseViewModelRow[]; + visibleColumnIds: string[]; + visibleColumnIdSet: ReadonlySet; + + defaultColumnWidth: number; + pinnedOffsets: Record; + getColumnStickyStyle: typeof getColumnStickyStyle; + getFixedEdgeStyle: typeof getFixedEdgeStyle; + isCellSelected: (row: number, column: number) => boolean; + formatRemoteCell: (row: DatabaseViewModelRow, column: DatabaseViewModelColumn) => string; + + calendarMonth: Date; + shiftCalendarMonth: (amount: number) => void; + calendarDateColumn: DatabaseColumnDef | undefined; +} diff --git a/packages/extensions/database/src/engine.ts b/packages/extensions/database/src/engine.ts index 7fd984a..a78b623 100644 --- a/packages/extensions/database/src/engine.ts +++ b/packages/extensions/database/src/engine.ts @@ -1,867 +1,4 @@ -import { - coerceDatabaseValue, - formatStoredMultiSelectValue, - formatStoredSelectValue, - parseDatabaseMultiSelectValue, - resolveStoredSelectOption, -} from "@pen/types"; -import type { BlockHandle, Editor } from "@pen/types"; -import type { - ColumnType, - DatabaseColumnDef, - DatabaseDataProvider, - DatabasePage, - DatabaseQuery, - DatabaseRowGroup, - DatabaseRowPinning, - DatabaseRow, - DatabaseSort, - FacetBucket, - DatabaseViewModel, - DatabaseViewModelColumn, - DatabaseViewModelRow, - DatabaseViewState, - FilterCondition, - FilterGroup, - FilterOperator, - NumberFormat, - DateFormat, -} from "./types"; +import "./engineRows"; +import "./engineFilters"; -const DEFAULT_PAGE_SIZE = 50; -const VALID_COLUMN_TYPES = new Set([ - "text", - "number", - "checkbox", - "select", - "multiSelect", - "date", - "url", - "email", - "relation", - "formula", -]); - -export class DatabaseEngine { - private readonly _editor: Editor; - private readonly _blockId: string; - private _dataProvider: DatabaseDataProvider | null = null; - - constructor(editor: Editor, blockId: string) { - this._editor = editor; - this._blockId = blockId; - } - - get blockId(): string { - return this._blockId; - } - - get editor(): Editor { - return this._editor; - } - - get dataProvider(): DatabaseDataProvider | null { - return this._dataProvider; - } - - setDataProvider(provider: DatabaseDataProvider): void { - this._dataProvider = provider; - } - - get isRemote(): boolean { - const block = this._block; - return block?.props.dataSource === "remote" || block?.props.dataSource === "hybrid"; - } - - private get _block(): BlockHandle | null { - return this._editor.getBlock(this._blockId) ?? null; - } - - deriveColumnSchema(): DatabaseColumnDef[] { - const block = this._block; - if (!block) return []; - return block.tableColumns().map((column, index) => ({ - id: column.id || `col-${index}`, - title: column.title || "Untitled", - type: this.normalizeColumnType(column.type), - width: column.width, - hidden: column.hidden, - pinned: column.pinned, - options: column.options, - format: column.format, - readonly: column.readonly, - })); - } - - deriveRowData(): DatabaseRow[] { - const block = this._block; - if (!block) return []; - const columns = this.deriveColumnSchema(); - const rowCount = block.tableRowCount(); - const columnCount = Math.max(columns.length, block.tableColumnCount()); - const rows: DatabaseRow[] = []; - - for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { - const rowHandle = typeof block.tableRow === "function" ? block.tableRow(rowIndex) : null; - const cells: Record = {}; - for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { - const columnId = columns[columnIndex]?.id ?? `col-${columnIndex}`; - cells[columnId] = block.tableCell(rowIndex, columnIndex)?.textContent() ?? ""; - } - rows.push({ - id: rowHandle?.id ?? `row-${rowIndex}`, - crdtRowIndex: rowIndex, - cells, - }); - } - - return rows; - } - - deriveViewState(): DatabaseViewState { - const block = this._block; - const columns = this.deriveColumnSchema(); - const fallbackColumnIds = columns.map((column) => column.id); - const activeView = block?.databaseActiveView(); - if (activeView) { - return { - ...activeView, - visibleColumnIds: activeView.visibleColumnIds ?? fallbackColumnIds, - columnOrder: activeView.columnOrder ?? fallbackColumnIds, - pageIndex: activeView.pageIndex ?? 0, - pageSize: activeView.pageSize ?? DEFAULT_PAGE_SIZE, - }; - } - - return { - id: "default", - title: "Table view", - type: "table", - visibleColumnIds: fallbackColumnIds, - columnOrder: fallbackColumnIds, - sort: [], - filter: null, - pageIndex: 0, - pageSize: DEFAULT_PAGE_SIZE, - }; - } - - createQuery(options?: { - view?: DatabaseViewState | null; - override?: Partial; - }): DatabaseQuery { - const view = options?.view ?? this.deriveViewState(); - return { - sort: options?.override?.sort ?? view.sort, - filter: options?.override?.filter ?? view.filter ?? undefined, - groupBy: options?.override?.groupBy ?? view.groupBy ?? null, - pageIndex: options?.override?.pageIndex ?? view.pageIndex ?? 0, - pageSize: options?.override?.pageSize ?? view.pageSize ?? DEFAULT_PAGE_SIZE, - }; - } - - buildViewModel(options?: { - view?: DatabaseViewState | null; - rows?: DatabaseRow[]; - globalSearch?: string; - totalRows?: number; - remotePage?: boolean; - }): DatabaseViewModel { - const view = options?.view ?? this.deriveViewState(); - const columns = this.deriveViewColumns(view); - const pageIndex = view.pageIndex ?? 0; - const pageSize = view.pageSize ?? DEFAULT_PAGE_SIZE; - const sourceRows = options?.rows ?? this.deriveRowData(); - - if (options?.remotePage) { - const totalRows = options?.totalRows ?? sourceRows.length; - const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); - return { - view, - columns, - allRows: sourceRows, - pinnedTopRows: [], - rows: sourceRows, - pinnedBottomRows: [], - rowGroups: this.groupRows(sourceRows, view.groupBy ?? null, columns), - totalRows, - pageIndex, - pageSize, - pageCount, - }; - } - - const searchedRows = this.searchRows(sourceRows, options?.globalSearch ?? "", columns); - const filteredRows = this.filterRows(searchedRows, view.filter ?? null, columns); - const sortedRows = this.sortRows(filteredRows, view.sort ?? [], columns); - const pinnedRows = this.splitPinnedRows(sortedRows, view.rowPinning); - const rows = this.paginateRows(pinnedRows.rows, pageIndex, pageSize); - const totalRows = pinnedRows.rows.length; - const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); - - return { - view, - columns, - allRows: sortedRows, - pinnedTopRows: pinnedRows.top, - rows, - pinnedBottomRows: pinnedRows.bottom, - rowGroups: this.groupRows(rows, view.groupBy ?? null, columns), - totalRows, - pageIndex, - pageSize, - pageCount, - }; - } - - buildRemoteViewModel(page: DatabasePage, view?: DatabaseViewState | null): DatabaseViewModel { - return this.buildViewModel({ - view, - rows: page.rows, - totalRows: page.totalRows, - remotePage: true, - }); - } - - searchRows( - rows: DatabaseRow[], - globalSearch: string, - columns?: DatabaseViewModelColumn[], - ): DatabaseRow[] { - const query = globalSearch.trim().toLowerCase(); - if (!query) return rows; - const searchColumns = columns?.length ? columns : null; - return rows.filter((row) => - (searchColumns ?? Object.keys(row.cells).map((columnId) => ({ - id: columnId, - type: "text" as ColumnType, - columnIndex: 0, - title: columnId, - format: undefined, - options: undefined, - }))).some((column) => - this.formatCellDisplay( - row.cells[column.id] ?? "", - column.type, - column.format, - column.options, - ) - .toLowerCase() - .includes(query), - ), - ); - } - - filterRows(rows: DatabaseRow[], filter: FilterGroup | null, columns: DatabaseViewModelColumn[]): DatabaseRow[] { - if (!filter || filter.conditions.length === 0) return rows; - return rows.filter((row) => this.matchesFilterGroup(row, filter, columns)); - } - - sortRows(rows: DatabaseRow[], sorts: DatabaseSort[], columns: DatabaseViewModelColumn[]): DatabaseRow[] { - if (sorts.length === 0) return rows; - const columnMap = new Map(columns.map((column) => [column.id, column])); - return [...rows].sort((left, right) => { - for (const sort of sorts) { - const column = columnMap.get(sort.columnId); - if (!column) continue; - const compare = this.compareCellValues( - left.cells[sort.columnId] ?? "", - right.cells[sort.columnId] ?? "", - column.type, - column.options, - ); - if (compare !== 0) { - return sort.direction === "desc" ? -compare : compare; - } - } - return left.crdtRowIndex - right.crdtRowIndex; - }); - } - - paginateRows(rows: DatabaseRow[], pageIndex: number, pageSize: number): DatabaseViewModelRow[] { - const normalizedPageSize = Math.max(1, pageSize); - const normalizedPageIndex = Math.max(0, pageIndex); - const start = normalizedPageIndex * normalizedPageSize; - return rows.slice(start, start + normalizedPageSize); - } - - splitPinnedRows( - rows: DatabaseRow[], - rowPinning?: DatabaseRowPinning, - ): { - top: DatabaseViewModelRow[]; - rows: DatabaseViewModelRow[]; - bottom: DatabaseViewModelRow[]; - } { - const rowMap = new Map(rows.map((row) => [row.id, row])); - const topRowIds: string[] = [...(rowPinning?.top ?? [])]; - const bottomRowIds: string[] = [...(rowPinning?.bottom ?? [])]; - const top = topRowIds - .map((rowId: string) => rowMap.get(rowId)) - .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); - const bottom = bottomRowIds - .map((rowId: string) => rowMap.get(rowId)) - .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); - const pinnedIds = new Set([...top, ...bottom].map((row) => row.id)); - return { - top, - rows: rows.filter((row) => !pinnedIds.has(row.id)), - bottom, - }; - } - - groupRows( - rows: DatabaseViewModelRow[], - groupBy: string | null, - columns: DatabaseViewModelColumn[], - ): DatabaseRowGroup[] { - if (!groupBy) { - return []; - } - const column = this.resolveGroupingColumn(groupBy, columns); - if (!column) { - return []; - } - const groups: DatabaseRowGroup[] = []; - const groupByKey = new Map(); - for (const row of rows) { - const label = this.formatGroupLabel(row.cells[column.id] ?? "", column); - const key = `${column.id}:${label}`; - const existing = groupByKey.get(key); - if (existing) { - existing.rows.push(row); - continue; - } - const nextGroup: DatabaseRowGroup = { - key, - label, - rows: [row], - }; - groupByKey.set(key, nextGroup); - groups.push(nextGroup); - } - return groups; - } - - facetColumnValues( - rows: DatabaseRow[], - columnId: string, - columns: DatabaseViewModelColumn[], - ): FacetBucket[] { - const column = columns.find((entry) => entry.id === columnId); - if (!column) return []; - const buckets = new Map(); - for (const row of rows) { - const raw = row.cells[columnId] ?? ""; - if (!raw) continue; - if (column.type === "multiSelect") { - const values = parseDatabaseMultiSelectValue(raw); - for (const value of values) { - const option = resolveStoredSelectOption(value, column.options); - const bucketValue = option?.id ?? value; - const bucketLabel = option?.value ?? value; - this.incrementFacetBucket(buckets, bucketValue, bucketLabel); - } - continue; - } - if (column.type === "select") { - const option = resolveStoredSelectOption(raw, column.options); - const bucketValue = option?.id ?? raw; - const bucketLabel = option?.value ?? raw; - this.incrementFacetBucket(buckets, bucketValue, bucketLabel); - continue; - } - if (column.type === "checkbox") { - const bucketValue = raw.toLowerCase() === "true" ? "true" : "false"; - const bucketLabel = bucketValue === "true" ? "Checked" : "Unchecked"; - this.incrementFacetBucket(buckets, bucketValue, bucketLabel); - continue; - } - this.incrementFacetBucket(buckets, raw, raw); - } - return [...buckets.values()].sort((left, right) => - left.label.toLowerCase().localeCompare(right.label.toLowerCase()), - ); - } - - parseCellValue(raw: string, columnType: ColumnType): unknown { - switch (columnType) { - case "number": { - if (raw === "") return null; - const value = Number(raw); - return Number.isNaN(value) ? null : value; - } - case "checkbox": - return raw.toLowerCase() === "true"; - case "date": { - if (raw === "") return null; - const value = new Date(raw); - return Number.isNaN(value.getTime()) ? null : value; - } - case "select": - return raw === "" ? null : raw; - case "multiSelect": - return parseDatabaseMultiSelectValue(raw); - default: - return raw; - } - } - - serializeCellValue(value: unknown, columnType: ColumnType): string { - if (value == null) return ""; - switch (columnType) { - case "checkbox": - return value ? "true" : "false"; - case "date": - return value instanceof Date ? value.toISOString() : String(value); - case "select": - return String(value); - case "multiSelect": - return Array.isArray(value) ? JSON.stringify(value) : ""; - default: - return String(value); - } - } - - validateCellValue(raw: string, columnType: ColumnType): string | null { - switch (columnType) { - case "number": - return raw !== "" && Number.isNaN(Number(raw)) ? "Invalid number" : null; - case "date": - return raw !== "" && Number.isNaN(new Date(raw).getTime()) ? "Invalid date" : null; - case "email": - return raw !== "" && !raw.includes("@") ? "Invalid email" : null; - case "url": - if (raw === "") return null; - try { - new URL(raw); - return null; - } catch { - return "Invalid URL"; - } - default: - return null; - } - } - - formatCellDisplay( - raw: string, - columnType: ColumnType, - format?: NumberFormat | DateFormat, - options?: DatabaseColumnDef["options"], - ): string { - if (raw === "") return ""; - switch (columnType) { - case "number": { - const value = Number(raw); - if (Number.isNaN(value)) return raw; - const numberFormat = format as NumberFormat | undefined; - if (numberFormat?.style === "currency" && numberFormat.currency) { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: numberFormat.currency, - minimumFractionDigits: numberFormat.decimals, - maximumFractionDigits: numberFormat.decimals, - }).format(value); - } - if (numberFormat?.style === "percent") { - return new Intl.NumberFormat(undefined, { - style: "percent", - minimumFractionDigits: numberFormat.decimals, - maximumFractionDigits: numberFormat.decimals, - }).format(value); - } - if (numberFormat?.decimals != null) { - return value.toFixed(numberFormat.decimals); - } - return String(value); - } - case "date": { - const value = new Date(raw); - if (Number.isNaN(value.getTime())) return raw; - const dateFormat = format as DateFormat | undefined; - const options: Intl.DateTimeFormatOptions = { - dateStyle: dateFormat?.dateStyle ?? "medium", - }; - if (dateFormat?.includeTime) { - options.timeStyle = "short"; - } - return new Intl.DateTimeFormat(undefined, options).format(value); - } - case "checkbox": - return raw.toLowerCase() === "true" ? "✓" : ""; - case "select": - return formatStoredSelectValue(raw, options); - case "multiSelect": - return formatStoredMultiSelectValue(raw, options); - default: - return raw; - } - } - - coerceValue( - raw: string, - fromType: ColumnType, - toType: ColumnType, - options?: DatabaseColumnDef["options"], - ): string { - return coerceDatabaseValue(raw, fromType, toType, options); - } - - getRowId(row: DatabaseRow): string { - return row.id; - } - - private deriveViewColumns(view: DatabaseViewState): DatabaseViewModelColumn[] { - const schema = this.deriveColumnSchema(); - const schemaById = new Map(schema.map((column, columnIndex) => [column.id, { column, columnIndex }])); - const columnOrder = view.columnOrder ?? schema.map((column) => column.id); - const visibleColumnIds = new Set( - view.visibleColumnIds ?? schema.filter((column) => !column.hidden).map((column) => column.id), - ); - const orderedIds = [ - ...columnOrder, - ...schema.map((column) => column.id).filter((columnId) => !columnOrder.includes(columnId)), - ]; - - const orderedColumns = orderedIds - .map((columnId) => schemaById.get(columnId)) - .filter((entry): entry is { column: DatabaseColumnDef; columnIndex: number } => entry != null) - .filter(({ column }) => !column.hidden && visibleColumnIds.has(column.id)) - .map(({ column, columnIndex }) => ({ - id: column.id, - title: column.title, - type: this.normalizeColumnType(column.type), - columnIndex, - width: column.width, - hidden: column.hidden, - pinned: column.pinned, - options: column.options, - format: column.format, - readonly: column.readonly, - })); - - const leftColumns = orderedColumns.filter((column) => column.pinned === "left"); - const centerColumns = orderedColumns.filter((column) => column.pinned == null); - const rightColumns = orderedColumns.filter((column) => column.pinned === "right"); - return [...leftColumns, ...centerColumns, ...rightColumns]; - } - - private matchesFilterGroup(row: DatabaseRow, filterGroup: FilterGroup, columns: DatabaseViewModelColumn[]): boolean { - const results = filterGroup.conditions.map((condition) => - this.isFilterGroup(condition) - ? this.matchesFilterGroup(row, condition, columns) - : this.matchesFilterCondition(row, condition, columns), - ); - return filterGroup.operator === "or" ? results.some(Boolean) : results.every(Boolean); - } - - private matchesFilterCondition(row: DatabaseRow, condition: FilterCondition, columns: DatabaseViewModelColumn[]): boolean { - const column = columns.find((entry) => entry.id === condition.columnId); - if (!column) return true; - return this.matchesOperator( - row.cells[condition.columnId] ?? "", - condition.operator, - condition.value, - column.type, - column.options, - ); - } - - private matchesOperator( - rawValue: string, - operator: FilterOperator, - filterValue: string | string[] | null, - columnType: ColumnType, - options?: DatabaseColumnDef["options"], - ): boolean { - const normalizedRawValue = - columnType === "select" - ? resolveStoredSelectOption(rawValue, options)?.id ?? rawValue - : rawValue; - if (columnType === "date") { - return this.matchesDateOperator(normalizedRawValue, operator, filterValue); - } - const lowerValue = normalizedRawValue.toLowerCase(); - switch (operator) { - case "is": - return lowerValue === String(filterValue ?? "").toLowerCase(); - case "is_not": - return lowerValue !== String(filterValue ?? "").toLowerCase(); - case "contains": - if (columnType === "multiSelect") { - return parseDatabaseMultiSelectValue(rawValue).includes( - String(filterValue ?? ""), - ); - } - return lowerValue.includes(String(filterValue ?? "").toLowerCase()); - case "not_contains": - if (columnType === "multiSelect") { - return !parseDatabaseMultiSelectValue(rawValue).includes( - String(filterValue ?? ""), - ); - } - return !lowerValue.includes(String(filterValue ?? "").toLowerCase()); - case "starts_with": - return lowerValue.startsWith(String(filterValue ?? "").toLowerCase()); - case "ends_with": - return lowerValue.endsWith(String(filterValue ?? "").toLowerCase()); - case "is_empty": - return rawValue === ""; - case "is_not_empty": - return rawValue !== ""; - case "is_checked": - return rawValue.toLowerCase() === "true"; - case "is_unchecked": - return rawValue.toLowerCase() !== "true"; - case "is_any_of": { - const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; - if (columnType === "multiSelect") { - const selectedValues = parseDatabaseMultiSelectValue(rawValue); - return values.some((value) => selectedValues.includes(value)); - } - return values.includes(normalizedRawValue); - } - case "is_none_of": { - const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; - if (columnType === "multiSelect") { - const selectedValues = parseDatabaseMultiSelectValue(rawValue); - return values.every((value) => !selectedValues.includes(value)); - } - return !values.includes(normalizedRawValue); - } - case "=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) === 0; - case "!=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) !== 0; - case ">": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) > 0; - case "<": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) < 0; - case ">=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) >= 0; - case "<=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) <= 0; - default: - return true; - } - } - - private matchesDateOperator( - rawValue: string, - operator: FilterOperator, - filterValue: string | string[] | null, - ): boolean { - if (operator === "is_empty") { - return rawValue === ""; - } - if (operator === "is_not_empty") { - return rawValue !== ""; - } - const rawDate = this.parseFilterDate(rawValue); - if (!rawDate) { - return false; - } - switch (operator) { - case "is": { - const targetDate = this.parseFilterDate(String(filterValue ?? "")); - return targetDate ? this.isSameCalendarDay(rawDate, targetDate) : false; - } - case "is_before": { - const targetDate = this.parseFilterDate(String(filterValue ?? "")); - return targetDate ? this.startOfCalendarDay(rawDate) < this.startOfCalendarDay(targetDate) : false; - } - case "is_after": { - const targetDate = this.parseFilterDate(String(filterValue ?? "")); - return targetDate ? this.startOfCalendarDay(rawDate) > this.startOfCalendarDay(targetDate) : false; - } - case "is_between": { - if (!Array.isArray(filterValue) || filterValue.length < 2) { - return false; - } - const startDate = this.parseFilterDate(filterValue[0] ?? ""); - const endDate = this.parseFilterDate(filterValue[1] ?? ""); - if (!startDate || !endDate) { - return false; - } - const rawTime = this.startOfCalendarDay(rawDate); - return rawTime >= this.startOfCalendarDay(startDate) && rawTime <= this.startOfCalendarDay(endDate); - } - case "is_relative": - return this.matchesRelativeDate(rawDate, String(filterValue ?? "")); - default: - return true; - } - } - - private compareCellValues( - left: string, - right: string, - columnType: ColumnType, - options?: DatabaseColumnDef["options"], - ): number { - if (columnType === "select") { - return this.comparePrimitive( - formatStoredSelectValue(left, options), - formatStoredSelectValue(right, options), - "text", - ); - } - if (columnType === "multiSelect") { - return this.comparePrimitive( - formatStoredMultiSelectValue(left, options), - formatStoredMultiSelectValue(right, options), - "text", - ); - } - return this.comparePrimitive(left, right, columnType); - } - - private comparePrimitive(left: string, right: string, columnType: ColumnType): number { - switch (columnType) { - case "number": - return (Number(left) || 0) - (Number(right) || 0); - case "date": - return (new Date(left).getTime() || 0) - (new Date(right).getTime() || 0); - case "checkbox": - return (left.toLowerCase() === "true" ? 1 : 0) - (right.toLowerCase() === "true" ? 1 : 0); - default: - return left.toLowerCase().localeCompare(right.toLowerCase()); - } - } - - private parseFilterDate(raw: string): Date | null { - if (!raw) { - return null; - } - const value = new Date(raw); - return Number.isNaN(value.getTime()) ? null : value; - } - - private startOfCalendarDay(value: Date): number { - return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); - } - - private endOfCalendarDay(value: Date): number { - return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999).getTime(); - } - - private startOfCalendarWeek(value: Date): number { - const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); - nextValue.setDate(nextValue.getDate() - nextValue.getDay()); - return nextValue.getTime(); - } - - private endOfCalendarWeek(value: Date): number { - const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); - nextValue.setDate(nextValue.getDate() + (6 - nextValue.getDay())); - return this.endOfCalendarDay(nextValue); - } - - private startOfCalendarMonth(value: Date): number { - return new Date(value.getFullYear(), value.getMonth(), 1).getTime(); - } - - private endOfCalendarMonth(value: Date): number { - return new Date(value.getFullYear(), value.getMonth() + 1, 0, 23, 59, 59, 999).getTime(); - } - - private isSameCalendarDay(left: Date, right: Date): boolean { - return left.getFullYear() === right.getFullYear() - && left.getMonth() === right.getMonth() - && left.getDate() === right.getDate(); - } - - private matchesRelativeDate(rawDate: Date, relativeValue: string): boolean { - const now = new Date(); - const rawTime = rawDate.getTime(); - const todayStart = this.startOfCalendarDay(now); - switch (relativeValue) { - case "today": - return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(now); - case "yesterday": { - const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); - return rawTime >= this.startOfCalendarDay(yesterday) && rawTime <= this.endOfCalendarDay(yesterday); - } - case "tomorrow": { - const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); - return rawTime >= this.startOfCalendarDay(tomorrow) && rawTime <= this.endOfCalendarDay(tomorrow); - } - case "this_week": - return rawTime >= this.startOfCalendarWeek(now) && rawTime <= this.endOfCalendarWeek(now); - case "last_7_days": { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6); - return rawTime >= this.startOfCalendarDay(start) && rawTime <= this.endOfCalendarDay(now); - } - case "next_7_days": { - const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 6); - return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(end); - } - case "this_month": - return rawTime >= this.startOfCalendarMonth(now) && rawTime <= this.endOfCalendarMonth(now); - default: - return false; - } - } - - private incrementFacetBucket( - buckets: Map, - value: string, - label: string, - ): void { - const existing = buckets.get(value); - if (existing) { - existing.count += 1; - return; - } - buckets.set(value, { value, label, count: 1 }); - } - - private formatGroupLabel( - raw: string, - column: DatabaseViewModelColumn, - ): string { - const formatted = this.formatCellDisplay( - raw, - column.type, - column.format, - column.options, - ); - return formatted || "(empty)"; - } - - private resolveGroupingColumn( - groupBy: string, - columns: DatabaseViewModelColumn[], - ): DatabaseViewModelColumn | null { - const visibleColumn = columns.find((entry) => entry.id === groupBy); - if (visibleColumn) { - return visibleColumn; - } - const schema = this.deriveColumnSchema(); - const schemaColumn = schema.find((entry) => entry.id === groupBy); - if (!schemaColumn) { - return null; - } - return { - id: schemaColumn.id, - title: schemaColumn.title, - type: this.normalizeColumnType(schemaColumn.type), - columnIndex: schema.findIndex((entry) => entry.id === groupBy), - width: schemaColumn.width, - hidden: schemaColumn.hidden, - pinned: schemaColumn.pinned, - options: schemaColumn.options, - format: schemaColumn.format, - readonly: schemaColumn.readonly, - }; - } - - private normalizeColumnType(type: string | undefined): ColumnType { - return type && VALID_COLUMN_TYPES.has(type as ColumnType) ? (type as ColumnType) : "text"; - } - - private isFilterGroup(value: FilterCondition | FilterGroup): value is FilterGroup { - return "conditions" in value; - } -} +export { DatabaseEngine } from "./engineCore"; diff --git a/packages/extensions/database/src/engineCore.ts b/packages/extensions/database/src/engineCore.ts new file mode 100644 index 0000000..e83c2e3 --- /dev/null +++ b/packages/extensions/database/src/engineCore.ts @@ -0,0 +1,453 @@ +import { + coerceDatabaseValue, + formatStoredMultiSelectValue, + formatStoredSelectValue, + parseDatabaseMultiSelectValue, + resolveStoredSelectOption, +} from "@pen/types"; +import type { BlockHandle, Editor } from "@pen/types"; +import type { + ColumnType, + DatabaseColumnDef, + DatabaseDataProvider, + DatabasePage, + DatabaseQuery, + DatabaseRowGroup, + DatabaseRowPinning, + DatabaseRow, + DatabaseSort, + FacetBucket, + DatabaseViewModel, + DatabaseViewModelColumn, + DatabaseViewModelRow, + DatabaseViewState, + FilterCondition, + FilterGroup, + FilterOperator, + NumberFormat, + DateFormat, +} from "./types"; + +const DEFAULT_PAGE_SIZE = 50; +export const VALID_COLUMN_TYPES = new Set([ + "text", + "number", + "checkbox", + "select", + "multiSelect", + "date", + "url", + "email", + "relation", + "formula", +]); + +export interface DatabaseEngine { + filterRows(rows: DatabaseRow[], filter: FilterGroup | null, columns: DatabaseViewModelColumn[]): DatabaseRow[]; + sortRows(rows: DatabaseRow[], sorts: DatabaseSort[], columns: DatabaseViewModelColumn[]): DatabaseRow[]; + paginateRows(rows: DatabaseRow[], pageIndex: number, pageSize: number): DatabaseViewModelRow[]; + splitPinnedRows( + rows: DatabaseRow[], + rowPinning?: DatabaseRowPinning, + ): { + top: DatabaseViewModelRow[]; + rows: DatabaseViewModelRow[]; + bottom: DatabaseViewModelRow[]; + }; + groupRows( + rows: DatabaseViewModelRow[], + groupBy: string | null, + columns: DatabaseViewModelColumn[], + ): DatabaseRowGroup[]; + facetColumnValues( + rows: DatabaseRow[], + columnId: string, + columns: DatabaseViewModelColumn[], + ): FacetBucket[]; + deriveViewColumns(view: DatabaseViewState): DatabaseViewModelColumn[]; + matchesFilterGroup(row: DatabaseRow, filterGroup: FilterGroup, columns: DatabaseViewModelColumn[]): boolean; + matchesFilterCondition(row: DatabaseRow, condition: FilterCondition, columns: DatabaseViewModelColumn[]): boolean; + matchesOperator( + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], + ): boolean; + matchesDateOperator( + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, + ): boolean; + compareCellValues( + left: string, + right: string, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], + ): number; + comparePrimitive(left: string, right: string, columnType: ColumnType): number; + parseFilterDate(raw: string): Date | null; + startOfCalendarDay(value: Date): number; + endOfCalendarDay(value: Date): number; + startOfCalendarWeek(value: Date): number; + endOfCalendarWeek(value: Date): number; + startOfCalendarMonth(value: Date): number; + endOfCalendarMonth(value: Date): number; + isSameCalendarDay(left: Date, right: Date): boolean; + matchesRelativeDate(rawDate: Date, relativeValue: string): boolean; + incrementFacetBucket( + buckets: Map, + value: string, + label: string, + ): void; + formatGroupLabel( + raw: string, + column: DatabaseViewModelColumn, + ): string; + resolveGroupingColumn( + groupBy: string, + columns: DatabaseViewModelColumn[], + ): DatabaseViewModelColumn | null; + normalizeColumnType(type: string | undefined): ColumnType; + isFilterGroup(value: FilterCondition | FilterGroup): value is FilterGroup; +} + +export class DatabaseEngine { + private readonly _editor: Editor; + private readonly _blockId: string; + private _dataProvider: DatabaseDataProvider | null = null; + + constructor(editor: Editor, blockId: string) { + this._editor = editor; + this._blockId = blockId; + } + + get blockId(): string { + return this._blockId; + } + + get editor(): Editor { + return this._editor; + } + + get dataProvider(): DatabaseDataProvider | null { + return this._dataProvider; + } + + setDataProvider(provider: DatabaseDataProvider): void { + this._dataProvider = provider; + } + + get isRemote(): boolean { + const block = this._block; + return block?.props.dataSource === "remote" || block?.props.dataSource === "hybrid"; + } + + private get _block(): BlockHandle | null { + return this._editor.getBlock(this._blockId) ?? null; + } + + deriveColumnSchema(): DatabaseColumnDef[] { + const block = this._block; + if (!block) return []; + return block.tableColumns().map((column, index) => ({ + id: column.id || `col-${index}`, + title: column.title || "Untitled", + type: this.normalizeColumnType(column.type), + width: column.width, + hidden: column.hidden, + pinned: column.pinned, + options: column.options, + format: column.format, + readonly: column.readonly, + })); + } + + deriveRowData(): DatabaseRow[] { + const block = this._block; + if (!block) return []; + const columns = this.deriveColumnSchema(); + const rowCount = block.tableRowCount(); + const columnCount = Math.max(columns.length, block.tableColumnCount()); + const rows: DatabaseRow[] = []; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const rowHandle = typeof block.tableRow === "function" ? block.tableRow(rowIndex) : null; + const cells: Record = {}; + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + const columnId = columns[columnIndex]?.id ?? `col-${columnIndex}`; + cells[columnId] = block.tableCell(rowIndex, columnIndex)?.textContent() ?? ""; + } + rows.push({ + id: rowHandle?.id ?? `row-${rowIndex}`, + crdtRowIndex: rowIndex, + cells, + }); + } + + return rows; + } + + deriveViewState(): DatabaseViewState { + const block = this._block; + const columns = this.deriveColumnSchema(); + const fallbackColumnIds = columns.map((column) => column.id); + const activeView = block?.databaseActiveView(); + if (activeView) { + return { + ...activeView, + visibleColumnIds: activeView.visibleColumnIds ?? fallbackColumnIds, + columnOrder: activeView.columnOrder ?? fallbackColumnIds, + pageIndex: activeView.pageIndex ?? 0, + pageSize: activeView.pageSize ?? DEFAULT_PAGE_SIZE, + }; + } + + return { + id: "default", + title: "Table view", + type: "table", + visibleColumnIds: fallbackColumnIds, + columnOrder: fallbackColumnIds, + sort: [], + filter: null, + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + }; + } + + createQuery(options?: { + view?: DatabaseViewState | null; + override?: Partial; + }): DatabaseQuery { + const view = options?.view ?? this.deriveViewState(); + return { + sort: options?.override?.sort ?? view.sort, + filter: options?.override?.filter ?? view.filter ?? undefined, + groupBy: options?.override?.groupBy ?? view.groupBy ?? null, + pageIndex: options?.override?.pageIndex ?? view.pageIndex ?? 0, + pageSize: options?.override?.pageSize ?? view.pageSize ?? DEFAULT_PAGE_SIZE, + }; + } + + buildViewModel(options?: { + view?: DatabaseViewState | null; + rows?: DatabaseRow[]; + globalSearch?: string; + totalRows?: number; + remotePage?: boolean; + }): DatabaseViewModel { + const view = options?.view ?? this.deriveViewState(); + const columns = this.deriveViewColumns(view); + const pageIndex = view.pageIndex ?? 0; + const pageSize = view.pageSize ?? DEFAULT_PAGE_SIZE; + const sourceRows = options?.rows ?? this.deriveRowData(); + + if (options?.remotePage) { + const totalRows = options?.totalRows ?? sourceRows.length; + const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); + return { + view, + columns, + allRows: sourceRows, + pinnedTopRows: [], + rows: sourceRows, + pinnedBottomRows: [], + rowGroups: this.groupRows(sourceRows, view.groupBy ?? null, columns), + totalRows, + pageIndex, + pageSize, + pageCount, + }; + } + + const searchedRows = this.searchRows(sourceRows, options?.globalSearch ?? "", columns); + const filteredRows = this.filterRows(searchedRows, view.filter ?? null, columns); + const sortedRows = this.sortRows(filteredRows, view.sort ?? [], columns); + const pinnedRows = this.splitPinnedRows(sortedRows, view.rowPinning); + const rows = this.paginateRows(pinnedRows.rows, pageIndex, pageSize); + const totalRows = pinnedRows.rows.length; + const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); + + return { + view, + columns, + allRows: sortedRows, + pinnedTopRows: pinnedRows.top, + rows, + pinnedBottomRows: pinnedRows.bottom, + rowGroups: this.groupRows(rows, view.groupBy ?? null, columns), + totalRows, + pageIndex, + pageSize, + pageCount, + }; + } + + buildRemoteViewModel(page: DatabasePage, view?: DatabaseViewState | null): DatabaseViewModel { + return this.buildViewModel({ + view, + rows: page.rows, + totalRows: page.totalRows, + remotePage: true, + }); + } + + searchRows( + rows: DatabaseRow[], + globalSearch: string, + columns?: DatabaseViewModelColumn[], + ): DatabaseRow[] { + const query = globalSearch.trim().toLowerCase(); + if (!query) return rows; + const searchColumns = columns?.length ? columns : null; + return rows.filter((row) => + (searchColumns ?? Object.keys(row.cells).map((columnId) => ({ + id: columnId, + type: "text" as ColumnType, + columnIndex: 0, + title: columnId, + format: undefined, + options: undefined, + }))).some((column) => + this.formatCellDisplay( + row.cells[column.id] ?? "", + column.type, + column.format, + column.options, + ) + .toLowerCase() + .includes(query), + ), + ); + } + + parseCellValue(raw: string, columnType: ColumnType): unknown { + switch (columnType) { + case "number": { + if (raw === "") return null; + const value = Number(raw); + return Number.isNaN(value) ? null : value; + } + case "checkbox": + return raw.toLowerCase() === "true"; + case "date": { + if (raw === "") return null; + const value = new Date(raw); + return Number.isNaN(value.getTime()) ? null : value; + } + case "select": + return raw === "" ? null : raw; + case "multiSelect": + return parseDatabaseMultiSelectValue(raw); + default: + return raw; + } + } + + serializeCellValue(value: unknown, columnType: ColumnType): string { + if (value == null) return ""; + switch (columnType) { + case "checkbox": + return value ? "true" : "false"; + case "date": + return value instanceof Date ? value.toISOString() : String(value); + case "select": + return String(value); + case "multiSelect": + return Array.isArray(value) ? JSON.stringify(value) : ""; + default: + return String(value); + } + } + + validateCellValue(raw: string, columnType: ColumnType): string | null { + switch (columnType) { + case "number": + return raw !== "" && Number.isNaN(Number(raw)) ? "Invalid number" : null; + case "date": + return raw !== "" && Number.isNaN(new Date(raw).getTime()) ? "Invalid date" : null; + case "email": + return raw !== "" && !raw.includes("@") ? "Invalid email" : null; + case "url": + if (raw === "") return null; + try { + new URL(raw); + return null; + } catch { + return "Invalid URL"; + } + default: + return null; + } + } + + formatCellDisplay( + raw: string, + columnType: ColumnType, + format?: NumberFormat | DateFormat, + options?: DatabaseColumnDef["options"], + ): string { + if (raw === "") return ""; + switch (columnType) { + case "number": { + const value = Number(raw); + if (Number.isNaN(value)) return raw; + const numberFormat = format as NumberFormat | undefined; + if (numberFormat?.style === "currency" && numberFormat.currency) { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: numberFormat.currency, + minimumFractionDigits: numberFormat.decimals, + maximumFractionDigits: numberFormat.decimals, + }).format(value); + } + if (numberFormat?.style === "percent") { + return new Intl.NumberFormat(undefined, { + style: "percent", + minimumFractionDigits: numberFormat.decimals, + maximumFractionDigits: numberFormat.decimals, + }).format(value); + } + if (numberFormat?.decimals != null) { + return value.toFixed(numberFormat.decimals); + } + return String(value); + } + case "date": { + const value = new Date(raw); + if (Number.isNaN(value.getTime())) return raw; + const dateFormat = format as DateFormat | undefined; + const options: Intl.DateTimeFormatOptions = { + dateStyle: dateFormat?.dateStyle ?? "medium", + }; + if (dateFormat?.includeTime) { + options.timeStyle = "short"; + } + return new Intl.DateTimeFormat(undefined, options).format(value); + } + case "checkbox": + return raw.toLowerCase() === "true" ? "✓" : ""; + case "select": + return formatStoredSelectValue(raw, options); + case "multiSelect": + return formatStoredMultiSelectValue(raw, options); + default: + return raw; + } + } + + coerceValue( + raw: string, + fromType: ColumnType, + toType: ColumnType, + options?: DatabaseColumnDef["options"], + ): string { + return coerceDatabaseValue(raw, fromType, toType, options); + } + + getRowId(row: DatabaseRow): string { + return row.id; + } + +} diff --git a/packages/extensions/database/src/engineFilters.ts b/packages/extensions/database/src/engineFilters.ts new file mode 100644 index 0000000..8b11d0d --- /dev/null +++ b/packages/extensions/database/src/engineFilters.ts @@ -0,0 +1,376 @@ +import { + formatStoredMultiSelectValue, + formatStoredSelectValue, + parseDatabaseMultiSelectValue, + resolveStoredSelectOption, +} from "@pen/types"; +import type { DatabaseEngine } from "./engineCore"; +import { + DatabaseEngine as DatabaseEngineClass, + VALID_COLUMN_TYPES, +} from "./engineCore"; +import type { + ColumnType, + DatabaseColumnDef, + DatabaseRow, + DatabaseViewModelColumn, + DatabaseViewState, + FacetBucket, + FilterCondition, + FilterGroup, + FilterOperator, +} from "./types"; + +DatabaseEngineClass.prototype.deriveViewColumns = function deriveViewColumns(this: DatabaseEngine, view: DatabaseViewState): DatabaseViewModelColumn[] { + const schema = this.deriveColumnSchema(); + const schemaById = new Map(schema.map((column, columnIndex) => [column.id, { column, columnIndex }])); + const columnOrder = view.columnOrder ?? schema.map((column) => column.id); + const visibleColumnIds = new Set( + view.visibleColumnIds ?? schema.filter((column) => !column.hidden).map((column) => column.id), + ); + const orderedIds = [ + ...columnOrder, + ...schema.map((column) => column.id).filter((columnId) => !columnOrder.includes(columnId)), + ]; + + const orderedColumns = orderedIds + .map((columnId) => schemaById.get(columnId)) + .filter((entry): entry is { column: DatabaseColumnDef; columnIndex: number } => entry != null) + .filter(({ column }) => !column.hidden && visibleColumnIds.has(column.id)) + .map(({ column, columnIndex }) => ({ + id: column.id, + title: column.title, + type: this.normalizeColumnType(column.type), + columnIndex, + width: column.width, + hidden: column.hidden, + pinned: column.pinned, + options: column.options, + format: column.format, + readonly: column.readonly, + })); + + const leftColumns = orderedColumns.filter((column) => column.pinned === "left"); + const centerColumns = orderedColumns.filter((column) => column.pinned == null); + const rightColumns = orderedColumns.filter((column) => column.pinned === "right"); + return [...leftColumns, ...centerColumns, ...rightColumns]; +} +; +DatabaseEngineClass.prototype.matchesFilterGroup = function matchesFilterGroup(this: DatabaseEngine, row: DatabaseRow, filterGroup: FilterGroup, columns: DatabaseViewModelColumn[]): boolean { + const results = filterGroup.conditions.map((condition) => + this.isFilterGroup(condition) + ? this.matchesFilterGroup(row, condition, columns) + : this.matchesFilterCondition(row, condition, columns), + ); + return filterGroup.operator === "or" ? results.some(Boolean) : results.every(Boolean); +} +; +DatabaseEngineClass.prototype.matchesFilterCondition = function matchesFilterCondition(this: DatabaseEngine, row: DatabaseRow, condition: FilterCondition, columns: DatabaseViewModelColumn[]): boolean { + const column = columns.find((entry) => entry.id === condition.columnId); + if (!column) return true; + return this.matchesOperator( + row.cells[condition.columnId] ?? "", + condition.operator, + condition.value, + column.type, + column.options, + ); +} +; +DatabaseEngineClass.prototype.matchesOperator = function matchesOperator(this: DatabaseEngine, + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], +): boolean { + const normalizedRawValue = + columnType === "select" + ? resolveStoredSelectOption(rawValue, options)?.id ?? rawValue + : rawValue; + if (columnType === "date") { + return this.matchesDateOperator(normalizedRawValue, operator, filterValue); + } + const lowerValue = normalizedRawValue.toLowerCase(); + switch (operator) { + case "is": + return lowerValue === String(filterValue ?? "").toLowerCase(); + case "is_not": + return lowerValue !== String(filterValue ?? "").toLowerCase(); + case "contains": + if (columnType === "multiSelect") { + return parseDatabaseMultiSelectValue(rawValue).includes( + String(filterValue ?? ""), + ); + } + return lowerValue.includes(String(filterValue ?? "").toLowerCase()); + case "not_contains": + if (columnType === "multiSelect") { + return !parseDatabaseMultiSelectValue(rawValue).includes( + String(filterValue ?? ""), + ); + } + return !lowerValue.includes(String(filterValue ?? "").toLowerCase()); + case "starts_with": + return lowerValue.startsWith(String(filterValue ?? "").toLowerCase()); + case "ends_with": + return lowerValue.endsWith(String(filterValue ?? "").toLowerCase()); + case "is_empty": + return rawValue === ""; + case "is_not_empty": + return rawValue !== ""; + case "is_checked": + return rawValue.toLowerCase() === "true"; + case "is_unchecked": + return rawValue.toLowerCase() !== "true"; + case "is_any_of": { + const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; + if (columnType === "multiSelect") { + const selectedValues = parseDatabaseMultiSelectValue(rawValue); + return values.some((value) => selectedValues.includes(value)); + } + return values.includes(normalizedRawValue); + } + case "is_none_of": { + const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; + if (columnType === "multiSelect") { + const selectedValues = parseDatabaseMultiSelectValue(rawValue); + return values.every((value) => !selectedValues.includes(value)); + } + return !values.includes(normalizedRawValue); + } + case "=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) === 0; + case "!=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) !== 0; + case ">": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) > 0; + case "<": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) < 0; + case ">=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) >= 0; + case "<=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) <= 0; + default: + return true; + } +} +; +DatabaseEngineClass.prototype.matchesDateOperator = function matchesDateOperator(this: DatabaseEngine, + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, +): boolean { + if (operator === "is_empty") { + return rawValue === ""; + } + if (operator === "is_not_empty") { + return rawValue !== ""; + } + const rawDate = this.parseFilterDate(rawValue); + if (!rawDate) { + return false; + } + switch (operator) { + case "is": { + const targetDate = this.parseFilterDate(String(filterValue ?? "")); + return targetDate ? this.isSameCalendarDay(rawDate, targetDate) : false; + } + case "is_before": { + const targetDate = this.parseFilterDate(String(filterValue ?? "")); + return targetDate ? this.startOfCalendarDay(rawDate) < this.startOfCalendarDay(targetDate) : false; + } + case "is_after": { + const targetDate = this.parseFilterDate(String(filterValue ?? "")); + return targetDate ? this.startOfCalendarDay(rawDate) > this.startOfCalendarDay(targetDate) : false; + } + case "is_between": { + if (!Array.isArray(filterValue) || filterValue.length < 2) { + return false; + } + const startDate = this.parseFilterDate(filterValue[0] ?? ""); + const endDate = this.parseFilterDate(filterValue[1] ?? ""); + if (!startDate || !endDate) { + return false; + } + const rawTime = this.startOfCalendarDay(rawDate); + return rawTime >= this.startOfCalendarDay(startDate) && rawTime <= this.startOfCalendarDay(endDate); + } + case "is_relative": + return this.matchesRelativeDate(rawDate, String(filterValue ?? "")); + default: + return true; + } +} +; +DatabaseEngineClass.prototype.compareCellValues = function compareCellValues(this: DatabaseEngine, + left: string, + right: string, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], +): number { + if (columnType === "select") { + return this.comparePrimitive( + formatStoredSelectValue(left, options), + formatStoredSelectValue(right, options), + "text", + ); + } + if (columnType === "multiSelect") { + return this.comparePrimitive( + formatStoredMultiSelectValue(left, options), + formatStoredMultiSelectValue(right, options), + "text", + ); + } + return this.comparePrimitive(left, right, columnType); +} +; +DatabaseEngineClass.prototype.comparePrimitive = function comparePrimitive(this: DatabaseEngine, left: string, right: string, columnType: ColumnType): number { + switch (columnType) { + case "number": + return (Number(left) || 0) - (Number(right) || 0); + case "date": + return (new Date(left).getTime() || 0) - (new Date(right).getTime() || 0); + case "checkbox": + return (left.toLowerCase() === "true" ? 1 : 0) - (right.toLowerCase() === "true" ? 1 : 0); + default: + return left.toLowerCase().localeCompare(right.toLowerCase()); + } +} +; +DatabaseEngineClass.prototype.parseFilterDate = function parseFilterDate(this: DatabaseEngine, raw: string): Date | null { + if (!raw) { + return null; + } + const value = new Date(raw); + return Number.isNaN(value.getTime()) ? null : value; +} +; +DatabaseEngineClass.prototype.startOfCalendarDay = function startOfCalendarDay(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +} +; +DatabaseEngineClass.prototype.endOfCalendarDay = function endOfCalendarDay(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999).getTime(); +} +; +DatabaseEngineClass.prototype.startOfCalendarWeek = function startOfCalendarWeek(this: DatabaseEngine, value: Date): number { + const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); + nextValue.setDate(nextValue.getDate() - nextValue.getDay()); + return nextValue.getTime(); +} +; +DatabaseEngineClass.prototype.endOfCalendarWeek = function endOfCalendarWeek(this: DatabaseEngine, value: Date): number { + const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); + nextValue.setDate(nextValue.getDate() + (6 - nextValue.getDay())); + return this.endOfCalendarDay(nextValue); +} +; +DatabaseEngineClass.prototype.startOfCalendarMonth = function startOfCalendarMonth(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), 1).getTime(); +} +; +DatabaseEngineClass.prototype.endOfCalendarMonth = function endOfCalendarMonth(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth() + 1, 0, 23, 59, 59, 999).getTime(); +} +; +DatabaseEngineClass.prototype.isSameCalendarDay = function isSameCalendarDay(this: DatabaseEngine, left: Date, right: Date): boolean { + return left.getFullYear() === right.getFullYear() + && left.getMonth() === right.getMonth() + && left.getDate() === right.getDate(); +} +; +DatabaseEngineClass.prototype.matchesRelativeDate = function matchesRelativeDate(this: DatabaseEngine, rawDate: Date, relativeValue: string): boolean { + const now = new Date(); + const rawTime = rawDate.getTime(); + const todayStart = this.startOfCalendarDay(now); + switch (relativeValue) { + case "today": + return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(now); + case "yesterday": { + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + return rawTime >= this.startOfCalendarDay(yesterday) && rawTime <= this.endOfCalendarDay(yesterday); + } + case "tomorrow": { + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return rawTime >= this.startOfCalendarDay(tomorrow) && rawTime <= this.endOfCalendarDay(tomorrow); + } + case "this_week": + return rawTime >= this.startOfCalendarWeek(now) && rawTime <= this.endOfCalendarWeek(now); + case "last_7_days": { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6); + return rawTime >= this.startOfCalendarDay(start) && rawTime <= this.endOfCalendarDay(now); + } + case "next_7_days": { + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 6); + return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(end); + } + case "this_month": + return rawTime >= this.startOfCalendarMonth(now) && rawTime <= this.endOfCalendarMonth(now); + default: + return false; + } +} +; +DatabaseEngineClass.prototype.incrementFacetBucket = function incrementFacetBucket(this: DatabaseEngine, + buckets: Map, + value: string, + label: string, +): void { + const existing = buckets.get(value); + if (existing) { + existing.count += 1; + return; + } + buckets.set(value, { value, label, count: 1 }); +} +; +DatabaseEngineClass.prototype.formatGroupLabel = function formatGroupLabel(this: DatabaseEngine, + raw: string, + column: DatabaseViewModelColumn, +): string { + const formatted = this.formatCellDisplay( + raw, + column.type, + column.format, + column.options, + ); + return formatted || "(empty)"; +} +; +DatabaseEngineClass.prototype.resolveGroupingColumn = function resolveGroupingColumn(this: DatabaseEngine, + groupBy: string, + columns: DatabaseViewModelColumn[], +): DatabaseViewModelColumn | null { + const visibleColumn = columns.find((entry) => entry.id === groupBy); + if (visibleColumn) { + return visibleColumn; + } + const schema = this.deriveColumnSchema(); + const schemaColumn = schema.find((entry) => entry.id === groupBy); + if (!schemaColumn) { + return null; + } + return { + id: schemaColumn.id, + title: schemaColumn.title, + type: this.normalizeColumnType(schemaColumn.type), + columnIndex: schema.findIndex((entry) => entry.id === groupBy), + width: schemaColumn.width, + hidden: schemaColumn.hidden, + pinned: schemaColumn.pinned, + options: schemaColumn.options, + format: schemaColumn.format, + readonly: schemaColumn.readonly, + }; +} +; +DatabaseEngineClass.prototype.normalizeColumnType = function normalizeColumnType(this: DatabaseEngine, type: string | undefined): ColumnType { + return type && VALID_COLUMN_TYPES.has(type as ColumnType) ? (type as ColumnType) : "text"; +} +; +DatabaseEngineClass.prototype.isFilterGroup = function isFilterGroup(this: DatabaseEngine, value: FilterCondition | FilterGroup): value is FilterGroup { + return "conditions" in value; +} +; diff --git a/packages/extensions/database/src/engineRows.ts b/packages/extensions/database/src/engineRows.ts new file mode 100644 index 0000000..b2fa729 --- /dev/null +++ b/packages/extensions/database/src/engineRows.ts @@ -0,0 +1,151 @@ +import { + parseDatabaseMultiSelectValue, + resolveStoredSelectOption, +} from "@pen/types"; +import type { DatabaseEngine } from "./engineCore"; +import { DatabaseEngine as DatabaseEngineClass } from "./engineCore"; +import type { + ColumnType, + DatabaseColumnDef, + DatabaseRow, + DatabaseRowGroup, + DatabaseRowPinning, + DatabaseSort, + DatabaseViewModelColumn, + DatabaseViewModelRow, + FacetBucket, + FilterGroup, +} from "./types"; + +DatabaseEngineClass.prototype.filterRows = function filterRows(this: DatabaseEngine, rows: DatabaseRow[], filter: FilterGroup | null, columns: DatabaseViewModelColumn[]): DatabaseRow[] { + if (!filter || filter.conditions.length === 0) return rows; + return rows.filter((row) => this.matchesFilterGroup(row, filter, columns)); +} +; +DatabaseEngineClass.prototype.sortRows = function sortRows(this: DatabaseEngine, rows: DatabaseRow[], sorts: DatabaseSort[], columns: DatabaseViewModelColumn[]): DatabaseRow[] { + if (sorts.length === 0) return rows; + const columnMap = new Map(columns.map((column) => [column.id, column])); + return [...rows].sort((left, right) => { + for (const sort of sorts) { + const column = columnMap.get(sort.columnId); + if (!column) continue; + const compare = this.compareCellValues( + left.cells[sort.columnId] ?? "", + right.cells[sort.columnId] ?? "", + column.type, + column.options, + ); + if (compare !== 0) { + return sort.direction === "desc" ? -compare : compare; + } + } + return left.crdtRowIndex - right.crdtRowIndex; + }); +} +; +DatabaseEngineClass.prototype.paginateRows = function paginateRows(this: DatabaseEngine, rows: DatabaseRow[], pageIndex: number, pageSize: number): DatabaseViewModelRow[] { + const normalizedPageSize = Math.max(1, pageSize); + const normalizedPageIndex = Math.max(0, pageIndex); + const start = normalizedPageIndex * normalizedPageSize; + return rows.slice(start, start + normalizedPageSize); +} +; +DatabaseEngineClass.prototype.splitPinnedRows = function splitPinnedRows(this: DatabaseEngine, + rows: DatabaseRow[], + rowPinning?: DatabaseRowPinning, +): { + top: DatabaseViewModelRow[]; + rows: DatabaseViewModelRow[]; + bottom: DatabaseViewModelRow[]; +} { + const rowMap = new Map(rows.map((row) => [row.id, row])); + const topRowIds: string[] = [...(rowPinning?.top ?? [])]; + const bottomRowIds: string[] = [...(rowPinning?.bottom ?? [])]; + const top = topRowIds + .map((rowId: string) => rowMap.get(rowId)) + .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); + const bottom = bottomRowIds + .map((rowId: string) => rowMap.get(rowId)) + .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); + const pinnedIds = new Set([...top, ...bottom].map((row) => row.id)); + return { + top, + rows: rows.filter((row) => !pinnedIds.has(row.id)), + bottom, + }; +} +; +DatabaseEngineClass.prototype.groupRows = function groupRows(this: DatabaseEngine, + rows: DatabaseViewModelRow[], + groupBy: string | null, + columns: DatabaseViewModelColumn[], +): DatabaseRowGroup[] { + if (!groupBy) { + return []; + } + const column = this.resolveGroupingColumn(groupBy, columns); + if (!column) { + return []; + } + const groups: DatabaseRowGroup[] = []; + const groupByKey = new Map(); + for (const row of rows) { + const label = this.formatGroupLabel(row.cells[column.id] ?? "", column); + const key = `${column.id}:${label}`; + const existing = groupByKey.get(key); + if (existing) { + existing.rows.push(row); + continue; + } + const nextGroup: DatabaseRowGroup = { + key, + label, + rows: [row], + }; + groupByKey.set(key, nextGroup); + groups.push(nextGroup); + } + return groups; +} +; +DatabaseEngineClass.prototype.facetColumnValues = function facetColumnValues(this: DatabaseEngine, + rows: DatabaseRow[], + columnId: string, + columns: DatabaseViewModelColumn[], +): FacetBucket[] { + const column = columns.find((entry) => entry.id === columnId); + if (!column) return []; + const buckets = new Map(); + for (const row of rows) { + const raw = row.cells[columnId] ?? ""; + if (!raw) continue; + if (column.type === "multiSelect") { + const values = parseDatabaseMultiSelectValue(raw); + for (const value of values) { + const option = resolveStoredSelectOption(value, column.options); + const bucketValue = option?.id ?? value; + const bucketLabel = option?.value ?? value; + this.incrementFacetBucket(buckets, bucketValue, bucketLabel); + } + continue; + } + if (column.type === "select") { + const option = resolveStoredSelectOption(raw, column.options); + const bucketValue = option?.id ?? raw; + const bucketLabel = option?.value ?? raw; + this.incrementFacetBucket(buckets, bucketValue, bucketLabel); + continue; + } + if (column.type === "checkbox") { + const bucketValue = raw.toLowerCase() === "true" ? "true" : "false"; + const bucketLabel = bucketValue === "true" ? "Checked" : "Unchecked"; + this.incrementFacetBucket(buckets, bucketValue, bucketLabel); + continue; + } + this.incrementFacetBucket(buckets, raw, raw); + } + return [...buckets.values()].sort((left, right) => + left.label.toLowerCase().localeCompare(right.label.toLowerCase()), + ); +} +; diff --git a/packages/extensions/database/src/rendererColumnMenu.tsx b/packages/extensions/database/src/rendererColumnMenu.tsx new file mode 100644 index 0000000..5bfd63f --- /dev/null +++ b/packages/extensions/database/src/rendererColumnMenu.tsx @@ -0,0 +1,273 @@ +import { DATA_ATTRS } from "@pen/react"; +import { useEffect, useState } from "react"; +import type { ColumnType, DatabaseColumnDef } from "./types"; + +const COLUMN_TYPES: ColumnType[] = [ + "text", + "number", + "checkbox", + "select", + "multiSelect", + "date", + "url", + "email", + "relation", +]; + +const OPTION_COLOR_CHOICES = [ + "gray", + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "pink", +] as const; +export function ColumnMenu(props: { + column: DatabaseColumnDef | undefined; + onClose: () => void; + onRename: (title: string) => void; + onChangeType: (type: ColumnType) => void; + onDelete: () => void; + onToggleVisibility: () => void; + onChangePin: (nextPinned: "left" | "right" | undefined) => void; + onAddOption: (value: string, color?: string) => void; + onRenameOption: (optionId: string, value: string) => void; + onRecolorOption: (optionId: string, color: string) => void; + onRemoveOption: (optionId: string) => void; + onMoveOption: (optionId: string, direction: "up" | "down") => void; +}) { + const { + column, + onClose, + onRename, + onChangeType, + onDelete, + onToggleVisibility, + onChangePin, + onAddOption, + onRenameOption, + onRecolorOption, + onRemoveOption, + onMoveOption, + } = props; + + const [renameValue, setRenameValue] = useState(column?.title ?? ""); + const [showTypeMenu, setShowTypeMenu] = useState(false); + const [newOptionValue, setNewOptionValue] = useState(""); + const [newOptionColor, setNewOptionColor] = useState("gray"); + + const typeItems = COLUMN_TYPES.map((type) => ( + + )); + const typeMenu = showTypeMenu ? ( +
{typeItems}
+ ) : null; + const supportsOptionEditing = + column?.type === "select" || column?.type === "multiSelect"; + const pinMenuButtons = ( +
+ + + +
+ ); + const optionColorItems = OPTION_COLOR_CHOICES.map((color) => ( + + )); + const optionEditorRows = (column?.options ?? []).map((option, index, options) => ( + 0} + canMoveDown={index < options.length - 1} + onRename={(value) => onRenameOption(option.id, value)} + onRecolor={(color) => onRecolorOption(option.id, color)} + onRemove={() => onRemoveOption(option.id)} + onMoveUp={() => onMoveOption(option.id, "up")} + onMoveDown={() => onMoveOption(option.id, "down")} + /> + )); + const optionEditor = supportsOptionEditing ? ( +
+
Options
+ {optionEditorRows} +
+ setNewOptionValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onAddOption(newOptionValue, newOptionColor); + setNewOptionValue(""); + } + }} + /> + + +
+
+ ) : null; + + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
+ setRenameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onRename(renameValue); + } + if (event.key === "Escape") { + onClose(); + } + }} + autoFocus + /> +
+
+ + {typeMenu} + {column?.type === "formula" ? ( +
+ Formula columns are read-only until the evaluator lands. +
+ ) : null} +
+ {optionEditor} +
+ +
+ {pinMenuButtons} +
+ +
+
+ +
+
+ ); +} + +function OptionEditorRow(props: { + option: NonNullable[number]; + colorItems: React.ReactElement[]; + canMoveUp: boolean; + canMoveDown: boolean; + onRename: (value: string) => void; + onRecolor: (color: string) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +}) { + const { + option, + colorItems, + canMoveUp, + canMoveDown, + onRename, + onRecolor, + onRemove, + onMoveUp, + onMoveDown, + } = props; + + const [value, setValue] = useState(option.value); + + useEffect(() => { + setValue(option.value); + }, [option.id, option.value]); + + return ( +
+ setValue(event.target.value)} + onBlur={() => onRename(value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onRename(value); + } + }} + /> + + + + +
+ ); +} diff --git a/packages/extensions/database/src/rendererFilterPanel.tsx b/packages/extensions/database/src/rendererFilterPanel.tsx new file mode 100644 index 0000000..3c7c32b --- /dev/null +++ b/packages/extensions/database/src/rendererFilterPanel.tsx @@ -0,0 +1,374 @@ +import { DATA_ATTRS } from "@pen/react"; +import { useEffect, useState } from "react"; +import type { + DatabaseColumnDef, + FacetBucket, + FilterCondition, + FilterGroup, + FilterOperator, +} from "./types"; +import { + addFilterNodeAtPath, + createDefaultFilterCondition, + DATE_RELATIVE_FILTER_OPTIONS, + dateFilterNeedsValue, + defaultOperatorFor, + getDateFilterRangeValue, + getDateFilterSingleValue, + getDefaultFilterValue, + getDefaultFilterValueForOperator, + getFilterPathKey, + operatorNeedsValue, + operatorOptionsFor, + removeFilterNodeAtPath, + updateFilterConditionAtPath, + updateFilterGroupOperatorAtPath, +} from "./utils/databaseRenderer"; + +type FilterNode = FilterCondition | FilterGroup; +type FilterPath = number[]; +export function FilterPanel(props: { + columnSchema: DatabaseColumnDef[]; + filterGroup: FilterGroup; + facetBucketsByColumnId: Record; + onChange: (filter: FilterGroup | null) => void; + onClose: () => void; +}) { + const { columnSchema, filterGroup, facetBucketsByColumnId, onChange, onClose } = + props; + + const rootEditor = ( + + ); + + return ( +
+
+ Filters + +
+ {rootEditor} +
+ ); +} + +function FilterGroupEditor(props: { + columnSchema: DatabaseColumnDef[]; + facetBucketsByColumnId: Record; + rootFilterGroup: FilterGroup; + group: FilterGroup; + groupPath: FilterPath; + isRoot?: boolean; + onChange: (filter: FilterGroup | null) => void; +}) { + const { + columnSchema, + facetBucketsByColumnId, + rootFilterGroup, + group, + groupPath, + isRoot = false, + onChange, + } = props; + + const groupPathKey = getFilterPathKey(groupPath); + + function handleGroupOperatorChange(operator: FilterGroup["operator"]) { + const nextFilter = updateFilterGroupOperatorAtPath( + rootFilterGroup, + groupPath, + operator, + ); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + function handleAddCondition() { + const nextFilter = addFilterNodeAtPath( + rootFilterGroup, + groupPath, + createDefaultFilterCondition(columnSchema), + ); + onChange(nextFilter); + } + + function handleAddGroup() { + const nextFilter = addFilterNodeAtPath(rootFilterGroup, groupPath, { + operator: "and", + conditions: [createDefaultFilterCondition(columnSchema)], + }); + onChange(nextFilter); + } + + function handleRemoveGroup() { + if (groupPath.length === 0) { + onChange(null); + return; + } + const nextFilter = removeFilterNodeAtPath(rootFilterGroup, groupPath); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + const childItems = group.conditions.map((condition, index) => { + const childPath = [...groupPath, index]; + if (isFilterGroupNode(condition)) { + return ( + + ); + } + return ( + + ); + }); + + return ( +
+
+ + {!isRoot ? ( + + ) : null} +
+ {childItems} +
+ + +
+
+ ); +} + +function FilterConditionRow(props: { + columnSchema: DatabaseColumnDef[]; + condition: FilterCondition; + conditionPath: FilterPath; + facetBucketsByColumnId: Record; + rootFilterGroup: FilterGroup; + onChange: (filter: FilterGroup | null) => void; +}) { + const { + columnSchema, + condition, + conditionPath, + facetBucketsByColumnId, + rootFilterGroup, + onChange, + } = props; + + const conditionPathKey = getFilterPathKey(conditionPath); + const column = + columnSchema.find((entry) => entry.id === condition.columnId) ?? columnSchema[0]; + const operatorOptions = operatorOptionsFor(column?.type ?? "text"); + const facetBuckets = facetBucketsByColumnId[condition.columnId] ?? []; + const datalistId = `pen-db-filter-values-${conditionPathKey}`; + + const columnOptionItems = columnSchema.map((columnItem) => ( + + )); + const operatorOptionItems = operatorOptions.map((option) => ( + + )); + const facetOptionItems = facetBuckets.map((bucket) => ( + + )); + + function handleUpdateCondition(patch: Partial) { + const nextFilter = updateFilterConditionAtPath( + rootFilterGroup, + conditionPath, + patch, + ); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + function handleRemoveCondition() { + const nextFilter = removeFilterNodeAtPath(rootFilterGroup, conditionPath); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + function handleDateRangeChange(index: 0 | 1, nextValue: string) { + const currentValue = Array.isArray(condition.value) + ? condition.value + : ["", ""]; + const nextRangeValue: string[] = [...currentValue]; + nextRangeValue[index] = nextValue; + handleUpdateCondition({ value: nextRangeValue }); + } + + const checkboxValueControl = ( + + ); + const relativeOptionItems = DATE_RELATIVE_FILTER_OPTIONS.map((option) => ( + + )); + const dateValueControl = !dateFilterNeedsValue(condition.operator) ? null : condition.operator === "is_relative" ? ( + + ) : condition.operator === "is_between" ? ( +
+ handleDateRangeChange(0, event.target.value)} + /> + to + handleDateRangeChange(1, event.target.value)} + /> +
+ ) : ( + handleUpdateCondition({ value: event.target.value })} + /> + ); + const textValueControl = ( + <> + 0 ? datalistId : undefined} + value={typeof condition.value === "string" ? condition.value : ""} + onChange={(event) => handleUpdateCondition({ value: event.target.value })} + placeholder="Filter value…" + /> + {facetBuckets.length > 0 ? ( + {facetOptionItems} + ) : null} + + ); + const valueControl = + column?.type === "checkbox" + ? checkboxValueControl + : column?.type === "date" + ? dateValueControl + : operatorNeedsValue(condition.operator) + ? textValueControl + : null; + + return ( +
+ + + {valueControl} + +
+ ); +} +function isFilterGroupNode(value: FilterNode): value is FilterGroup { + return "conditions" in value; +} diff --git a/packages/extensions/database/src/rendererPanels.tsx b/packages/extensions/database/src/rendererPanels.tsx index dfe1b5b..09375f5 100644 --- a/packages/extensions/database/src/rendererPanels.tsx +++ b/packages/extensions/database/src/rendererPanels.tsx @@ -1,305 +1,10 @@ import { DATA_ATTRS } from "@pen/react"; -import React, { useEffect, useState } from "react"; -import type { - ColumnType, - DatabaseColumnDef, - DatabaseViewState, - FacetBucket, - FilterCondition, - FilterGroup, - FilterOperator, -} from "./types"; -import { - addFilterNodeAtPath, - createDefaultFilterCondition, - DATE_RELATIVE_FILTER_OPTIONS, - dateFilterNeedsValue, - defaultOperatorFor, - getDateFilterRangeValue, - getDateFilterSingleValue, - getDefaultFilterValue, - getDefaultFilterValueForOperator, - getFilterPathKey, - operatorNeedsValue, - operatorOptionsFor, - removeFilterNodeAtPath, - updateFilterConditionAtPath, - updateFilterGroupOperatorAtPath, -} from "./utils/databaseRenderer"; +import type { DatabaseColumnDef, DatabaseViewState } from "./types"; -const COLUMN_TYPES: ColumnType[] = [ - "text", - "number", - "checkbox", - "select", - "multiSelect", - "date", - "url", - "email", - "relation", -]; +export { ColumnMenu } from "./rendererColumnMenu"; +export { FilterPanel } from "./rendererFilterPanel"; -const OPTION_COLOR_CHOICES = [ - "gray", - "red", - "orange", - "yellow", - "green", - "blue", - "purple", - "pink", -] as const; -type FilterNode = FilterCondition | FilterGroup; -type FilterPath = number[]; - -export function ColumnMenu(props: { - column: DatabaseColumnDef | undefined; - onClose: () => void; - onRename: (title: string) => void; - onChangeType: (type: ColumnType) => void; - onDelete: () => void; - onToggleVisibility: () => void; - onChangePin: (nextPinned: "left" | "right" | undefined) => void; - onAddOption: (value: string, color?: string) => void; - onRenameOption: (optionId: string, value: string) => void; - onRecolorOption: (optionId: string, color: string) => void; - onRemoveOption: (optionId: string) => void; - onMoveOption: (optionId: string, direction: "up" | "down") => void; -}) { - const { - column, - onClose, - onRename, - onChangeType, - onDelete, - onToggleVisibility, - onChangePin, - onAddOption, - onRenameOption, - onRecolorOption, - onRemoveOption, - onMoveOption, - } = props; - - const [renameValue, setRenameValue] = useState(column?.title ?? ""); - const [showTypeMenu, setShowTypeMenu] = useState(false); - const [newOptionValue, setNewOptionValue] = useState(""); - const [newOptionColor, setNewOptionColor] = useState("gray"); - - const typeItems = COLUMN_TYPES.map((type) => ( - - )); - const typeMenu = showTypeMenu ? ( -
{typeItems}
- ) : null; - const supportsOptionEditing = - column?.type === "select" || column?.type === "multiSelect"; - const pinMenuButtons = ( -
- - - -
- ); - const optionColorItems = OPTION_COLOR_CHOICES.map((color) => ( - - )); - const optionEditorRows = (column?.options ?? []).map((option, index, options) => ( - 0} - canMoveDown={index < options.length - 1} - onRename={(value) => onRenameOption(option.id, value)} - onRecolor={(color) => onRecolorOption(option.id, color)} - onRemove={() => onRemoveOption(option.id)} - onMoveUp={() => onMoveOption(option.id, "up")} - onMoveDown={() => onMoveOption(option.id, "down")} - /> - )); - const optionEditor = supportsOptionEditing ? ( -
-
Options
- {optionEditorRows} -
- setNewOptionValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - onAddOption(newOptionValue, newOptionColor); - setNewOptionValue(""); - } - }} - /> - - -
-
- ) : null; - - return ( -
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > -
- setRenameValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - onRename(renameValue); - } - if (event.key === "Escape") { - onClose(); - } - }} - autoFocus - /> -
-
- - {typeMenu} - {column?.type === "formula" ? ( -
- Formula columns are read-only until the evaluator lands. -
- ) : null} -
- {optionEditor} -
- -
- {pinMenuButtons} -
- -
-
- -
-
- ); -} - -function OptionEditorRow(props: { - option: NonNullable[number]; - colorItems: React.ReactElement[]; - canMoveUp: boolean; - canMoveDown: boolean; - onRename: (value: string) => void; - onRecolor: (color: string) => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; -}) { - const { - option, - colorItems, - canMoveUp, - canMoveDown, - onRename, - onRecolor, - onRemove, - onMoveUp, - onMoveDown, - } = props; - - const [value, setValue] = useState(option.value); - - useEffect(() => { - setValue(option.value); - }, [option.id, option.value]); - - return ( -
- setValue(event.target.value)} - onBlur={() => onRename(value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - onRename(value); - } - }} - /> - - - - -
- ); -} export function SortPanel(props: { columnSchema: DatabaseColumnDef[]; @@ -410,348 +115,6 @@ export function SortPanel(props: { ); } -export function FilterPanel(props: { - columnSchema: DatabaseColumnDef[]; - filterGroup: FilterGroup; - facetBucketsByColumnId: Record; - onChange: (filter: FilterGroup | null) => void; - onClose: () => void; -}) { - const { columnSchema, filterGroup, facetBucketsByColumnId, onChange, onClose } = - props; - - const rootEditor = ( - - ); - - return ( -
-
- Filters - -
- {rootEditor} -
- ); -} - -function FilterGroupEditor(props: { - columnSchema: DatabaseColumnDef[]; - facetBucketsByColumnId: Record; - rootFilterGroup: FilterGroup; - group: FilterGroup; - groupPath: FilterPath; - isRoot?: boolean; - onChange: (filter: FilterGroup | null) => void; -}) { - const { - columnSchema, - facetBucketsByColumnId, - rootFilterGroup, - group, - groupPath, - isRoot = false, - onChange, - } = props; - - const groupPathKey = getFilterPathKey(groupPath); - - function handleGroupOperatorChange(operator: FilterGroup["operator"]) { - const nextFilter = updateFilterGroupOperatorAtPath( - rootFilterGroup, - groupPath, - operator, - ); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - function handleAddCondition() { - const nextFilter = addFilterNodeAtPath( - rootFilterGroup, - groupPath, - createDefaultFilterCondition(columnSchema), - ); - onChange(nextFilter); - } - - function handleAddGroup() { - const nextFilter = addFilterNodeAtPath(rootFilterGroup, groupPath, { - operator: "and", - conditions: [createDefaultFilterCondition(columnSchema)], - }); - onChange(nextFilter); - } - - function handleRemoveGroup() { - if (groupPath.length === 0) { - onChange(null); - return; - } - const nextFilter = removeFilterNodeAtPath(rootFilterGroup, groupPath); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - const childItems = group.conditions.map((condition, index) => { - const childPath = [...groupPath, index]; - if (isFilterGroupNode(condition)) { - return ( - - ); - } - return ( - - ); - }); - - return ( -
-
- - {!isRoot ? ( - - ) : null} -
- {childItems} -
- - -
-
- ); -} - -function FilterConditionRow(props: { - columnSchema: DatabaseColumnDef[]; - condition: FilterCondition; - conditionPath: FilterPath; - facetBucketsByColumnId: Record; - rootFilterGroup: FilterGroup; - onChange: (filter: FilterGroup | null) => void; -}) { - const { - columnSchema, - condition, - conditionPath, - facetBucketsByColumnId, - rootFilterGroup, - onChange, - } = props; - - const conditionPathKey = getFilterPathKey(conditionPath); - const column = - columnSchema.find((entry) => entry.id === condition.columnId) ?? columnSchema[0]; - const operatorOptions = operatorOptionsFor(column?.type ?? "text"); - const facetBuckets = facetBucketsByColumnId[condition.columnId] ?? []; - const datalistId = `pen-db-filter-values-${conditionPathKey}`; - - const columnOptionItems = columnSchema.map((columnItem) => ( - - )); - const operatorOptionItems = operatorOptions.map((option) => ( - - )); - const facetOptionItems = facetBuckets.map((bucket) => ( - - )); - - function handleUpdateCondition(patch: Partial) { - const nextFilter = updateFilterConditionAtPath( - rootFilterGroup, - conditionPath, - patch, - ); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - function handleRemoveCondition() { - const nextFilter = removeFilterNodeAtPath(rootFilterGroup, conditionPath); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - function handleDateRangeChange(index: 0 | 1, nextValue: string) { - const currentValue = Array.isArray(condition.value) - ? condition.value - : ["", ""]; - const nextRangeValue: string[] = [...currentValue]; - nextRangeValue[index] = nextValue; - handleUpdateCondition({ value: nextRangeValue }); - } - - const checkboxValueControl = ( - - ); - const relativeOptionItems = DATE_RELATIVE_FILTER_OPTIONS.map((option) => ( - - )); - const dateValueControl = !dateFilterNeedsValue(condition.operator) ? null : condition.operator === "is_relative" ? ( - - ) : condition.operator === "is_between" ? ( -
- handleDateRangeChange(0, event.target.value)} - /> - to - handleDateRangeChange(1, event.target.value)} - /> -
- ) : ( - handleUpdateCondition({ value: event.target.value })} - /> - ); - const textValueControl = ( - <> - 0 ? datalistId : undefined} - value={typeof condition.value === "string" ? condition.value : ""} - onChange={(event) => handleUpdateCondition({ value: event.target.value })} - placeholder="Filter value…" - /> - {facetBuckets.length > 0 ? ( - {facetOptionItems} - ) : null} - - ); - const valueControl = - column?.type === "checkbox" - ? checkboxValueControl - : column?.type === "date" - ? dateValueControl - : operatorNeedsValue(condition.operator) - ? textValueControl - : null; - - return ( -
- - - {valueControl} - -
- ); -} export function ColumnVisibilityPanel(props: { columnSchema: DatabaseColumnDef[]; @@ -815,6 +178,3 @@ export function GroupPanel(props: { ); } -function isFilterGroupNode(value: FilterNode): value is FilterGroup { - return "conditions" in value; -} diff --git a/packages/extensions/database/src/rendererViewTypes.ts b/packages/extensions/database/src/rendererViewTypes.ts new file mode 100644 index 0000000..aafb27f --- /dev/null +++ b/packages/extensions/database/src/rendererViewTypes.ts @@ -0,0 +1,64 @@ +import type React from "react"; +import type { + DatabaseColumnDef, + DatabaseRow, + DatabaseRowGroup, + DatabaseViewModelColumn, + DatabaseViewModelRow, + DatabaseViewState, +} from "./types"; +import type { ColumnStickyStyle } from "./utils/databaseRenderer"; + +export type RowSectionOptions = { + sectionLabel?: string; +}; + +export type CellPointerHandler = ( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, +) => void; + +export type DatabaseViewBodyProps = { + blockId: string; + viewType: DatabaseViewState["type"]; + ctxSelected: boolean | undefined; + headerRow: React.ReactElement; + tableColumnSpan: number; + columns: DatabaseViewModelColumn[]; + allRows: DatabaseRow[]; + rows: DatabaseViewModelRow[]; + pinnedTopRows: DatabaseViewModelRow[]; + pinnedBottomRows: DatabaseViewModelRow[]; + rowGroups: DatabaseRowGroup[]; + rowSelection: Record; + showRowSelectionControls: boolean; + isDataReadonly: boolean; + isRemote: boolean; + defaultColumnWidth: number; + pinnedOffsets: Record; + getColumnStickyStyle: ( + column: DatabaseViewModelColumn, + pinnedOffsets: Record, + defaultColumnWidth: number, + section: "header" | "body", + ) => ColumnStickyStyle; + isCellSelected: (row: number, column: number) => boolean; + formatRemoteCell: ( + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, + ) => string; + onToggleRow: (rowId: string) => void; + onRowSelectionKeyDown: ( + event: React.KeyboardEvent, + rowId: string, + ) => void; + onCellMouseDown: CellPointerHandler; + onCellDoubleClick: CellPointerHandler; + addListRow: React.ReactNode; + addRowControl: React.ReactNode; + addColumnControl: React.ReactNode; + calendarMonth: Date; + onShiftCalendarMonth: (amount: number) => void; + calendarDateColumn: DatabaseColumnDef | undefined; +}; diff --git a/packages/extensions/database/src/rendererViews.tsx b/packages/extensions/database/src/rendererViews.tsx index 6366f69..1118e9e 100644 --- a/packages/extensions/database/src/rendererViews.tsx +++ b/packages/extensions/database/src/rendererViews.tsx @@ -1,74 +1,18 @@ import { DATA_ATTRS } from "@pen/react"; import React from "react"; +import type { DatabaseViewBodyProps, RowSectionOptions } from "./rendererViewTypes"; import { DatabaseCellContent } from "./cellEditors"; import type { - DatabaseColumnDef, - DatabaseRow, - DatabaseRowGroup, DatabaseViewModelColumn, DatabaseViewModelRow, - DatabaseViewState, } from "./types"; -import type { ColumnStickyStyle } from "./utils/databaseRenderer"; import { buildCalendarMonthData, CALENDAR_WEEKDAY_LABELS, shiftMonth, } from "./utils/databaseRenderer"; -type RowSectionOptions = { - sectionLabel?: string; -}; - -type CellPointerHandler = ( - event: React.MouseEvent, - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, -) => void; - -export function DatabaseViewBody(props: { - blockId: string; - viewType: DatabaseViewState["type"]; - ctxSelected: boolean | undefined; - headerRow: React.ReactElement; - tableColumnSpan: number; - columns: DatabaseViewModelColumn[]; - allRows: DatabaseRow[]; - rows: DatabaseViewModelRow[]; - pinnedTopRows: DatabaseViewModelRow[]; - pinnedBottomRows: DatabaseViewModelRow[]; - rowGroups: DatabaseRowGroup[]; - rowSelection: Record; - showRowSelectionControls: boolean; - isDataReadonly: boolean; - isRemote: boolean; - defaultColumnWidth: number; - pinnedOffsets: Record; - getColumnStickyStyle: ( - column: DatabaseViewModelColumn, - pinnedOffsets: Record, - defaultColumnWidth: number, - section: "header" | "body", - ) => ColumnStickyStyle; - isCellSelected: (row: number, column: number) => boolean; - formatRemoteCell: ( - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, - ) => string; - onToggleRow: (rowId: string) => void; - onRowSelectionKeyDown: ( - event: React.KeyboardEvent, - rowId: string, - ) => void; - onCellMouseDown: CellPointerHandler; - onCellDoubleClick: CellPointerHandler; - addListRow: React.ReactNode; - addRowControl: React.ReactNode; - addColumnControl: React.ReactNode; - calendarMonth: Date; - onShiftCalendarMonth: (amount: number) => void; - calendarDateColumn: DatabaseColumnDef | undefined; -}) { +export function DatabaseViewBody(props: DatabaseViewBodyProps) { const { blockId, viewType, diff --git a/packages/extensions/database/src/useDatabaseController.ts b/packages/extensions/database/src/useDatabaseController.ts index a770d28..42fa269 100644 --- a/packages/extensions/database/src/useDatabaseController.ts +++ b/packages/extensions/database/src/useDatabaseController.ts @@ -1,4 +1,4 @@ -import type { BlockHandle, CellSelection } from "@pen/types"; +import type { CellSelection } from "@pen/types"; import { DATA_ATTRS, useEditorContext, @@ -6,9 +6,12 @@ import { useFieldEditorState, useSelection, } from "@pen/react"; -import { generateId } from "@pen/types"; import { useEffect, useMemo, useState } from "react"; import { DatabaseEngine } from "./engine"; +import { createDatabaseMutationHandlers } from "./databaseControllerMutationHandlers"; +import { createDatabaseSelectionHandlers } from "./databaseControllerSelectionHandlers"; +import type { DatabaseController, DatabaseControllerConfig } from "./databaseControllerTypes"; +export type { DatabaseController, DatabaseControllerConfig } from "./databaseControllerTypes"; import type { ColumnType, DatabaseColumnDef, @@ -21,7 +24,6 @@ import type { FacetBucket, FilterGroup, } from "./types"; -import { isCellInSelection } from "./utils"; import { createDatabaseViewDefinition, getCalendarDateColumn, @@ -84,118 +86,6 @@ function getOrCreateDatabaseRowSelectionController( return controller; } -export interface DatabaseControllerConfig { - blockId: string; -} - -export interface DatabaseController { - block: BlockHandle; - engine: DatabaseEngine; - viewModel: DatabaseViewModel; - columnSchema: DatabaseColumnDef[]; - - viewState: DatabaseViewState; - updateViewState: (patch: Partial>) => void; - views: readonly DatabaseViewState[]; - - title: string; - isEditingTitle: boolean; - setIsEditingTitle: (editing: boolean) => void; - handleTitleClick: () => void; - handleTitleBlur: (event: React.FocusEvent) => void; - handleTitleKeyDown: (event: React.KeyboardEvent) => void; - - addRow: () => void; - addColumn: () => void; - deleteColumn: (columnId: string) => void; - renameColumn: (columnId: string, title: string) => void; - changeColumnType: (columnId: string, type: ColumnType) => void; - toggleColumnVisibility: (columnId: string) => void; - changeColumnPin: (columnId: string, pinned: "left" | "right" | undefined) => void; - addOption: (columnId: string, value: string, color?: string) => void; - renameOption: (columnId: string, optionId: string, value: string) => void; - recolorOption: (columnId: string, optionId: string, color: string) => void; - removeOption: (columnId: string, optionId: string) => void; - moveOption: (columnId: string, optionId: string, direction: "up" | "down") => void; - - addView: (type: DatabaseViewState["type"]) => void; - setActiveView: (viewId: string) => void; - removeView: (viewId: string) => void; - showAddViewMenu: boolean; - setShowAddViewMenu: (show: boolean) => void; - - rowSelection: Record; - toggleRow: (rowId: string) => void; - toggleAllRows: () => void; - deleteSelectedRows: () => void; - pinSelectedRows: (target: "top" | "bottom" | "none") => void; - handleRowSelectionKeyDown: (event: React.KeyboardEvent, rowId: string) => void; - hasSelectedRows: boolean; - selectedRowCount: number; - allVisibleSelected: boolean; - - cellSelection: CellSelection | null; - createCellSelection: (anchor: { row: number; col: number }, head?: { row: number; col: number }) => CellSelection; - handleCellMouseDown: CellPointerHandler; - handleCellDoubleClick: CellPointerHandler; - - globalSearch: string; - setGlobalSearch: (value: string) => void; - - filterGroup: FilterGroup; - handleFilterGroupChange: (filter: FilterGroup | null) => void; - facetBucketsByColumnId: Record; - showFilterPanel: boolean; - setShowFilterPanel: (show: boolean) => void; - - handleSortChange: (sort: NonNullable) => void; - handleHeaderClick: (event: React.MouseEvent, columnId: string) => void; - showSortPanel: boolean; - setShowSortPanel: (show: boolean) => void; - - handleChangeGroupBy: (groupBy: string | null) => void; - showGroupPanel: boolean; - setShowGroupPanel: (show: boolean) => void; - - showColumnVisibilityMenu: boolean; - setShowColumnVisibilityMenu: (show: boolean) => void; - - activeColumnMenu: string | null; - setActiveColumnMenu: (columnId: string | null) => void; - - handlePreviousPage: () => void; - handleNextPage: () => void; - pageCount: number; - showPagination: boolean; - - remoteLoading: boolean; - remoteError: string | null; - - isUiReadonly: boolean; - isDataReadonly: boolean; - showRowSelectionControls: boolean; - - columns: DatabaseViewModelColumn[]; - allRows: DatabaseViewModelRow[]; - rows: DatabaseViewModelRow[]; - pinnedTopRows: DatabaseViewModelRow[]; - pinnedBottomRows: DatabaseViewModelRow[]; - rowGroups: DatabaseViewModel["rowGroups"]; - visibleRows: DatabaseViewModelRow[]; - visibleColumnIds: string[]; - visibleColumnIdSet: ReadonlySet; - - defaultColumnWidth: number; - pinnedOffsets: Record; - getColumnStickyStyle: typeof getColumnStickyStyle; - getFixedEdgeStyle: typeof getFixedEdgeStyle; - isCellSelected: (row: number, column: number) => boolean; - formatRemoteCell: (row: DatabaseViewModelRow, column: DatabaseViewModelColumn) => string; - - calendarMonth: Date; - shiftCalendarMonth: (amount: number) => void; - calendarDateColumn: DatabaseColumnDef | undefined; -} export function useDatabaseController(config: DatabaseControllerConfig): DatabaseController { const { blockId } = config; @@ -291,561 +181,100 @@ export function useDatabaseController(config: DatabaseControllerConfig): Databas // --- Cell selection helpers --- - function createDatabaseCellSelection( - anchor: { row: number; col: number }, - head: { row: number; col: number } = anchor, - ): CellSelection { - return { - type: "cell", - blockId, - anchor, - head, - rowIds: visibleRowIds, - columnIds: visibleSelectionColumnIds, - }; - } - - function findVisibleCellCoordByIds( - rowId: string | null, - columnId: string | null, - ): { row: number; col: number } | null { - if (!rowId || !columnId) { - return null; - } - const row = visibleRows.findIndex((entry) => entry.id === rowId); - const col = columns.findIndex((entry) => entry.id === columnId); - if (row < 0 || col < 0) { - return null; - } - return { row, col }; - } - - function findVisibleCellCoordByStorage( - row: number, - col: number, - ): { row: number; col: number } | null { - const rowIndex = visibleRows.findIndex( - (entry) => entry.crdtRowIndex === row, - ); - const colIndex = columns.findIndex( - (entry) => entry.columnIndex === col, - ); - if (rowIndex < 0 || colIndex < 0) { - return null; - } - return { row: rowIndex, col: colIndex }; - } - - function normalizeDatabaseCellSelection( - selection: CellSelection, - ): CellSelection | null { - if (columns.length === 0) { - return null; - } - if (visibleRows.length === 0) { - return { - type: "cell", - blockId, - anchor: selection.anchor, - head: selection.head, - }; - } - - const firstVisibleCell = { row: 0, col: 0 }; - const anchorCoord = - findVisibleCellCoordByIds( - selection.rowIds?.[selection.anchor.row] ?? null, - selection.columnIds?.[selection.anchor.col] ?? null, - ) ?? - findVisibleCellCoordByStorage( - selection.anchor.row, - selection.anchor.col, - ) ?? - firstVisibleCell; - const headCoord = - findVisibleCellCoordByIds( - selection.rowIds?.[selection.head.row] ?? null, - selection.columnIds?.[selection.head.col] ?? null, - ) ?? - findVisibleCellCoordByStorage( - selection.head.row, - selection.head.col, - ) ?? - anchorCoord; - - return createDatabaseCellSelection(anchorCoord, headCoord); - } - - function areSelectionAxesEqual( - left: string[] | undefined, - right: string[], - ): boolean { - if (!left || left.length !== right.length) { - return false; - } - return left.every((value, index) => value === right[index]); - } - - function isDatabaseSelectionCurrent(selection: CellSelection): boolean { - if (visibleRows.length === 0) { - return !selection.rowIds && !selection.columnIds; - } - - return ( - areSelectionAxesEqual(selection.rowIds, visibleRowIds) && - areSelectionAxesEqual(selection.columnIds, visibleSelectionColumnIds) - ); - } - - // --- Mutation handlers --- - - function updateViewState(patch: Partial>) { - const nextView = { - ...viewState, - ...patch, - }; - setViewState(nextView); - editor.apply([ - { - type: "database-update-view", - blockId, - viewId: block.databasePrimaryViewId() ?? undefined, - patch, - }, - ], { origin: "user" }); - } - - function handleTitleClick() { - if (isUiReadonly) return; - setIsEditingTitle(true); - } - - function handleTitleBlur(event: React.FocusEvent) { - setIsEditingTitle(false); - const nextTitle = event.currentTarget.value.trim() || "Untitled"; - if (nextTitle === title) return; - editor.apply([ - { - type: "update-block", - blockId, - props: { title: nextTitle }, - }, - ]); - } - - function handleTitleKeyDown(event: React.KeyboardEvent) { - if (event.key === "Enter" || event.key === "Escape") { - event.currentTarget.blur(); - } - } - - function handleCellMouseDown( - event: React.MouseEvent, - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, - ) { - if (!fieldEditor) return; - const isEditing = - fieldEditorActiveCell?.blockId === blockId - && fieldEditorActiveCell.row === row.crdtRowIndex - && fieldEditorActiveCell.col === column.columnIndex; - if (isEditing) return; - const nextCoord = findVisibleCellCoordByIds(row.id, column.id); - if (!nextCoord) return; - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation?.(); - const isSameSingleCellSelection = - cellSelection && - cellSelection.anchor.row === nextCoord.row && - cellSelection.anchor.col === nextCoord.col && - cellSelection.head.row === nextCoord.row && - cellSelection.head.col === nextCoord.col; - if (!event.shiftKey && isSameSingleCellSelection) { - editor.selectBlock(blockId); - return; - } - if (event.shiftKey && cellSelection) { - editor.setSelection( - createDatabaseCellSelection(cellSelection.anchor, nextCoord), - ); - return; - } - editor.setSelection(createDatabaseCellSelection(nextCoord)); - } - - function handleCellDoubleClick( - event: React.MouseEvent, - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, - ) { - if (isDataReadonly || !fieldEditor) return; - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation?.(); - const cellSurface = event.currentTarget.querySelector(`[${DATA_ATTRS.fieldEditorSurface}]`) as HTMLElement | null; - if (cellSurface) { - fieldEditor.activateCellFromElement?.(blockId, row.crdtRowIndex, column.columnIndex, cellSurface) - ?? fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); - return; - } - fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); - } - - function handleHeaderClick(event: React.MouseEvent, columnId: string) { - const nextSort = getNextSortState(viewState.sort ?? [], columnId, event.shiftKey); - updateViewState({ sort: nextSort, pageIndex: 0 }); - } - - function handleAddRow() { - if (isDataReadonly) return; - editor.apply([ - { - type: "database-insert-row", - blockId, - index: block.tableRowCount(), - }, - ], { origin: "user" }); - } - - function handleAddColumn() { - if (isUiReadonly) return; - const columnId = generateId(); - const nextColumn: DatabaseColumnDef = { - id: columnId, - title: "New column", - type: "text", - }; - editor.apply([ - { - type: "database-add-column", - blockId, - column: nextColumn, - index: block.tableColumnCount(), - viewId: block.databasePrimaryViewId() ?? undefined, - }, - ], { origin: "user" }); - } - - function handleAddView(nextType: DatabaseViewState["type"]) { - if (isUiReadonly) return; - const nextViewId = generateId(); - const nextView = createDatabaseViewDefinition({ - id: nextViewId, - type: nextType, - columns: columnSchema, - existingViews: databaseViews, - }); - setViewState(nextView); - editor.apply([ - { - type: "database-add-view", - blockId, - view: nextView, - }, - { - type: "database-set-active-view", - blockId, - viewId: nextViewId, - }, - ], { origin: "user" }); - setShowAddViewMenu(false); - } - - function handleSetActiveView(viewId: string) { - const nextView = databaseViews.find((view) => view.id === viewId); - if (nextView) { - setViewState(nextView); - } - editor.apply([ - { - type: "database-set-active-view", - blockId, - viewId, - }, - ], { origin: "user" }); - } - - function handleRemoveView(viewId: string) { - if (isUiReadonly || databaseViews.length <= 1) return; - const currentActiveViewId = block.databasePrimaryViewId() ?? viewState.id; - if (currentActiveViewId === viewId) { - const fallbackView = databaseViews.find((view) => view.id !== viewId); - if (fallbackView) { - setViewState(fallbackView); - } - } - editor.apply([ - { - type: "database-remove-view", - blockId, - viewId, - }, - ], { origin: "user" }); - } - - function handleToggleAllRows() { - if (allVisibleSelected) { - const nextSelection = { ...rowSelection }; - for (const rowId of visibleRowIds) { - delete nextSelection[rowId]; - } - setRowSelection(nextSelection); - return; - } - const nextSelection = { ...rowSelection }; - for (const rowId of visibleRowIds) { - nextSelection[rowId] = true; - } - setRowSelection(nextSelection); - } - - function handleToggleRow(rowId: string) { - setRowSelection((previous) => ({ - ...previous, - [rowId]: !previous[rowId], - })); - } - - function getSelectedRowIds( - fallback?: { rowId: string; checked: boolean }, - ): string[] { - const selectedRowIds = allRows - .filter((row) => rowSelection[row.id]) - .map((row) => row.id); - if ( - fallback?.checked && - !selectedRowIds.includes(fallback.rowId) - ) { - selectedRowIds.push(fallback.rowId); - } - return selectedRowIds; - } - - function handleRowSelectionKeyDown( - event: React.KeyboardEvent, - rowId: string, - ) { - if (event.key !== "Backspace" && event.key !== "Delete") { - return; - } - event.preventDefault(); - event.stopPropagation(); - handleDeleteSelectedRows({ - rowId, - checked: event.currentTarget.checked, - }); - } - - function handleDeleteSelectedRows( - fallback?: { rowId: string; checked: boolean }, - ) { - const selectedRowIds = getSelectedRowIds(fallback); - if (selectedRowIds.length === 0 || isDataReadonly) return; - editor.apply([ - { - type: "database-delete-rows", - blockId, - rowIds: selectedRowIds, - }, - ], { origin: "user" }); - setRowSelection({}); - } - - function handlePinSelectedRows(target: "top" | "bottom" | "none") { - const selectedRowIds = getSelectedRowIds(); - if (selectedRowIds.length === 0) { - return; - } - const currentRowPinning = viewState.rowPinning; - const nextRowPinning = getNextRowPinningState( - currentRowPinning, - selectedRowIds, - target, - ); - updateViewState({ rowPinning: nextRowPinning, pageIndex: 0 }); - } - - function handleDeleteColumn(columnId: string) { - if (isUiReadonly) return; - editor.apply([ - { type: "database-remove-column", blockId, columnId }, - ], { origin: "user" }); - setActiveColumnMenu(null); - } - - function handleRenameColumn(columnId: string, nextTitle: string) { - editor.apply([{ - type: "database-update-column", - blockId, - columnId, - patch: { title: nextTitle || "Untitled" }, - }], { origin: "user" }); - setActiveColumnMenu(null); - } - - function handleChangeColumnType(columnId: string, nextType: ColumnType) { - const targetColumn = columnSchema.find((column) => column.id === columnId); - if (!targetColumn || targetColumn.type === nextType) return; - editor.apply([{ - type: "database-convert-column", - blockId, - columnId, - toType: nextType, - }], { origin: "user" }); - setActiveColumnMenu(null); - } - - function handleToggleColumnVisibility(columnId: string) { - const nextVisibleColumnIds = visibleColumnIdSet.has(columnId) - ? visibleColumnIds.filter((id) => id !== columnId) - : [...visibleColumnIds, columnId]; - updateViewState({ visibleColumnIds: nextVisibleColumnIds }); - } - - function handleChangeColumnPin( - columnId: string, - nextPinned: "left" | "right" | undefined, - ) { - editor.apply([{ - type: "database-update-column", - blockId, - columnId, - patch: { pinned: nextPinned }, - }], { origin: "user" }); - setActiveColumnMenu(null); - } - - function refreshColumnSchemaSoon() { - requestAnimationFrame(() => { - setColumnSchemaRefreshToken((value) => value + 1); - }); - } - - function handleAddOption(columnId: string, value: string, color?: string) { - const trimmedValue = value.trim(); - if (!trimmedValue) return; - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "add", - option: { - id: generateId(), - value: trimmedValue, - color, - }, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleRenameOption(columnId: string, optionId: string, value: string) { - const trimmedValue = value.trim(); - if (!trimmedValue) return; - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "rename", - optionId, - value: trimmedValue, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleRecolorOption(columnId: string, optionId: string, color: string) { - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "recolor", - optionId, - color, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleRemoveOption(columnId: string, optionId: string) { - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "remove", - optionId, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleMoveOption(columnId: string, optionId: string, direction: "up" | "down") { - const column = columnSchema.find((entry) => entry.id === columnId); - const currentOptions = column?.options ?? []; - const currentIndex = currentOptions.findIndex((option) => option.id === optionId); - if (currentIndex < 0) return; - const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; - if (targetIndex < 0 || targetIndex >= currentOptions.length) return; - const nextOrder = [...currentOptions.map((option) => option.id)]; - const [movedOptionId] = nextOrder.splice(currentIndex, 1); - nextOrder.splice(targetIndex, 0, movedOptionId); - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "reorder", - order: nextOrder, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleFilterGroupChange(nextFilter: FilterGroup | null) { - updateViewState({ filter: nextFilter, pageIndex: 0 }); - } - - function handleSortChange(nextSort: NonNullable) { - updateViewState({ sort: nextSort, pageIndex: 0 }); - } - - function handleChangeGroupBy(nextGroupBy: string | null) { - updateViewState({ groupBy: nextGroupBy, pageIndex: 0 }); - } - - function handlePreviousPage() { - updateViewState({ pageIndex: Math.max(0, (viewState.pageIndex ?? 0) - 1) }); - } - - function handleNextPage() { - updateViewState({ pageIndex: Math.min(pageCount - 1, (viewState.pageIndex ?? 0) + 1) }); - } - - function setGlobalSearch(value: string) { - setGlobalSearchRaw(value); - updateViewState({ pageIndex: 0 }); - } - - function isCellSelectedFn(row: number, column: number): boolean { - return !!( - cellSelection && - isCellInSelection(cellSelection, row, column, { - rowId: visibleRows.find((entry) => entry.crdtRowIndex === row)?.id, - columnId: columns.find((entry) => entry.columnIndex === column)?.id, - }) - ); - } - - function formatRemoteCell(row: DatabaseViewModelRow, column: DatabaseViewModelColumn): string { - return engine.formatCellDisplay( - row.cells[column.id] ?? "", - column.type, - column.format, - column.options, - ); - } - const activeCalendarMonth = calendarMonth ?? inferCalendarMonth(allRows, calendarDateColumn?.id ?? null); - - function shiftCalendarMonthFn(amount: number) { - setCalendarMonth(shiftMonth(activeCalendarMonth, amount)); - } + const mutationHandlers = createDatabaseMutationHandlers({ + activeCalendarMonth, + allRows, + block, + blockId, + calendarMonth, + cellSelection, + columnSchema, + columns, + databaseViews, + editor, + engine, + globalSearch, + isDataReadonly, + isUiReadonly, + pageCount, + rowSelection, + setActiveColumnMenu, + setCalendarMonth, + setColumnSchemaRefreshToken, + setGlobalSearchRaw, + setIsEditingTitle, + setShowAddViewMenu, + setViewState, + title, + viewState, + visibleColumnIds, + visibleColumnIdSet, + visibleRows, + }); + const { + updateViewState, + handleTitleClick, + handleTitleBlur, + handleTitleKeyDown, + handleHeaderClick, + handleAddRow, + handleAddColumn, + handleAddView, + handleSetActiveView, + handleRemoveView, + handleDeleteColumn, + handleRenameColumn, + handleChangeColumnType, + handleToggleColumnVisibility, + handleChangeColumnPin, + handleAddOption, + handleRenameOption, + handleRecolorOption, + handleRemoveOption, + handleMoveOption, + handleFilterGroupChange, + handleSortChange, + handleChangeGroupBy, + handlePreviousPage, + handleNextPage, + setGlobalSearch, + isCellSelectedFn, + formatRemoteCell, + shiftCalendarMonthFn, + } = mutationHandlers; + const selectionHandlers = createDatabaseSelectionHandlers({ + allRows, + allVisibleSelected, + blockId, + cellSelection, + columns, + editor, + fieldEditor, + fieldEditorActiveCell, + isDataReadonly, + rowSelection, + setRowSelection, + updateViewState, + viewState, + visibleRowIds, + visibleRows, + visibleSelectionColumnIds, + }); + const { + createDatabaseCellSelection, + normalizeDatabaseCellSelection, + isDatabaseSelectionCurrent, + handleCellMouseDown, + handleCellDoubleClick, + handleToggleAllRows, + handleToggleRow, + getSelectedRowIds, + handleRowSelectionKeyDown, + handleDeleteSelectedRows, + handlePinSelectedRows, + } = selectionHandlers; // --- Effects --- diff --git a/packages/extensions/database/src/utils/databaseRenderer.ts b/packages/extensions/database/src/utils/databaseRenderer.ts index ece65a6..daa202c 100644 --- a/packages/extensions/database/src/utils/databaseRenderer.ts +++ b/packages/extensions/database/src/utils/databaseRenderer.ts @@ -10,20 +10,26 @@ import type { FilterOperator, } from "../types"; +export { + DATE_RELATIVE_FILTER_OPTIONS, + addFilterNodeAtPath, + createDefaultFilterCondition, + dateFilterNeedsValue, + defaultOperatorFor, + getDateFilterRangeValue, + getDateFilterSingleValue, + getDefaultFilterValue, + getDefaultFilterValueForOperator, + getFilterPathKey, + operatorNeedsValue, + operatorOptionsFor, + removeFilterNodeAtPath, + updateFilterConditionAtPath, + updateFilterGroupOperatorAtPath, +} from "./databaseRendererFilters"; const PINNED_CELL_Z_INDEX = 2; const PINNED_HEADER_Z_INDEX = 3; -const DATE_RELATIVE_FILTER_OPTIONS = [ - { value: "today", label: "Today" }, - { value: "yesterday", label: "Yesterday" }, - { value: "tomorrow", label: "Tomorrow" }, - { value: "this_week", label: "This week" }, - { value: "last_7_days", label: "Last 7 days" }, - { value: "next_7_days", label: "Next 7 days" }, - { value: "this_month", label: "This month" }, -] as const; -type FilterNode = FilterCondition | FilterGroup; -type FilterPath = number[]; export const CALENDAR_WEEKDAY_LABELS = [ "Sun", @@ -308,236 +314,6 @@ export function getFixedEdgeStyle( } as FixedEdgeStyle; } -export function createDefaultFilterCondition( - columnSchema: DatabaseColumnDef[], -): FilterCondition { - const firstColumn = columnSchema[0]; - if (!firstColumn) { - return { - columnId: "", - operator: "contains", - value: "", - }; - } - return { - columnId: firstColumn.id, - operator: defaultOperatorFor(firstColumn.type), - value: getDefaultFilterValue(firstColumn.type), - }; -} - -export function getDefaultFilterValue( - columnType: ColumnType, -): FilterCondition["value"] { - return getDefaultFilterValueForOperator( - columnType, - defaultOperatorFor(columnType), - ); -} - -export function getDefaultFilterValueForOperator( - columnType: ColumnType, - operator: FilterOperator, -): FilterCondition["value"] { - if (!operatorNeedsValue(operator)) { - return null; - } - if (columnType === "date") { - if (operator === "is_between") { - return ["", ""]; - } - if (operator === "is_relative") { - return DATE_RELATIVE_FILTER_OPTIONS[0]?.value ?? "today"; - } - } - return ""; -} - -export function operatorNeedsValue(operator: FilterOperator): boolean { - return ![ - "is_empty", - "is_not_empty", - "is_checked", - "is_unchecked", - ].includes(operator); -} - -export function dateFilterNeedsValue(operator: FilterOperator): boolean { - return operatorNeedsValue(operator); -} - -export function getDateFilterSingleValue( - value: FilterCondition["value"], -): string { - return typeof value === "string" ? value : ""; -} - -export function getDateFilterRangeValue( - value: FilterCondition["value"], - index: 0 | 1, -): string { - if (!Array.isArray(value)) { - return ""; - } - return value[index] ?? ""; -} - -export function defaultOperatorFor(columnType: ColumnType): FilterOperator { - if (columnType === "checkbox") { - return "is_checked"; - } - if (columnType === "number") { - return "="; - } - if (columnType === "date") { - return "is"; - } - if (columnType === "select") { - return "is"; - } - if (columnType === "multiSelect") { - return "is_any_of"; - } - return "contains"; -} - -export function operatorOptionsFor( - columnType: ColumnType, -): Array<{ value: FilterOperator; label: string }> { - if (columnType === "checkbox") { - return [ - { value: "is_checked", label: "is checked" }, - { value: "is_unchecked", label: "is unchecked" }, - ]; - } - if (columnType === "number") { - return [ - { value: "=", label: "=" }, - { value: "!=", label: "!=" }, - { value: ">", label: ">" }, - { value: "<", label: "<" }, - { value: ">=", label: ">=" }, - { value: "<=", label: "<=" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - if (columnType === "date") { - return [ - { value: "is", label: "is" }, - { value: "is_before", label: "is before" }, - { value: "is_after", label: "is after" }, - { value: "is_between", label: "is between" }, - { value: "is_relative", label: "is relative to today" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - if (columnType === "select") { - return [ - { value: "is", label: "is" }, - { value: "is_not", label: "is not" }, - { value: "is_any_of", label: "is any of" }, - { value: "is_none_of", label: "is none of" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - if (columnType === "multiSelect") { - return [ - { value: "contains", label: "contains" }, - { value: "not_contains", label: "does not contain" }, - { value: "is_any_of", label: "is any of" }, - { value: "is_none_of", label: "is none of" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - return [ - { value: "contains", label: "contains" }, - { value: "not_contains", label: "does not contain" }, - { value: "is", label: "is" }, - { value: "is_not", label: "is not" }, - { value: "starts_with", label: "starts with" }, - { value: "ends_with", label: "ends with" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; -} - -export function getFilterPathKey(path: number[]): string { - return path.length > 0 ? path.join("-") : "root"; -} - -export function updateFilterGroupOperatorAtPath( - root: FilterGroup, - path: number[], - operator: FilterGroup["operator"], -): FilterGroup { - return updateFilterGroupAtPath(root, path, (group) => ({ - ...group, - operator, - })); -} - -export function updateFilterConditionAtPath( - root: FilterGroup, - path: number[], - patch: Partial, -): FilterGroup { - if (path.length === 0) { - return root; - } - const [index, ...rest] = path; - const nextConditions = root.conditions.map((condition, conditionIndex) => { - if (conditionIndex !== index) { - return condition; - } - if (rest.length > 0 && isFilterGroupNode(condition)) { - return updateFilterConditionAtPath(condition, rest, patch); - } - if (isFilterGroupNode(condition)) { - return condition; - } - return { - ...condition, - ...patch, - }; - }); - return { - ...root, - conditions: nextConditions, - }; -} - -export function addFilterNodeAtPath( - root: FilterGroup, - path: number[], - node: FilterNode, -): FilterGroup { - return updateFilterGroupAtPath(root, path, (group) => ({ - ...group, - conditions: [...group.conditions, node], - })); -} - -export function removeFilterNodeAtPath( - root: FilterGroup, - path: number[], -): FilterGroup { - if (path.length === 0) { - return { ...root, conditions: [] }; - } - const parentPath = path.slice(0, -1); - const targetIndex = path[path.length - 1] ?? -1; - return updateFilterGroupAtPath(root, parentPath, (group) => ({ - ...group, - conditions: group.conditions.filter( - (_, conditionIndex) => conditionIndex !== targetIndex, - ), - })); -} - export function getDefaultViewTitle(viewType: DatabaseViewState["type"]): string { switch (viewType) { case "list": @@ -601,29 +377,3 @@ function getNextViewTitle( : `${baseTitle} ${matchingViews.length + 1}`; } -function isFilterGroupNode(value: FilterNode): value is FilterGroup { - return "conditions" in value; -} - -function updateFilterGroupAtPath( - root: FilterGroup, - path: FilterPath, - updater: (group: FilterGroup) => FilterGroup, -): FilterGroup { - if (path.length === 0) { - return updater(root); - } - const [index, ...rest] = path; - const nextConditions = root.conditions.map((condition, conditionIndex) => { - if (conditionIndex !== index || !isFilterGroupNode(condition)) { - return condition; - } - return updateFilterGroupAtPath(condition, rest, updater); - }); - return { - ...root, - conditions: nextConditions, - }; -} - -export { DATE_RELATIVE_FILTER_OPTIONS }; diff --git a/packages/extensions/database/src/utils/databaseRendererFilters.ts b/packages/extensions/database/src/utils/databaseRendererFilters.ts new file mode 100644 index 0000000..a0252d1 --- /dev/null +++ b/packages/extensions/database/src/utils/databaseRendererFilters.ts @@ -0,0 +1,275 @@ +import type { + ColumnType, + DatabaseColumnDef, + DatabaseViewState, + FilterCondition, + FilterGroup, + FilterOperator, +} from "../types"; + +export const DATE_RELATIVE_FILTER_OPTIONS = [ + { value: "today", label: "Today" }, + { value: "yesterday", label: "Yesterday" }, + { value: "tomorrow", label: "Tomorrow" }, + { value: "this_week", label: "This week" }, + { value: "last_7_days", label: "Last 7 days" }, + { value: "next_7_days", label: "Next 7 days" }, + { value: "this_month", label: "This month" }, +] as const; +type FilterNode = FilterCondition | FilterGroup; +type FilterPath = number[]; +export function createDefaultFilterCondition( + columnSchema: DatabaseColumnDef[], +): FilterCondition { + const firstColumn = columnSchema[0]; + if (!firstColumn) { + return { + columnId: "", + operator: "contains", + value: "", + }; + } + return { + columnId: firstColumn.id, + operator: defaultOperatorFor(firstColumn.type), + value: getDefaultFilterValue(firstColumn.type), + }; +} + +export function getDefaultFilterValue( + columnType: ColumnType, +): FilterCondition["value"] { + return getDefaultFilterValueForOperator( + columnType, + defaultOperatorFor(columnType), + ); +} + +export function getDefaultFilterValueForOperator( + columnType: ColumnType, + operator: FilterOperator, +): FilterCondition["value"] { + if (!operatorNeedsValue(operator)) { + return null; + } + if (columnType === "date") { + if (operator === "is_between") { + return ["", ""]; + } + if (operator === "is_relative") { + return DATE_RELATIVE_FILTER_OPTIONS[0]?.value ?? "today"; + } + } + return ""; +} + +export function operatorNeedsValue(operator: FilterOperator): boolean { + return ![ + "is_empty", + "is_not_empty", + "is_checked", + "is_unchecked", + ].includes(operator); +} + +export function dateFilterNeedsValue(operator: FilterOperator): boolean { + return operatorNeedsValue(operator); +} + +export function getDateFilterSingleValue( + value: FilterCondition["value"], +): string { + return typeof value === "string" ? value : ""; +} + +export function getDateFilterRangeValue( + value: FilterCondition["value"], + index: 0 | 1, +): string { + if (!Array.isArray(value)) { + return ""; + } + return value[index] ?? ""; +} + +export function defaultOperatorFor(columnType: ColumnType): FilterOperator { + if (columnType === "checkbox") { + return "is_checked"; + } + if (columnType === "number") { + return "="; + } + if (columnType === "date") { + return "is"; + } + if (columnType === "select") { + return "is"; + } + if (columnType === "multiSelect") { + return "is_any_of"; + } + return "contains"; +} + +export function operatorOptionsFor( + columnType: ColumnType, +): Array<{ value: FilterOperator; label: string }> { + if (columnType === "checkbox") { + return [ + { value: "is_checked", label: "is checked" }, + { value: "is_unchecked", label: "is unchecked" }, + ]; + } + if (columnType === "number") { + return [ + { value: "=", label: "=" }, + { value: "!=", label: "!=" }, + { value: ">", label: ">" }, + { value: "<", label: "<" }, + { value: ">=", label: ">=" }, + { value: "<=", label: "<=" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + if (columnType === "date") { + return [ + { value: "is", label: "is" }, + { value: "is_before", label: "is before" }, + { value: "is_after", label: "is after" }, + { value: "is_between", label: "is between" }, + { value: "is_relative", label: "is relative to today" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + if (columnType === "select") { + return [ + { value: "is", label: "is" }, + { value: "is_not", label: "is not" }, + { value: "is_any_of", label: "is any of" }, + { value: "is_none_of", label: "is none of" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + if (columnType === "multiSelect") { + return [ + { value: "contains", label: "contains" }, + { value: "not_contains", label: "does not contain" }, + { value: "is_any_of", label: "is any of" }, + { value: "is_none_of", label: "is none of" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + return [ + { value: "contains", label: "contains" }, + { value: "not_contains", label: "does not contain" }, + { value: "is", label: "is" }, + { value: "is_not", label: "is not" }, + { value: "starts_with", label: "starts with" }, + { value: "ends_with", label: "ends with" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; +} + +export function getFilterPathKey(path: number[]): string { + return path.length > 0 ? path.join("-") : "root"; +} + +export function updateFilterGroupOperatorAtPath( + root: FilterGroup, + path: number[], + operator: FilterGroup["operator"], +): FilterGroup { + return updateFilterGroupAtPath(root, path, (group) => ({ + ...group, + operator, + })); +} + +export function updateFilterConditionAtPath( + root: FilterGroup, + path: number[], + patch: Partial, +): FilterGroup { + if (path.length === 0) { + return root; + } + const [index, ...rest] = path; + const nextConditions = root.conditions.map((condition, conditionIndex) => { + if (conditionIndex !== index) { + return condition; + } + if (rest.length > 0 && isFilterGroupNode(condition)) { + return updateFilterConditionAtPath(condition, rest, patch); + } + if (isFilterGroupNode(condition)) { + return condition; + } + return { + ...condition, + ...patch, + }; + }); + return { + ...root, + conditions: nextConditions, + }; +} + +export function addFilterNodeAtPath( + root: FilterGroup, + path: number[], + node: FilterNode, +): FilterGroup { + return updateFilterGroupAtPath(root, path, (group) => ({ + ...group, + conditions: [...group.conditions, node], + })); +} + +export function removeFilterNodeAtPath( + root: FilterGroup, + path: number[], +): FilterGroup { + if (path.length === 0) { + return { ...root, conditions: [] }; + } + const parentPath = path.slice(0, -1); + const targetIndex = path[path.length - 1] ?? -1; + return updateFilterGroupAtPath(root, parentPath, (group) => ({ + ...group, + conditions: group.conditions.filter( + (_, conditionIndex) => conditionIndex !== targetIndex, + ), + })); +} + +function isFilterGroupNode(value: FilterNode): value is FilterGroup { + return "conditions" in value; +} + +function updateFilterGroupAtPath( + root: FilterGroup, + path: FilterPath, + updater: (group: FilterGroup) => FilterGroup, +): FilterGroup { + if (path.length === 0) { + return updater(root); + } + const [index, ...rest] = path; + const nextConditions = root.conditions.map((condition, conditionIndex) => { + if (conditionIndex !== index || !isFilterGroupNode(condition)) { + return condition; + } + return updateFilterGroupAtPath(condition, rest, updater); + }); + return { + ...root, + conditions: nextConditions, + }; +} + diff --git a/packages/extensions/document-ops/src/__tests__/tools.part2.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part2.test.ts new file mode 100644 index 0000000..54dfe39 --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part2.test.ts @@ -0,0 +1,430 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("guards ToolContext block insertion with the same policy", () => { + const editor = createFakeEditor("flow"); + const emit = vi.fn(); + const context = new ToolContextImpl(editor, "doc-1", emit); + + expect(() => + context.insertBlock("database", {}, "last"), + ).toThrow('Block type "database" is not available in flow documents.'); + + expect(emit).not.toHaveBeenCalled(); + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("allows ToolContext streaming without an undo manager", () => { + const streaming = { + beginStreaming: vi.fn(), + appendDelta: vi.fn(), + endStreaming: vi.fn(), + }; + const editor = { + ...createFakeEditor("structured"), + internals: { + emit: vi.fn(), + getSlot: vi.fn((key: string) => + key === "delta-stream:target" ? streaming : undefined + ), + }, + } as unknown as Editor; + const emit = vi.fn(); + const context = new ToolContextImpl(editor, "doc-1", emit); + + expect(() => { + context.beginStreaming("zone-1", "block-1"); + context.appendDelta("Hello"); + context.endStreaming("complete"); + }).not.toThrow(); + + expect(emit).toHaveBeenCalledWith({ + type: "gen-start", + zoneId: "zone-1", + blockId: "block-1", + }); + expect(emit).toHaveBeenCalledWith({ + type: "gen-delta", + zoneId: "zone-1", + delta: "Hello", + }); + expect(emit).toHaveBeenCalledWith({ + type: "gen-end", + zoneId: "zone-1", + status: "complete", + }); + expect(streaming.beginStreaming).toHaveBeenCalledWith("zone-1", "block-1"); + expect(streaming.appendDelta).toHaveBeenCalledWith("Hello"); + expect(streaming.endStreaming).toHaveBeenCalledWith("complete"); + }); + + it("defaults read_document to a compact summary", async () => { + const editor = createReadDocumentEditor(); + + const result = await readDocumentTool(editor).handler({}, {} as never) as { + blockCount: number; + preview: Array<{ id: string; type: string; content: string }>; + }; + + expect(result.blockCount).toBe(3); + expect(result.preview).toEqual([ + { id: "block-1", type: "paragraph", content: "First accepted" }, + { id: "block-2", type: "paragraph", content: "Second" }, + { id: "block-3", type: "heading", content: "Third" }, + ]); + }); + + it("limits read_document to the requested block range", async () => { + const editor = createReadDocumentEditor(); + + const result = await readDocumentTool(editor).handler( + { + format: "markdown", + range: { + startBlockId: "block-2", + endBlockId: "block-3", + }, + }, + {} as never, + ) as string; + + expect(result).toBe("Second\n\n# Third"); + }); + + it("returns summary context with selection details", async () => { + const editor = createReadDocumentEditor(); + + const result = await getContextTool(editor).handler( + { + format: "summary", + includeSelection: true, + }, + {} as never, + ) as { + blockCount: number; + activeBlockId: string; + selectedText: string; + blocks: Array<{ id: string; preview: string }>; + }; + + expect(result.blockCount).toBe(3); + expect(result.activeBlockId).toBe("block-2"); + expect(result.selectedText).toBe("Second"); + expect(result.blocks.map((block) => block.id)).toEqual([ + "block-1", + "block-2", + "block-3", + ]); + }); + + it("returns cursor context without reading the full document", async () => { + const editor = createReadDocumentEditor(); + + const result = await getCursorContextTool(editor).handler({}, {} as never) as { + activeBlockId: string | null; + activeBlockType: string | null; + selectedText: string | null; + surroundingBlocks: Array<{ id: string }>; + structuredTarget: { target: { kind: string }; validOperations: string[] } | null; + }; + + expect(result.activeBlockId).toBe("block-2"); + expect(result.activeBlockType).toBe("paragraph"); + expect(result.selectedText).toBe("Second"); + expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ + "block-1", + "block-2", + "block-3", + ]); + expect(result.structuredTarget?.target.kind).toBe("block"); + expect(result.structuredTarget?.validOperations).toContain("replace_text"); + }); + +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.part3.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part3.test.ts new file mode 100644 index 0000000..5138dfa --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part3.test.ts @@ -0,0 +1,434 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("uses bounded neighbor traversal for cursor context when block links exist", async () => { + const blocks: Array<{ + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: () => string; + textDeltas: () => Array<{ insert: string }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; + }> = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "First", + textDeltas: () => [{ insert: "First" }], + prev: null, + next: null, + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Second", + textDeltas: () => [{ insert: "Second" }], + prev: null, + next: null, + }), + createMockBlockHandle({ + id: "block-3", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Third", + textDeltas: () => [{ insert: "Third" }], + prev: null, + next: null, + }), + ]; + blocks[0].next = blocks[1]; + blocks[1].prev = blocks[0]; + blocks[1].next = blocks[2]; + blocks[2].prev = blocks[1]; + + const editor = { + documentProfile: "structured", + schema: defaultSchema, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + blocks: vi.fn(() => { + throw new Error("Cursor context should not scan the full document."); + }), + } as unknown as Editor; + + const result = await getCursorContextTool(editor).handler({}, {} as never) as { + surroundingBlocks: Array<{ id: string }>; + }; + + expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ + "block-1", + "block-2", + "block-3", + ]); + }); + + it("inspects table targets with schema-aware details", async () => { + const editor = createStructuredTargetEditor("table-1"); + + const result = await inspectTargetTool(editor).handler({}, {} as never) as { + target: { + target: { + kind: string; + rowCount: number; + columnCount: number; + }; + validOperations: string[]; + } | null; + }; + + expect(result.target?.target).toMatchObject({ + kind: "table", + rowCount: 3, + columnCount: 2, + }); + expect(result.target?.validOperations).toContain("insert_row"); + expect(result.target?.validOperations).toContain("set_cell_text"); + }); + + it("inspects database targets with view metadata", async () => { + const editor = createStructuredTargetEditor("database-1"); + + const result = await inspectTargetTool(editor).handler({}, {} as never) as { + target: { + target: { + kind: string; + rowCount: number; + activeViewId: string | null; + }; + validOperations: string[]; + } | null; + }; + + expect(result.target?.target).toMatchObject({ + kind: "database", + rowCount: 2, + activeViewId: "view-1", + }); + expect(result.target?.validOperations).toContain("add_column"); + expect(result.target?.validOperations).toContain("set_active_view"); + }); + + it("returns no valid mutation operations for read-only targets", async () => { + const editor = createStructuredTargetEditor("subdocument-1"); + + const result = await listValidOperationsTool(editor).handler({}, {} as never) as { + operations: string[]; + }; + + expect(result.operations).toEqual([]); + }); + +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.part4.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part4.test.ts new file mode 100644 index 0000000..d7ed489 --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part4.test.ts @@ -0,0 +1,427 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("rejects block mutations against read-only targets", async () => { + const editor = createStructuredTargetEditor("subdocument-1"); + + await expect( + updateBlockTool(editor).handler( + { + blockId: "subdocument-1", + props: { title: "Forbidden" }, + }, + {} as never, + ), + ).rejects.toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + await expect( + deleteBlockTool(editor).handler( + { blockId: "subdocument-1" }, + {} as never, + ), + ).rejects.toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + await expect( + moveBlockTool(editor).handler( + { + blockId: "subdocument-1", + position: "last", + }, + {} as never, + ), + ).rejects.toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("guards ToolContext block mutations with the same policy", () => { + const editor = createStructuredTargetEditor("subdocument-1"); + const emit = vi.fn(); + const context = new ToolContextImpl(editor, "doc-1", emit); + + expect(() => + context.updateBlock("subdocument-1", { title: "Forbidden" }), + ).toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + expect(() => context.deleteBlock("subdocument-1")).toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + + expect(emit).not.toHaveBeenCalled(); + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("returns raw text when suggestions are included", async () => { + const editor = createReadDocumentEditor(); + + const result = await readDocumentTool(editor).handler( + { + format: "json", + includeSuggestions: true, + }, + {} as never, + ) as { + viewMode: string; + blocks: Array<{ id: string; content: string }>; + }; + + expect(result.viewMode).toBe("raw"); + expect(result.blocks.find((block) => block.id === "block-2")?.content).toBe( + "Second draft", + ); + }); + + it("includes nested blocks when reading document ranges", async () => { + const editor = createNestedDocumentEditor(); + + const result = await readDocumentTool(editor).handler( + { format: "summary" }, + {} as never, + ) as { + preview: Array<{ id: string }>; + }; + + expect(result.preview.map((block) => block.id)).toEqual([ + "heading-1", + "layout-1", + "paragraph-1", + ]); + }); + + it("searches nested blocks through the shared document traversal", async () => { + const editor = createNestedDocumentEditor(); + + const result = await searchDocumentTool(editor).handler( + { query: "stable block identity" }, + {} as never, + ) as Array<{ blockId: string }>; + + expect(result).toEqual([ + expect.objectContaining({ blockId: "paragraph-1" }), + ]); + }); + + it("retrieves ranked spans with nested-block and heading metadata", async () => { + const editor = createNestedDocumentEditor(); + + const result = await retrieveDocumentSpansTool(editor).handler( + { + query: "stable block identity architecture", + activeBlockId: "paragraph-1", + targetBlockId: "paragraph-1", + }, + {} as never, + ) as { + spans: Array<{ + id: string; + blockIds: string[]; + headingPath: string[]; + score: number; + }>; + }; + + expect(result.spans[0]).toMatchObject({ + id: "span:paragraph-1", + blockIds: ["heading-1", "layout-1", "paragraph-1"], + range: { + startBlockId: "heading-1", + endBlockId: "paragraph-1", + }, + headingPath: ["Architecture"], + }); + expect(result.spans[0]?.score).toBeGreaterThan(0); + }); + +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.part5.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part5.test.ts new file mode 100644 index 0000000..5b107ef --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part5.test.ts @@ -0,0 +1,346 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("rejects invalid tool inputs at the document-ops runtime boundary", async () => { + const runtime = new ToolRuntimeImpl(); + const searchEditor = createReadDocumentEditor(); + const mutationEditor = createStructuredTargetEditor("paragraph-1"); + runtime.registerTool(searchDocumentTool(searchEditor)); + runtime.registerTool(retrieveDocumentSpansTool(searchEditor)); + runtime.registerTool(moveBlockTool(mutationEditor)); + runtime.registerTool(writeDocumentTool(mutationEditor)); + + await expect( + runtime.executeTool( + "search_document", + { + query: "", + maxResults: 0, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "search_document"'); + await expect( + runtime.executeTool( + "retrieve_document_spans", + { + query: "", + maxResults: 99, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "retrieve_document_spans"'); + await expect( + runtime.executeTool( + "move_block", + { + blockId: "paragraph-1", + position: { + after: "", + }, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "move_block"'); + await expect( + runtime.executeTool( + "write_document", + { + content: "Hello", + position: { + parent: "paragraph-1", + index: -1, + }, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "write_document"'); + }); +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.test.ts b/packages/extensions/document-ops/src/__tests__/tools.test.ts index 75c87c8..96065e2 100644 --- a/packages/extensions/document-ops/src/__tests__/tools.test.ts +++ b/packages/extensions/document-ops/src/__tests__/tools.test.ts @@ -442,477 +442,4 @@ describe("@pen/document-ops tools", () => { expect(flowEditor.internals.emit).toHaveBeenCalled(); }); - it("guards ToolContext block insertion with the same policy", () => { - const editor = createFakeEditor("flow"); - const emit = vi.fn(); - const context = new ToolContextImpl(editor, "doc-1", emit); - - expect(() => - context.insertBlock("database", {}, "last"), - ).toThrow('Block type "database" is not available in flow documents.'); - - expect(emit).not.toHaveBeenCalled(); - expect(editor.apply).not.toHaveBeenCalled(); - }); - - it("allows ToolContext streaming without an undo manager", () => { - const streaming = { - beginStreaming: vi.fn(), - appendDelta: vi.fn(), - endStreaming: vi.fn(), - }; - const editor = { - ...createFakeEditor("structured"), - internals: { - emit: vi.fn(), - getSlot: vi.fn((key: string) => - key === "delta-stream:target" ? streaming : undefined - ), - }, - } as unknown as Editor; - const emit = vi.fn(); - const context = new ToolContextImpl(editor, "doc-1", emit); - - expect(() => { - context.beginStreaming("zone-1", "block-1"); - context.appendDelta("Hello"); - context.endStreaming("complete"); - }).not.toThrow(); - - expect(emit).toHaveBeenCalledWith({ - type: "gen-start", - zoneId: "zone-1", - blockId: "block-1", - }); - expect(emit).toHaveBeenCalledWith({ - type: "gen-delta", - zoneId: "zone-1", - delta: "Hello", - }); - expect(emit).toHaveBeenCalledWith({ - type: "gen-end", - zoneId: "zone-1", - status: "complete", - }); - expect(streaming.beginStreaming).toHaveBeenCalledWith("zone-1", "block-1"); - expect(streaming.appendDelta).toHaveBeenCalledWith("Hello"); - expect(streaming.endStreaming).toHaveBeenCalledWith("complete"); - }); - - it("defaults read_document to a compact summary", async () => { - const editor = createReadDocumentEditor(); - - const result = await readDocumentTool(editor).handler({}, {} as never) as { - blockCount: number; - preview: Array<{ id: string; type: string; content: string }>; - }; - - expect(result.blockCount).toBe(3); - expect(result.preview).toEqual([ - { id: "block-1", type: "paragraph", content: "First accepted" }, - { id: "block-2", type: "paragraph", content: "Second" }, - { id: "block-3", type: "heading", content: "Third" }, - ]); - }); - - it("limits read_document to the requested block range", async () => { - const editor = createReadDocumentEditor(); - - const result = await readDocumentTool(editor).handler( - { - format: "markdown", - range: { - startBlockId: "block-2", - endBlockId: "block-3", - }, - }, - {} as never, - ) as string; - - expect(result).toBe("Second\n\n# Third"); - }); - - it("returns summary context with selection details", async () => { - const editor = createReadDocumentEditor(); - - const result = await getContextTool(editor).handler( - { - format: "summary", - includeSelection: true, - }, - {} as never, - ) as { - blockCount: number; - activeBlockId: string; - selectedText: string; - blocks: Array<{ id: string; preview: string }>; - }; - - expect(result.blockCount).toBe(3); - expect(result.activeBlockId).toBe("block-2"); - expect(result.selectedText).toBe("Second"); - expect(result.blocks.map((block) => block.id)).toEqual([ - "block-1", - "block-2", - "block-3", - ]); - }); - - it("returns cursor context without reading the full document", async () => { - const editor = createReadDocumentEditor(); - - const result = await getCursorContextTool(editor).handler({}, {} as never) as { - activeBlockId: string | null; - activeBlockType: string | null; - selectedText: string | null; - surroundingBlocks: Array<{ id: string }>; - structuredTarget: { target: { kind: string }; validOperations: string[] } | null; - }; - - expect(result.activeBlockId).toBe("block-2"); - expect(result.activeBlockType).toBe("paragraph"); - expect(result.selectedText).toBe("Second"); - expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ - "block-1", - "block-2", - "block-3", - ]); - expect(result.structuredTarget?.target.kind).toBe("block"); - expect(result.structuredTarget?.validOperations).toContain("replace_text"); - }); - - it("uses bounded neighbor traversal for cursor context when block links exist", async () => { - const blocks: Array<{ - id: string; - type: string; - props: Record; - children: unknown[]; - textContent: () => string; - textDeltas: () => Array<{ insert: string }>; - tableRowCount: () => number; - tableColumnCount: () => number; - tableCell: () => null; - tableRow: () => null; - tableColumns: () => never[]; - databaseViews: () => never[]; - databasePrimaryViewId: () => null; - databaseActiveView: () => null; - prev?: unknown; - next?: unknown; - }> = [ - createMockBlockHandle({ - id: "block-1", - type: "paragraph", - props: {}, - children: [], - textContent: () => "First", - textDeltas: () => [{ insert: "First" }], - prev: null, - next: null, - }), - createMockBlockHandle({ - id: "block-2", - type: "paragraph", - props: {}, - children: [], - textContent: () => "Second", - textDeltas: () => [{ insert: "Second" }], - prev: null, - next: null, - }), - createMockBlockHandle({ - id: "block-3", - type: "paragraph", - props: {}, - children: [], - textContent: () => "Third", - textDeltas: () => [{ insert: "Third" }], - prev: null, - next: null, - }), - ]; - blocks[0].next = blocks[1]; - blocks[1].prev = blocks[0]; - blocks[1].next = blocks[2]; - blocks[2].prev = blocks[1]; - - const editor = { - documentProfile: "structured", - schema: defaultSchema, - getSelection: () => ({ - type: "text", - anchor: { blockId: "block-2", offset: 0 }, - focus: { blockId: "block-2", offset: 6 }, - isCollapsed: false, - toRange: () => ({ - start: { blockId: "block-2", offset: 0 }, - end: { blockId: "block-2", offset: 6 }, - blockRange: ["block-2"], - }), - }), - getSelectedText: () => "Second", - getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, - blocks: vi.fn(() => { - throw new Error("Cursor context should not scan the full document."); - }), - } as unknown as Editor; - - const result = await getCursorContextTool(editor).handler({}, {} as never) as { - surroundingBlocks: Array<{ id: string }>; - }; - - expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ - "block-1", - "block-2", - "block-3", - ]); - }); - - it("inspects table targets with schema-aware details", async () => { - const editor = createStructuredTargetEditor("table-1"); - - const result = await inspectTargetTool(editor).handler({}, {} as never) as { - target: { - target: { - kind: string; - rowCount: number; - columnCount: number; - }; - validOperations: string[]; - } | null; - }; - - expect(result.target?.target).toMatchObject({ - kind: "table", - rowCount: 3, - columnCount: 2, - }); - expect(result.target?.validOperations).toContain("insert_row"); - expect(result.target?.validOperations).toContain("set_cell_text"); - }); - - it("inspects database targets with view metadata", async () => { - const editor = createStructuredTargetEditor("database-1"); - - const result = await inspectTargetTool(editor).handler({}, {} as never) as { - target: { - target: { - kind: string; - rowCount: number; - activeViewId: string | null; - }; - validOperations: string[]; - } | null; - }; - - expect(result.target?.target).toMatchObject({ - kind: "database", - rowCount: 2, - activeViewId: "view-1", - }); - expect(result.target?.validOperations).toContain("add_column"); - expect(result.target?.validOperations).toContain("set_active_view"); - }); - - it("returns no valid mutation operations for read-only targets", async () => { - const editor = createStructuredTargetEditor("subdocument-1"); - - const result = await listValidOperationsTool(editor).handler({}, {} as never) as { - operations: string[]; - }; - - expect(result.operations).toEqual([]); - }); - - it("rejects block mutations against read-only targets", async () => { - const editor = createStructuredTargetEditor("subdocument-1"); - - await expect( - updateBlockTool(editor).handler( - { - blockId: "subdocument-1", - props: { title: "Forbidden" }, - }, - {} as never, - ), - ).rejects.toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - await expect( - deleteBlockTool(editor).handler( - { blockId: "subdocument-1" }, - {} as never, - ), - ).rejects.toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - await expect( - moveBlockTool(editor).handler( - { - blockId: "subdocument-1", - position: "last", - }, - {} as never, - ), - ).rejects.toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - - expect(editor.apply).not.toHaveBeenCalled(); - }); - - it("guards ToolContext block mutations with the same policy", () => { - const editor = createStructuredTargetEditor("subdocument-1"); - const emit = vi.fn(); - const context = new ToolContextImpl(editor, "doc-1", emit); - - expect(() => - context.updateBlock("subdocument-1", { title: "Forbidden" }), - ).toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - expect(() => context.deleteBlock("subdocument-1")).toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - - expect(emit).not.toHaveBeenCalled(); - expect(editor.apply).not.toHaveBeenCalled(); - }); - - it("returns raw text when suggestions are included", async () => { - const editor = createReadDocumentEditor(); - - const result = await readDocumentTool(editor).handler( - { - format: "json", - includeSuggestions: true, - }, - {} as never, - ) as { - viewMode: string; - blocks: Array<{ id: string; content: string }>; - }; - - expect(result.viewMode).toBe("raw"); - expect(result.blocks.find((block) => block.id === "block-2")?.content).toBe( - "Second draft", - ); - }); - - it("includes nested blocks when reading document ranges", async () => { - const editor = createNestedDocumentEditor(); - - const result = await readDocumentTool(editor).handler( - { format: "summary" }, - {} as never, - ) as { - preview: Array<{ id: string }>; - }; - - expect(result.preview.map((block) => block.id)).toEqual([ - "heading-1", - "layout-1", - "paragraph-1", - ]); - }); - - it("searches nested blocks through the shared document traversal", async () => { - const editor = createNestedDocumentEditor(); - - const result = await searchDocumentTool(editor).handler( - { query: "stable block identity" }, - {} as never, - ) as Array<{ blockId: string }>; - - expect(result).toEqual([ - expect.objectContaining({ blockId: "paragraph-1" }), - ]); - }); - - it("retrieves ranked spans with nested-block and heading metadata", async () => { - const editor = createNestedDocumentEditor(); - - const result = await retrieveDocumentSpansTool(editor).handler( - { - query: "stable block identity architecture", - activeBlockId: "paragraph-1", - targetBlockId: "paragraph-1", - }, - {} as never, - ) as { - spans: Array<{ - id: string; - blockIds: string[]; - headingPath: string[]; - score: number; - }>; - }; - - expect(result.spans[0]).toMatchObject({ - id: "span:paragraph-1", - blockIds: ["heading-1", "layout-1", "paragraph-1"], - range: { - startBlockId: "heading-1", - endBlockId: "paragraph-1", - }, - headingPath: ["Architecture"], - }); - expect(result.spans[0]?.score).toBeGreaterThan(0); - }); - - it("rejects invalid tool inputs at the document-ops runtime boundary", async () => { - const runtime = new ToolRuntimeImpl(); - const searchEditor = createReadDocumentEditor(); - const mutationEditor = createStructuredTargetEditor("paragraph-1"); - runtime.registerTool(searchDocumentTool(searchEditor)); - runtime.registerTool(retrieveDocumentSpansTool(searchEditor)); - runtime.registerTool(moveBlockTool(mutationEditor)); - runtime.registerTool(writeDocumentTool(mutationEditor)); - - await expect( - runtime.executeTool( - "search_document", - { - query: "", - maxResults: 0, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "search_document"'); - await expect( - runtime.executeTool( - "retrieve_document_spans", - { - query: "", - maxResults: 99, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "retrieve_document_spans"'); - await expect( - runtime.executeTool( - "move_block", - { - blockId: "paragraph-1", - position: { - after: "", - }, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "move_block"'); - await expect( - runtime.executeTool( - "write_document", - { - content: "Hello", - position: { - parent: "paragraph-1", - index: -1, - }, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "write_document"'); - }); }); diff --git a/packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts b/packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts new file mode 100644 index 0000000..b870855 --- /dev/null +++ b/packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { createEditor } from "@pen/core"; +import type { DocumentOp } from "@pen/types"; +import { htmlExporter } from "../exporter"; + +type InsertTableCellTextOp = Extract; +type FormatTableCellTextOp = Extract; +type UpdateTableColumnsOp = Extract; +type DatabaseInsertRowOp = Extract; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function editorWithBlocks(ops: Parameters["apply"]>[0]) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + return editor; +} + +function editorWithTable( + insertOp: Parameters["apply"]>[0][0], + cellOps: Parameters["apply"]>[0], +) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply([insertOp]); + if (cellOps.length > 0) { + editor.apply(cellOps); + } + return editor; +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +) { + const seedEditor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + seed(seedEditor); + + const document = seedEditor.internals.crdtDoc; + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + + const editor = createEditor({ + document, + preset: noDefaultExtensionsPreset, + }); + seedEditor.destroy(); + return editor; +} + +describe("@pen/export-html", () => { + it("supports resolved suggestion export inside table cells", () => { + const editor = editorWithTable( + { + type: "insert-block", + blockId: "t3", + blockType: "table", + props: { hasHeaderRow: false }, + position: "last", + }, + [ + { + type: "insert-table-cell-text", + blockId: "t3", + row: 0, + col: 0, + offset: 0, + text: "ab", + } as InsertTableCellTextOp, + { + type: "format-table-cell-text", + blockId: "t3", + row: 0, + col: 0, + offset: 0, + length: 1, + marks: { + suggestion: { id: "cell-insert", action: "insert" }, + }, + } as FormatTableCellTextOp, + { + type: "format-table-cell-text", + blockId: "t3", + row: 0, + col: 0, + offset: 1, + length: 1, + marks: { + suggestion: { id: "cell-delete", action: "delete" }, + }, + } as FormatTableCellTextOp, + ], + ); + + const rawHtml = htmlExporter.export(editor); + expect(rawHtml).toContain('a'); + expect(rawHtml).toContain('b'); + + const resolvedHtml = htmlExporter.export(editor, { + includeSuggestions: false, + }); + expect(resolvedHtml).toContain("a"); + expect(resolvedHtml).not.toContain("b<"); + + editor.destroy(); + }); + + it("preserves seeded structured and hidden blocks when exporting flow documents", () => { + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db1", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "update-table-columns", + blockId: "db1", + columns: [{ id: "name", title: "Name", type: "text" }], + } as UpdateTableColumnsOp, + { + type: "database-insert-row", + blockId: "db1", + rowId: "row-1", + values: { name: "Alice" }, + } as DatabaseInsertRowOp, + { + type: "insert-block", + blockId: "sub-1", + blockType: "subdocument", + props: { subdocumentGuid: "nested-guid" }, + position: "last", + }, + ]); + }); + + const html = htmlExporter.export(editor); + + expect(editor.documentProfile).toBe("flow"); + expect(html).toContain("data-pen-database="); + expect(html).toContain(">Alice"); + expect(html).toContain('data-pen-subdocument="'); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/export-html/src/__tests__/exportHtml.test.ts b/packages/extensions/export-html/src/__tests__/exportHtml.test.ts index 19d3299..19cc5de 100644 --- a/packages/extensions/export-html/src/__tests__/exportHtml.test.ts +++ b/packages/extensions/export-html/src/__tests__/exportHtml.test.ts @@ -415,102 +415,4 @@ describe("@pen/export-html", () => { editor.destroy(); }); - it("supports resolved suggestion export inside table cells", () => { - const editor = editorWithTable( - { - type: "insert-block", - blockId: "t3", - blockType: "table", - props: { hasHeaderRow: false }, - position: "last", - }, - [ - { - type: "insert-table-cell-text", - blockId: "t3", - row: 0, - col: 0, - offset: 0, - text: "ab", - } as InsertTableCellTextOp, - { - type: "format-table-cell-text", - blockId: "t3", - row: 0, - col: 0, - offset: 0, - length: 1, - marks: { - suggestion: { id: "cell-insert", action: "insert" }, - }, - } as FormatTableCellTextOp, - { - type: "format-table-cell-text", - blockId: "t3", - row: 0, - col: 0, - offset: 1, - length: 1, - marks: { - suggestion: { id: "cell-delete", action: "delete" }, - }, - } as FormatTableCellTextOp, - ], - ); - - const rawHtml = htmlExporter.export(editor); - expect(rawHtml).toContain('a'); - expect(rawHtml).toContain('b'); - - const resolvedHtml = htmlExporter.export(editor, { - includeSuggestions: false, - }); - expect(resolvedHtml).toContain("a"); - expect(resolvedHtml).not.toContain("b<"); - - editor.destroy(); - }); - - it("preserves seeded structured and hidden blocks when exporting flow documents", () => { - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db1", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "update-table-columns", - blockId: "db1", - columns: [{ id: "name", title: "Name", type: "text" }], - } as UpdateTableColumnsOp, - { - type: "database-insert-row", - blockId: "db1", - rowId: "row-1", - values: { name: "Alice" }, - } as DatabaseInsertRowOp, - { - type: "insert-block", - blockId: "sub-1", - blockType: "subdocument", - props: { subdocumentGuid: "nested-guid" }, - position: "last", - }, - ]); - }); - - const html = htmlExporter.export(editor); - - expect(editor.documentProfile).toBe("flow"); - expect(html).toContain("data-pen-database="); - expect(html).toContain(">Alice"); - expect(html).toContain('data-pen-subdocument="'); - - editor.destroy(); - }); }); diff --git a/packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts new file mode 100644 index 0000000..0a329ac --- /dev/null +++ b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { + blocksToOps, + createEditor, + type PendingBlock, +} from "@pen/core"; +import type { DocumentOp } from "@pen/types"; +import { markdownExporter } from "../exporter"; + +type InsertTableCellTextOp = Extract; +type FormatTableCellTextOp = Extract; +type UpdateTableColumnsOp = Extract; +type DatabaseInsertRowOp = Extract; +type InsertBlockOp = Extract; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function editorWithBlocks(ops: Parameters["apply"]>[0]) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + return editor; +} + +function editorWithTable( + insertOp: Parameters["apply"]>[0][0], + cellOps: Parameters["apply"]>[0], +) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply([insertOp]); + if (cellOps.length > 0) { + editor.apply(cellOps); + } + return editor; +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +) { + const seedEditor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + seed(seedEditor); + + const document = seedEditor.internals.crdtDoc; + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + + const editor = createEditor({ + document, + preset: noDefaultExtensionsPreset, + }); + seedEditor.destroy(); + return editor; +} + +describe("table markdown round-trip", () => { + it("import → editor → export produces equivalent markdown", () => { + const inputBlocks: PendingBlock[] = [ + { + type: "table", + props: { hasHeaderRow: true, hasHeaderColumn: false }, + children: [ + { + type: "__table_row", + props: { _rowIndex: 0 }, + children: [ + { type: "__table_cell", props: {}, content: "Name" }, + { type: "__table_cell", props: {}, content: "Value" }, + ], + }, + { + type: "__table_row", + props: { _rowIndex: 1 }, + children: [ + { type: "__table_cell", props: {}, content: "foo" }, + { type: "__table_cell", props: {}, content: "42" }, + ], + }, + ], + }, + ]; + + const ops = blocksToOps(inputBlocks); + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + + const tableBlockId = (ops[0] as InsertBlockOp).blockId; + const cell00 = editor.getBlock(tableBlockId)?.tableCell(0, 0); + const cell01 = editor.getBlock(tableBlockId)?.tableCell(0, 1); + const cell10 = editor.getBlock(tableBlockId)?.tableCell(1, 0); + const cell11 = editor.getBlock(tableBlockId)?.tableCell(1, 1); + expect(cell00?.textContent()).toBe("Name"); + expect(cell01?.textContent()).toBe("Value"); + expect(cell10?.textContent()).toBe("foo"); + expect(cell11?.textContent()).toBe("42"); + + const md = markdownExporter.export(editor); + expect(md).toContain("| Name | Value |"); + expect(md).toContain("| --- | --- |"); + expect(md).toContain("| foo | 42 |"); + + editor.destroy(); + }); + + it("round-trips a 2-column table through import and export", () => { + const inputBlocks: PendingBlock[] = [ + { + type: "table", + props: { hasHeaderRow: true, hasHeaderColumn: false }, + children: [ + { + type: "__table_row", + props: { _rowIndex: 0 }, + children: [ + { type: "__table_cell", props: {}, content: "X" }, + { type: "__table_cell", props: {}, content: "Y" }, + ], + }, + { + type: "__table_row", + props: { _rowIndex: 1 }, + children: [ + { type: "__table_cell", props: {}, content: "10" }, + { type: "__table_cell", props: {}, content: "20" }, + ], + }, + ], + }, + ]; + + const ops = blocksToOps(inputBlocks); + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + + const block = editor.getBlock((ops[0] as InsertBlockOp).blockId); + expect(block?.tableRowCount()).toBe(2); + expect(block?.tableColumnCount()).toBe(2); + + const md = markdownExporter.export(editor); + expect(md).toContain("| X | Y |"); + expect(md).toContain("| --- | --- |"); + expect(md).toContain("| 10 | 20 |"); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts index ac32bf9..e0e73f7 100644 --- a/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts +++ b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts @@ -418,99 +418,3 @@ describe("@pen/export-markdown", () => { editor.destroy(); }); }); - -describe("table markdown round-trip", () => { - it("import → editor → export produces equivalent markdown", () => { - const inputBlocks: PendingBlock[] = [ - { - type: "table", - props: { hasHeaderRow: true, hasHeaderColumn: false }, - children: [ - { - type: "__table_row", - props: { _rowIndex: 0 }, - children: [ - { type: "__table_cell", props: {}, content: "Name" }, - { type: "__table_cell", props: {}, content: "Value" }, - ], - }, - { - type: "__table_row", - props: { _rowIndex: 1 }, - children: [ - { type: "__table_cell", props: {}, content: "foo" }, - { type: "__table_cell", props: {}, content: "42" }, - ], - }, - ], - }, - ]; - - const ops = blocksToOps(inputBlocks); - const editor = createEditor({ - preset: noDefaultExtensionsPreset, - }); - editor.apply(ops); - - const tableBlockId = (ops[0] as InsertBlockOp).blockId; - const cell00 = editor.getBlock(tableBlockId)?.tableCell(0, 0); - const cell01 = editor.getBlock(tableBlockId)?.tableCell(0, 1); - const cell10 = editor.getBlock(tableBlockId)?.tableCell(1, 0); - const cell11 = editor.getBlock(tableBlockId)?.tableCell(1, 1); - expect(cell00?.textContent()).toBe("Name"); - expect(cell01?.textContent()).toBe("Value"); - expect(cell10?.textContent()).toBe("foo"); - expect(cell11?.textContent()).toBe("42"); - - const md = markdownExporter.export(editor); - expect(md).toContain("| Name | Value |"); - expect(md).toContain("| --- | --- |"); - expect(md).toContain("| foo | 42 |"); - - editor.destroy(); - }); - - it("round-trips a 2-column table through import and export", () => { - const inputBlocks: PendingBlock[] = [ - { - type: "table", - props: { hasHeaderRow: true, hasHeaderColumn: false }, - children: [ - { - type: "__table_row", - props: { _rowIndex: 0 }, - children: [ - { type: "__table_cell", props: {}, content: "X" }, - { type: "__table_cell", props: {}, content: "Y" }, - ], - }, - { - type: "__table_row", - props: { _rowIndex: 1 }, - children: [ - { type: "__table_cell", props: {}, content: "10" }, - { type: "__table_cell", props: {}, content: "20" }, - ], - }, - ], - }, - ]; - - const ops = blocksToOps(inputBlocks); - const editor = createEditor({ - preset: noDefaultExtensionsPreset, - }); - editor.apply(ops); - - const block = editor.getBlock((ops[0] as InsertBlockOp).blockId); - expect(block?.tableRowCount()).toBe(2); - expect(block?.tableColumnCount()).toBe(2); - - const md = markdownExporter.export(editor); - expect(md).toContain("| X | Y |"); - expect(md).toContain("| --- | --- |"); - expect(md).toContain("| 10 | 20 |"); - - editor.destroy(); - }); -}); diff --git a/packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts b/packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts new file mode 100644 index 0000000..797b231 --- /dev/null +++ b/packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect } from "vitest"; +import { blocksToOps, createEditor } from "@pen/core"; +import type { HTMLImportElement, SchemaRegistry } from "@pen/types"; +import { createDefaultSchema } from "@pen/schema-default"; +import { htmlExporter } from "@pen/export-html"; +import { htmlImporter, parseHtmlToBlocks } from "../importer"; +import { sanitizeHTML } from "../sanitize"; +import { parseHTML } from "../domAdapter"; +import { domToBlocks } from "../domToBlocks"; +import { parseInlineContent } from "../inlineParser"; +import type { DOMNode } from "../domAdapter"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const stubRegistry: SchemaRegistry = { + resolve: () => null, + resolveInline: () => null, + resolveApp: () => null, + resolveLayout: () => null, + allBlocks: () => [], + allInlines: () => [], + allApps: () => [], + allBlockDisplays: () => [], +}; + +const defaultRegistry = createDefaultSchema(); + +function convert(html: string, registry: SchemaRegistry = stubRegistry) { + const sanitized = sanitizeHTML(html); + const dom = parseHTML(sanitized); + return domToBlocks(dom, registry); +} + +function databaseEditor() { + const editor = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + editor.apply([{ + type: "insert-block", + blockId: "d1", + blockType: "database", + props: { title: "Roadmap", dataSource: "local" }, + position: "last", + }]); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "name", title: "Name", type: "text" }, + { + id: "tags", + title: "Tags", + type: "multiSelect", + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "feature", value: "Feature", color: "blue" }, + ], + }, + { id: "done", title: "Done", type: "checkbox" }, + ], + }]); + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "roadmap-1", + values: { + name: "Ship importer", + tags: JSON.stringify(["Feature"]), + done: "false", + }, + }]); + editor.apply([{ + type: "database-update-view", + blockId: "d1", + patch: { + title: "Main", + type: "table", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + }, + }]); + return editor; +} + +describe("@pen/import-html dom-to-blocks", () => { + it("heading + paragraph (AC 28)", () => { + const blocks = convert("

Title

Body

"); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "heading", + props: { level: 1 }, + content: "Title", + }); + expect(blocks[1]).toMatchObject({ + type: "paragraph", + content: "Body", + }); + }); + + it("script tag is stripped (AC 29)", () => { + const blocks = convert('

safe

'); + + const types = blocks.map((b) => b.type); + expect(types).not.toContain("script"); + expect(blocks.some((b) => b.content === "safe")).toBe(true); + }); + + it("event handler stripped, text preserved (AC 30)", () => { + const blocks = convert('
text
'); + + expect(blocks.length).toBeGreaterThanOrEqual(1); + const hasText = blocks.some( + (b) => b.content?.includes("text"), + ); + expect(hasText).toBe(true); + }); + + it("bold mark from (AC 32)", () => { + const blocks = convert("

bold

"); + + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toBe("bold"); + expect(blocks[0].marks?.some((m) => m.type === "bold")).toBe(true); + }); + + it("italic mark from (AC 33)", () => { + const blocks = convert("

italic

"); + + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toBe("italic"); + expect(blocks[0].marks?.some((m) => m.type === "italic")).toBe(true); + }); + + it("link mark with href (AC 34)", () => { + const blocks = convert('

text

'); + + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toBe("text"); + const linkMark = blocks[0].marks?.find((m) => m.type === "link"); + expect(linkMark).toBeDefined(); + expect(linkMark!.props!.href).toBe("https://example.com"); + }); + + it("bullet list items (AC 35)", () => { + const blocks = convert("
  • a
  • b
"); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "bulletListItem", + content: "a", + }); + expect(blocks[1]).toMatchObject({ + type: "bulletListItem", + content: "b", + }); + }); + + it("numbered list items (AC 36)", () => { + const blocks = convert("
  1. a
  2. b
"); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "numberedListItem", + content: "a", + }); + expect(blocks[1]).toMatchObject({ + type: "numberedListItem", + content: "b", + }); + }); + + it("nested list with indent (AC 37)", () => { + const blocks = convert( + "
  • a
    • b
", + ); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "bulletListItem", + content: "a", + props: { indent: 0 }, + }); + expect(blocks[1]).toMatchObject({ + type: "bulletListItem", + content: "b", + props: { indent: 1 }, + }); + }); + + it("code block with language (AC 38)", () => { + const blocks = convert( + '
const x = 1;
', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "codeBlock", + props: { language: "js" }, + content: "const x = 1;", + }); + }); + + it("hr → divider (AC 39)", () => { + const blocks = convert("
"); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("divider"); + }); + + it("image with props (AC 40)", () => { + const blocks = convert('text'); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "image", + props: { src: "url", alt: "text", caption: "cap" }, + }); + }); + + it("heading levels 1-6", () => { + const blocks = convert( + "

H1

H2

H3

H4

H5
H6
", + ); + + expect(blocks).toHaveLength(6); + for (let i = 0; i < 6; i++) { + expect(blocks[i].type).toBe("heading"); + expect(blocks[i].props.level).toBe(i + 1); + } + }); + + it("div content is unwrapped (block container)", () => { + const blocks = convert("

inner

"); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "paragraph", + content: "inner", + }); + }); + + it("table with header (AC 40 extension)", () => { + const blocks = convert( + "
AB
12
", + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("table"); + expect(blocks[0].props.hasHeaderRow).toBe(true); + expect(blocks[0].children).toHaveLength(2); + }); + + it("round-trips exported database HTML back into a database block", async () => { + const source = databaseEditor(); + const html = await htmlExporter.export(source); + + const blocks = convert(html, defaultRegistry); + const databaseBlock = blocks.find((block) => block.type === "database"); + expect(databaseBlock).toMatchObject({ + type: "database", + props: { title: "Roadmap", dataSource: "local" }, + }); + expect(databaseBlock?.database).toEqual( + expect.objectContaining({ + primaryViewId: expect.any(String), + columns: [ + expect.objectContaining({ id: "name", title: "Name", type: "text" }), + expect.objectContaining({ id: "tags", title: "Tags", type: "multiSelect" }), + expect.objectContaining({ id: "done", title: "Done", type: "checkbox" }), + ], + rows: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + values: { + name: "Ship importer", + tags: JSON.stringify(["feature"]), + done: "false", + }, + }), + ]), + }), + ); + + const target = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + const ops = blocksToOps(blocks); + target.apply(ops, { origin: "import", undoGroup: true }); + const imported = Array.from(target.documentState.allBlocks()).find( + (block) => block.type === "database", + ); + expect(imported?.props.title).toBe("Roadmap"); + expect(imported?.tableColumns().map((column) => column.id)).toEqual(["name", "tags", "done"]); + expect(imported?.tableRow(0)?.id).toEqual(expect.any(String)); + expect(imported?.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["feature"])); + expect(imported?.databaseActiveView()).toEqual( + expect.objectContaining({ + title: "Main", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + }), + ); + + source.destroy(); + target.destroy(); + }); + + it("preserves intentionally empty database rows when round-tripping HTML", async () => { + const source = databaseEditor(); + source.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "empty-row", + }]); + const html = await htmlExporter.export(source); + + const blocks = convert(html, defaultRegistry); + const databaseBlock = blocks.find((block) => block.type === "database"); + expect(databaseBlock?.database?.rows).toEqual([ + expect.objectContaining({ + values: { + name: "Ship importer", + tags: JSON.stringify(["feature"]), + done: "false", + }, + }), + { + id: "empty-row", + values: { + name: "", + tags: "", + done: "", + }, + }, + ]); + + const target = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + target.apply(blocksToOps(blocks), { origin: "import", undoGroup: true }); + + const imported = Array.from(target.documentState.allBlocks()).find( + (block) => block.type === "database", + ); + expect(imported?.tableRowCount()).toBe(2); + expect(imported?.tableRow(1)?.id).toBe("empty-row"); + expect(imported?.tableCell(1, 0)?.textContent()).toBe(""); + expect(imported?.tableCell(1, 1)?.textContent()).toBe(""); + expect(imported?.tableCell(1, 2)?.textContent()).toBe(""); + + source.destroy(); + target.destroy(); + }); + + it("imports typed HTML tables as database blocks without Pen payload", () => { + const blocks = convert( + '
NameStatus
Ship ittodo
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "database", + props: { title: "Untitled", dataSource: "local" }, + database: { + columns: [ + expect.objectContaining({ id: "name", title: "Name", type: "text" }), + expect.objectContaining({ + id: "status", + title: "Status", + type: "select", + options: [{ id: "todo", value: "Todo" }], + }), + ], + rows: [ + expect.objectContaining({ + values: { name: "Ship it", status: "todo" }, + }), + ], + }, + }); + }); + + it("coerces select labels to option IDs during typed HTML import", () => { + const blocks = convert( + '
NameStatus
Task ATodo
Task Bdone
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + const rows = blocks[0].database!.rows; + expect(rows[0].values.status).toBe("todo"); + expect(rows[1].values.status).toBe("done"); + }); + + it("coerces multiSelect labels to option IDs during typed HTML import", () => { + const blocks = convert( + '
Tags
Bug, Feature
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + const rows = blocks[0].database!.rows; + expect(rows[0].values.tags).toBe(JSON.stringify(["bug", "feat"])); + }); + + it("preserves hidden and readonly false values during typed HTML import", () => { + const blocks = convert( + '
A
x
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + const col = blocks[0].database!.columns[0]; + expect(col.hidden).toBe(false); + expect(col.readonly).toBe(false); + }); + + it("blocksToOps generates correct ops (AC 41)", () => { + const blocks = convert("

Title

bold

"); + const ops = blocksToOps(blocks); + + const insertBlocks = ops.filter((o) => o.type === "insert-block"); + expect(insertBlocks).toHaveLength(2); + + const formatTexts = ops.filter((o) => o.type === "format-text"); + expect(formatTexts.length).toBeGreaterThan(0); + expect(formatTexts[0].marks).toHaveProperty("bold"); + }); + + it("inline-only at block level wraps in paragraph", () => { + const dom = parseHTML("bold at root"); + const blocks = domToBlocks(dom, stubRegistry); + + expect(blocks.some((b) => b.type === "paragraph" && b.content?.includes("bold at root"))).toBe(true); + }); + +}); diff --git a/packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts b/packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts new file mode 100644 index 0000000..41c924d --- /dev/null +++ b/packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect } from "vitest"; +import { blocksToOps, createEditor } from "@pen/core"; +import type { HTMLImportElement, SchemaRegistry } from "@pen/types"; +import { createDefaultSchema } from "@pen/schema-default"; +import { htmlExporter } from "@pen/export-html"; +import { htmlImporter, parseHtmlToBlocks } from "../importer"; +import { sanitizeHTML } from "../sanitize"; +import { parseHTML } from "../domAdapter"; +import { domToBlocks } from "../domToBlocks"; +import { parseInlineContent } from "../inlineParser"; +import type { DOMNode } from "../domAdapter"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const stubRegistry: SchemaRegistry = { + resolve: () => null, + resolveInline: () => null, + resolveApp: () => null, + resolveLayout: () => null, + allBlocks: () => [], + allInlines: () => [], + allApps: () => [], + allBlockDisplays: () => [], +}; + +const defaultRegistry = createDefaultSchema(); + +function convert(html: string, registry: SchemaRegistry = stubRegistry) { + const sanitized = sanitizeHTML(html); + const dom = parseHTML(sanitized); + return domToBlocks(dom, registry); +} + +function databaseEditor() { + const editor = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + editor.apply([{ + type: "insert-block", + blockId: "d1", + blockType: "database", + props: { title: "Roadmap", dataSource: "local" }, + position: "last", + }]); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "name", title: "Name", type: "text" }, + { + id: "tags", + title: "Tags", + type: "multiSelect", + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "feature", value: "Feature", color: "blue" }, + ], + }, + { id: "done", title: "Done", type: "checkbox" }, + ], + }]); + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "roadmap-1", + values: { + name: "Ship importer", + tags: JSON.stringify(["Feature"]), + done: "false", + }, + }]); + editor.apply([{ + type: "database-update-view", + blockId: "d1", + patch: { + title: "Main", + type: "table", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + }, + }]); + return editor; +} + +describe("@pen/import-html dom-to-blocks", () => { + it("server-side parsing produces identical blocks as browser-side for same input (AC 43)", () => { + const inputs = [ + "

Title

Body

", + "
  • a
  • b
", + '
const x = 1;
', + "
", + 'text', + "

bold and italic

", + ]; + + for (const html of inputs) { + const sanitized = sanitizeHTML(html); + const dom = parseHTML(sanitized); + const blocks = domToBlocks(dom, stubRegistry); + + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + expect(block.type).toBeTruthy(); + expect(block.props).toBeDefined(); + } + } + }); + + it("
→ toggle block via schema fromHTML", () => { + const blocks = convert( + "
Toggle title
", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + props: { open: false }, + content: "Toggle title", + }); + }); + + it("passes the public HTML import element to schema fromHTML hooks", () => { + let receivedElement: HTMLImportElement | null = null; + const registry: SchemaRegistry = { + ...stubRegistry, + allBlocks: () => [{ + type: "custom", + propSchema: {}, + content: "inline", + serialize: { + fromHTML(element: HTMLImportElement) { + receivedElement = element; + if (element.tagName !== "div") { + return null; + } + return { + type: "paragraph", + props: {}, + content: element.getAttribute("data-title") ?? "", + }; + }, + }, + }], + resolve: (type) => (type === "custom" ? registry.allBlocks()[0] : null), + }; + + const blocks = convert('
', registry); + + expect(receivedElement).toMatchObject({ + type: "element", + tagName: "div", + attributes: { "data-title": "From hook" }, + }); + if (!receivedElement) { + throw new Error("Expected schema fromHTML hook to receive an element"); + } + const hookElement = receivedElement as unknown as HTMLImportElement; + expect(hookElement.getAttribute("data-title")).toBe("From hook"); + expect(hookElement.hasAttribute("data-title")).toBe(true); + expect(blocks).toMatchObject([ + { + type: "paragraph", + content: "From hook", + }, + ]); + }); + + it("
→ toggle block with open=true", () => { + const blocks = convert( + '
Open toggle
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + props: { open: true }, + content: "Open toggle", + }); + }); + + it("preserves inline formatting inside an HTML toggle summary", () => { + const blocks = convert( + "
Bold and italic
", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + content: "Bold and italic", + }); + expect(blocks[0].marks?.some((mark) => mark.type === "bold")).toBe(true); + expect(blocks[0].marks?.some((mark) => mark.type === "italic")).toBe(true); + }); + + it("
→ callout block", () => { + const blocks = convert( + '
Be careful
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "warning" }, + content: "Be careful", + }); + }); + + it("
→ callout block", () => { + const blocks = convert( + '
Something failed
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "error" }, + content: "Something failed", + }); + }); + + it("
→ callout block", () => { + const blocks = convert( + '
FYI
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "info" }, + content: "FYI", + }); + }); + + it("
    preserves start value on first list item", () => { + const blocks = convert('
    1. fifth
    2. sixth
    '); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "numberedListItem", + content: "fifth", + props: { indent: 0, start: 5 }, + }); + expect(blocks[1]).toMatchObject({ + type: "numberedListItem", + content: "sixth", + props: { indent: 0 }, + }); + }); + + it("
      without start attribute does not set start", () => { + const blocks = convert("
      1. first
      "); + + expect(blocks).toHaveLength(1); + expect(blocks[0].props.start).toBeUndefined(); + }); + + it("keeps parseHtmlToBlocks parse-only in flow documents", () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const blocks = parseHtmlToBlocks(`${html}

      Allowed

      `, editor); + + expect(blocks.some((block) => block.type === "database")).toBe(true); + expect(blocks.some((block) => block.type === "heading")).toBe(true); + + source.destroy(); + editor.destroy(); + }); + + it("does not emit normalization diagnostics during parseHtmlToBlocks", () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + parseHtmlToBlocks(`${html}

      Allowed

      `, editor); + + expect(diagnostics).toEqual([]); + + source.destroy(); + editor.destroy(); + }); + + it("filters flow-disallowed blocks during direct HTML import into flow documents", async () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + await htmlImporter.import(`${html}

      Allowed

      `, editor); + + const blockOrder = editor.documentState.blockOrder; + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), + ).toBe(true); + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), + ).toBe(false); + + source.destroy(); + editor.destroy(); + }); + + it("returns a structured import result for HTML imports with normalization", async () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const result = await htmlImporter.import(`${html}

      Allowed

      `, editor); + + expect(result).toEqual({ + parsedTopLevelBlockCount: 3, + importedTopLevelBlockCount: 2, + droppedBlockCount: 1, + droppedBlockTypes: ["database"], + normalized: true, + }); + + source.destroy(); + editor.destroy(); + }); +}); diff --git a/packages/extensions/import-html/src/__tests__/importHtml.test.ts b/packages/extensions/import-html/src/__tests__/importHtml.test.ts index 6f761d9..3f65cab 100644 --- a/packages/extensions/import-html/src/__tests__/importHtml.test.ts +++ b/packages/extensions/import-html/src/__tests__/importHtml.test.ts @@ -139,7 +139,6 @@ describe("sanitizeHTML", () => { expect(result).not.toContain("z-index:"); }); }); - describe("parseInlineContent", () => { it("extracts text from text nodes", () => { const node: DOMNode = { type: "text", textContent: "hello" }; @@ -198,623 +197,3 @@ describe("parseInlineContent", () => { expect(result.marks.some((m) => m.type === "italic")).toBe(true); }); }); - -describe("@pen/import-html dom-to-blocks", () => { - it("heading + paragraph (AC 28)", () => { - const blocks = convert("

      Title

      Body

      "); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "heading", - props: { level: 1 }, - content: "Title", - }); - expect(blocks[1]).toMatchObject({ - type: "paragraph", - content: "Body", - }); - }); - - it("script tag is stripped (AC 29)", () => { - const blocks = convert('

      safe

      '); - - const types = blocks.map((b) => b.type); - expect(types).not.toContain("script"); - expect(blocks.some((b) => b.content === "safe")).toBe(true); - }); - - it("event handler stripped, text preserved (AC 30)", () => { - const blocks = convert('
      text
      '); - - expect(blocks.length).toBeGreaterThanOrEqual(1); - const hasText = blocks.some( - (b) => b.content?.includes("text"), - ); - expect(hasText).toBe(true); - }); - - it("bold mark from (AC 32)", () => { - const blocks = convert("

      bold

      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].content).toBe("bold"); - expect(blocks[0].marks?.some((m) => m.type === "bold")).toBe(true); - }); - - it("italic mark from (AC 33)", () => { - const blocks = convert("

      italic

      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].content).toBe("italic"); - expect(blocks[0].marks?.some((m) => m.type === "italic")).toBe(true); - }); - - it("link mark with href (AC 34)", () => { - const blocks = convert('

      text

      '); - - expect(blocks).toHaveLength(1); - expect(blocks[0].content).toBe("text"); - const linkMark = blocks[0].marks?.find((m) => m.type === "link"); - expect(linkMark).toBeDefined(); - expect(linkMark!.props!.href).toBe("https://example.com"); - }); - - it("bullet list items (AC 35)", () => { - const blocks = convert("
      • a
      • b
      "); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "bulletListItem", - content: "a", - }); - expect(blocks[1]).toMatchObject({ - type: "bulletListItem", - content: "b", - }); - }); - - it("numbered list items (AC 36)", () => { - const blocks = convert("
      1. a
      2. b
      "); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "numberedListItem", - content: "a", - }); - expect(blocks[1]).toMatchObject({ - type: "numberedListItem", - content: "b", - }); - }); - - it("nested list with indent (AC 37)", () => { - const blocks = convert( - "
      • a
        • b
      ", - ); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "bulletListItem", - content: "a", - props: { indent: 0 }, - }); - expect(blocks[1]).toMatchObject({ - type: "bulletListItem", - content: "b", - props: { indent: 1 }, - }); - }); - - it("code block with language (AC 38)", () => { - const blocks = convert( - '
      const x = 1;
      ', - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "codeBlock", - props: { language: "js" }, - content: "const x = 1;", - }); - }); - - it("hr → divider (AC 39)", () => { - const blocks = convert("
      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].type).toBe("divider"); - }); - - it("image with props (AC 40)", () => { - const blocks = convert('text'); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "image", - props: { src: "url", alt: "text", caption: "cap" }, - }); - }); - - it("heading levels 1-6", () => { - const blocks = convert( - "

      H1

      H2

      H3

      H4

      H5
      H6
      ", - ); - - expect(blocks).toHaveLength(6); - for (let i = 0; i < 6; i++) { - expect(blocks[i].type).toBe("heading"); - expect(blocks[i].props.level).toBe(i + 1); - } - }); - - it("div content is unwrapped (block container)", () => { - const blocks = convert("

      inner

      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "paragraph", - content: "inner", - }); - }); - - it("table with header (AC 40 extension)", () => { - const blocks = convert( - "
      AB
      12
      ", - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0].type).toBe("table"); - expect(blocks[0].props.hasHeaderRow).toBe(true); - expect(blocks[0].children).toHaveLength(2); - }); - - it("round-trips exported database HTML back into a database block", async () => { - const source = databaseEditor(); - const html = await htmlExporter.export(source); - - const blocks = convert(html, defaultRegistry); - const databaseBlock = blocks.find((block) => block.type === "database"); - expect(databaseBlock).toMatchObject({ - type: "database", - props: { title: "Roadmap", dataSource: "local" }, - }); - expect(databaseBlock?.database).toEqual( - expect.objectContaining({ - primaryViewId: expect.any(String), - columns: [ - expect.objectContaining({ id: "name", title: "Name", type: "text" }), - expect.objectContaining({ id: "tags", title: "Tags", type: "multiSelect" }), - expect.objectContaining({ id: "done", title: "Done", type: "checkbox" }), - ], - rows: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - values: { - name: "Ship importer", - tags: JSON.stringify(["feature"]), - done: "false", - }, - }), - ]), - }), - ); - - const target = createEditor({ - schema: defaultRegistry, - preset: noDefaultExtensionsPreset, - }); - const ops = blocksToOps(blocks); - target.apply(ops, { origin: "import", undoGroup: true }); - const imported = Array.from(target.documentState.allBlocks()).find( - (block) => block.type === "database", - ); - expect(imported?.props.title).toBe("Roadmap"); - expect(imported?.tableColumns().map((column) => column.id)).toEqual(["name", "tags", "done"]); - expect(imported?.tableRow(0)?.id).toEqual(expect.any(String)); - expect(imported?.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["feature"])); - expect(imported?.databaseActiveView()).toEqual( - expect.objectContaining({ - title: "Main", - visibleColumnIds: ["name", "tags"], - columnOrder: ["name", "tags", "done"], - }), - ); - - source.destroy(); - target.destroy(); - }); - - it("preserves intentionally empty database rows when round-tripping HTML", async () => { - const source = databaseEditor(); - source.apply([{ - type: "database-insert-row", - blockId: "d1", - rowId: "empty-row", - }]); - const html = await htmlExporter.export(source); - - const blocks = convert(html, defaultRegistry); - const databaseBlock = blocks.find((block) => block.type === "database"); - expect(databaseBlock?.database?.rows).toEqual([ - expect.objectContaining({ - values: { - name: "Ship importer", - tags: JSON.stringify(["feature"]), - done: "false", - }, - }), - { - id: "empty-row", - values: { - name: "", - tags: "", - done: "", - }, - }, - ]); - - const target = createEditor({ - schema: defaultRegistry, - preset: noDefaultExtensionsPreset, - }); - target.apply(blocksToOps(blocks), { origin: "import", undoGroup: true }); - - const imported = Array.from(target.documentState.allBlocks()).find( - (block) => block.type === "database", - ); - expect(imported?.tableRowCount()).toBe(2); - expect(imported?.tableRow(1)?.id).toBe("empty-row"); - expect(imported?.tableCell(1, 0)?.textContent()).toBe(""); - expect(imported?.tableCell(1, 1)?.textContent()).toBe(""); - expect(imported?.tableCell(1, 2)?.textContent()).toBe(""); - - source.destroy(); - target.destroy(); - }); - - it("imports typed HTML tables as database blocks without Pen payload", () => { - const blocks = convert( - '
      NameStatus
      Ship ittodo
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "database", - props: { title: "Untitled", dataSource: "local" }, - database: { - columns: [ - expect.objectContaining({ id: "name", title: "Name", type: "text" }), - expect.objectContaining({ - id: "status", - title: "Status", - type: "select", - options: [{ id: "todo", value: "Todo" }], - }), - ], - rows: [ - expect.objectContaining({ - values: { name: "Ship it", status: "todo" }, - }), - ], - }, - }); - }); - - it("coerces select labels to option IDs during typed HTML import", () => { - const blocks = convert( - '
      NameStatus
      Task ATodo
      Task Bdone
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - const rows = blocks[0].database!.rows; - expect(rows[0].values.status).toBe("todo"); - expect(rows[1].values.status).toBe("done"); - }); - - it("coerces multiSelect labels to option IDs during typed HTML import", () => { - const blocks = convert( - '
      Tags
      Bug, Feature
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - const rows = blocks[0].database!.rows; - expect(rows[0].values.tags).toBe(JSON.stringify(["bug", "feat"])); - }); - - it("preserves hidden and readonly false values during typed HTML import", () => { - const blocks = convert( - '
      A
      x
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - const col = blocks[0].database!.columns[0]; - expect(col.hidden).toBe(false); - expect(col.readonly).toBe(false); - }); - - it("blocksToOps generates correct ops (AC 41)", () => { - const blocks = convert("

      Title

      bold

      "); - const ops = blocksToOps(blocks); - - const insertBlocks = ops.filter((o) => o.type === "insert-block"); - expect(insertBlocks).toHaveLength(2); - - const formatTexts = ops.filter((o) => o.type === "format-text"); - expect(formatTexts.length).toBeGreaterThan(0); - expect(formatTexts[0].marks).toHaveProperty("bold"); - }); - - it("inline-only at block level wraps in paragraph", () => { - const dom = parseHTML("bold at root"); - const blocks = domToBlocks(dom, stubRegistry); - - expect(blocks.some((b) => b.type === "paragraph" && b.content?.includes("bold at root"))).toBe(true); - }); - - it("server-side parsing produces identical blocks as browser-side for same input (AC 43)", () => { - const inputs = [ - "

      Title

      Body

      ", - "
      • a
      • b
      ", - '
      const x = 1;
      ', - "
      ", - 'text', - "

      bold and italic

      ", - ]; - - for (const html of inputs) { - const sanitized = sanitizeHTML(html); - const dom = parseHTML(sanitized); - const blocks = domToBlocks(dom, stubRegistry); - - expect(blocks.length).toBeGreaterThan(0); - for (const block of blocks) { - expect(block.type).toBeTruthy(); - expect(block.props).toBeDefined(); - } - } - }); - - it("
      → toggle block via schema fromHTML", () => { - const blocks = convert( - "
      Toggle title
      ", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - props: { open: false }, - content: "Toggle title", - }); - }); - - it("passes the public HTML import element to schema fromHTML hooks", () => { - let receivedElement: HTMLImportElement | null = null; - const registry: SchemaRegistry = { - ...stubRegistry, - allBlocks: () => [{ - type: "custom", - propSchema: {}, - content: "inline", - serialize: { - fromHTML(element: HTMLImportElement) { - receivedElement = element; - if (element.tagName !== "div") { - return null; - } - return { - type: "paragraph", - props: {}, - content: element.getAttribute("data-title") ?? "", - }; - }, - }, - }], - resolve: (type) => (type === "custom" ? registry.allBlocks()[0] : null), - }; - - const blocks = convert('
      ', registry); - - expect(receivedElement).toMatchObject({ - type: "element", - tagName: "div", - attributes: { "data-title": "From hook" }, - }); - if (!receivedElement) { - throw new Error("Expected schema fromHTML hook to receive an element"); - } - const hookElement = receivedElement as unknown as HTMLImportElement; - expect(hookElement.getAttribute("data-title")).toBe("From hook"); - expect(hookElement.hasAttribute("data-title")).toBe(true); - expect(blocks).toMatchObject([ - { - type: "paragraph", - content: "From hook", - }, - ]); - }); - - it("
      → toggle block with open=true", () => { - const blocks = convert( - '
      Open toggle
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - props: { open: true }, - content: "Open toggle", - }); - }); - - it("preserves inline formatting inside an HTML toggle summary", () => { - const blocks = convert( - "
      Bold and italic
      ", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - content: "Bold and italic", - }); - expect(blocks[0].marks?.some((mark) => mark.type === "bold")).toBe(true); - expect(blocks[0].marks?.some((mark) => mark.type === "italic")).toBe(true); - }); - - it("
      → callout block", () => { - const blocks = convert( - '
      Be careful
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "warning" }, - content: "Be careful", - }); - }); - - it("
      → callout block", () => { - const blocks = convert( - '
      Something failed
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "error" }, - content: "Something failed", - }); - }); - - it("
      → callout block", () => { - const blocks = convert( - '
      FYI
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "info" }, - content: "FYI", - }); - }); - - it("
        preserves start value on first list item", () => { - const blocks = convert('
        1. fifth
        2. sixth
        '); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "numberedListItem", - content: "fifth", - props: { indent: 0, start: 5 }, - }); - expect(blocks[1]).toMatchObject({ - type: "numberedListItem", - content: "sixth", - props: { indent: 0 }, - }); - }); - - it("
          without start attribute does not set start", () => { - const blocks = convert("
          1. first
          "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].props.start).toBeUndefined(); - }); - - it("keeps parseHtmlToBlocks parse-only in flow documents", () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const blocks = parseHtmlToBlocks(`${html}

          Allowed

          `, editor); - - expect(blocks.some((block) => block.type === "database")).toBe(true); - expect(blocks.some((block) => block.type === "heading")).toBe(true); - - source.destroy(); - editor.destroy(); - }); - - it("does not emit normalization diagnostics during parseHtmlToBlocks", () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - parseHtmlToBlocks(`${html}

          Allowed

          `, editor); - - expect(diagnostics).toEqual([]); - - source.destroy(); - editor.destroy(); - }); - - it("filters flow-disallowed blocks during direct HTML import into flow documents", async () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - await htmlImporter.import(`${html}

          Allowed

          `, editor); - - const blockOrder = editor.documentState.blockOrder; - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), - ).toBe(true); - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), - ).toBe(false); - - source.destroy(); - editor.destroy(); - }); - - it("returns a structured import result for HTML imports with normalization", async () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const result = await htmlImporter.import(`${html}

          Allowed

          `, editor); - - expect(result).toEqual({ - parsedTopLevelBlockCount: 3, - importedTopLevelBlockCount: 2, - droppedBlockCount: 1, - droppedBlockTypes: ["database"], - normalized: true, - }); - - source.destroy(); - editor.destroy(); - }); -}); diff --git a/packages/extensions/import-html/src/domToBlocks.ts b/packages/extensions/import-html/src/domToBlocks.ts index 1f7f854..6d81958 100644 --- a/packages/extensions/import-html/src/domToBlocks.ts +++ b/packages/extensions/import-html/src/domToBlocks.ts @@ -2,14 +2,16 @@ import type { DOMNode } from "./domAdapter"; import { parseInlineContent } from "./inlineParser"; import type { BlockImportMatch, - DatabaseViewState, HTMLImportElement, HTMLImportNode, SchemaRegistry, - TableColumnSchema, } from "@pen/types"; import type { PendingBlock } from "@pen/core"; -import { normalizeStoredSelectValue } from "@pen/types"; +import { + collectTableRows, + parseDatabasePayload, + parseTypedDatabaseTable, +} from "./domToDatabaseBlocks"; const BLOCK_ELEMENT_MAP: Record PendingBlock> = { h1: (node) => blockWithInline("heading", { level: 1 }, node), @@ -261,215 +263,6 @@ function parseHTMLTable(node: DOMNode): PendingBlock { }; } -function parseTypedDatabaseTable(node: DOMNode): PendingBlock | null { - const headerCells = collectDatabaseHeaderCells(node); - if (headerCells.length === 0) { - return null; - } - - const columns = headerCells.map((cell, index) => { - const columnId = cell.attributes?.["data-col-id"]?.trim() || `col-${index}`; - const columnType = cell.attributes?.["data-col-type"]?.trim() || "text"; - const inline = parseInlineContent(cell); - const options = parseEncodedJSON(cell.attributes?.["data-col-options"]); - const format = parseEncodedJSON(cell.attributes?.["data-col-format"]); - const width = cell.attributes?.["data-col-width"]; - const pinned = cell.attributes?.["data-col-pinned"]; - const column: TableColumnSchema = { - id: columnId, - title: inline.text || `Column ${index + 1}`, - type: columnType as TableColumnSchema["type"], - }; - - if (Array.isArray(options)) { - column.options = options as TableColumnSchema["options"]; - } - if (format && typeof format === "object") { - column.format = format as TableColumnSchema["format"]; - } - if (width != null && width !== "" && Number.isFinite(Number(width))) { - column.width = Number(width); - } - if (pinned === "left" || pinned === "right") { - column.pinned = pinned; - } - if (cell.attributes?.["data-col-hidden"] !== undefined) { - column.hidden = cell.attributes["data-col-hidden"] === "true"; - } - if (cell.attributes?.["data-col-readonly"] !== undefined) { - column.readonly = cell.attributes["data-col-readonly"] === "true"; - } - - return column; - }); - - const bodyRows = collectDatabaseBodyRows(node); - const rows = bodyRows.map((row, rowIndex) => { - const cellNodes = (row.children ?? []).filter((child) => child.tagName === "td" || child.tagName === "th"); - const values = Object.fromEntries( - columns.map((column, columnIndex) => { - const raw = parseInlineContent(cellNodes[columnIndex] ?? { type: "element", tagName: "span", children: [] }).text; - return [column.id, coerceImportedCellValue(raw, column)]; - }), - ); - - return { - id: `row-${rowIndex}`, - values, - }; - }); - - return { - type: "database", - props: { - title: "Untitled", - dataSource: "local", - }, - database: { - columns, - rows, - views: undefined, - primaryViewId: null, - }, - }; -} - -function coerceImportedCellValue(raw: string, column: TableColumnSchema): string { - if (!raw || !column.options?.length) { - return raw; - } - if (column.type === "select") { - return normalizeStoredSelectValue(raw, column.options); - } - if (column.type === "multiSelect") { - let parsed: string[]; - try { - const json = JSON.parse(raw); - parsed = Array.isArray(json) ? json.map(String) : [raw]; - } catch { - parsed = raw.split(",").map((s) => s.trim()).filter(Boolean); - } - const normalized = parsed.map((v) => normalizeStoredSelectValue(v, column.options)); - return normalized.length > 0 ? JSON.stringify(normalized) : raw; - } - return raw; -} - -function parseDatabasePayload( - rawValue: string | undefined, -): { - title?: string; - dataSource?: string; - columns: TableColumnSchema[]; - rows: Array<{ id: string; values: Record }>; - views?: DatabaseViewState[]; - primaryViewId?: string | null; -} | null { - if (!rawValue) { - return null; - } - try { - const parsed = JSON.parse(decodeURIComponent(rawValue)) as { - title?: string; - dataSource?: string; - columns?: TableColumnSchema[]; - rows?: Array<{ id?: string; values?: Record }>; - views?: DatabaseViewState[]; - primaryViewId?: string | null; - }; - if (!Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) { - return null; - } - return { - title: parsed.title, - dataSource: parsed.dataSource, - columns: parsed.columns, - rows: parsed.rows.map((row, index) => ({ - id: - typeof row?.id === "string" && row.id.length > 0 - ? row.id - : `row-${index}`, - values: Object.fromEntries( - Object.entries(row?.values ?? {}).map(([key, value]) => [ - key, - value == null ? "" : String(value), - ]), - ), - })), - views: Array.isArray(parsed.views) ? parsed.views : undefined, - primaryViewId: - typeof parsed.primaryViewId === "string" || parsed.primaryViewId === null - ? parsed.primaryViewId - : undefined, - }; - } catch { - return null; - } -} - -function collectTableRows(tableNode: DOMNode): DOMNode[] { - const rows: DOMNode[] = []; - for (const child of tableNode.children ?? []) { - if (child.tagName === "tr") { - rows.push(child); - } else if ( - child.tagName === "thead" || - child.tagName === "tbody" || - child.tagName === "tfoot" - ) { - for (const row of child.children ?? []) { - if (row.tagName === "tr") rows.push(row); - } - } - } - return rows; -} - -function collectDatabaseHeaderCells(tableNode: DOMNode): DOMNode[] { - const headerRow = - tableNode.children?.find((child) => child.tagName === "thead")?.children?.find( - (child) => child.tagName === "tr", - ) ?? - collectTableRows(tableNode).find((row) => - (row.children ?? []).some((child) => child.tagName === "th" && child.attributes?.["data-col-type"] != null), - ) ?? - null; - - if (!headerRow) { - return []; - } - - const headerCells = (headerRow.children ?? []).filter((child) => child.tagName === "th"); - const isTyped = headerCells.some( - (cell) => - cell.attributes?.["data-col-type"] != null || - cell.attributes?.["data-col-id"] != null, - ); - return isTyped ? headerCells : []; -} - -function collectDatabaseBodyRows(tableNode: DOMNode): DOMNode[] { - const tbody = tableNode.children?.find((child) => child.tagName === "tbody"); - if (tbody) { - return (tbody.children ?? []).filter((child) => child.tagName === "tr"); - } - - const allRows = collectTableRows(tableNode); - return allRows.slice(1); -} - -function parseEncodedJSON(rawValue: string | undefined): unknown { - if (!rawValue) { - return undefined; - } - - try { - return JSON.parse(decodeURIComponent(rawValue)); - } catch { - return undefined; - } -} - function blockWithInline( type: string, props: Record, diff --git a/packages/extensions/import-html/src/domToDatabaseBlocks.ts b/packages/extensions/import-html/src/domToDatabaseBlocks.ts new file mode 100644 index 0000000..2a91196 --- /dev/null +++ b/packages/extensions/import-html/src/domToDatabaseBlocks.ts @@ -0,0 +1,219 @@ +import type { DOMNode } from "./domAdapter"; +import { parseInlineContent } from "./inlineParser"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { PendingBlock } from "@pen/core"; +import { normalizeStoredSelectValue } from "@pen/types"; + +export function parseTypedDatabaseTable(node: DOMNode): PendingBlock | null { + const headerCells = collectDatabaseHeaderCells(node); + if (headerCells.length === 0) { + return null; + } + + const columns = headerCells.map((cell, index) => { + const columnId = cell.attributes?.["data-col-id"]?.trim() || `col-${index}`; + const columnType = cell.attributes?.["data-col-type"]?.trim() || "text"; + const inline = parseInlineContent(cell); + const options = parseEncodedJSON(cell.attributes?.["data-col-options"]); + const format = parseEncodedJSON(cell.attributes?.["data-col-format"]); + const width = cell.attributes?.["data-col-width"]; + const pinned = cell.attributes?.["data-col-pinned"]; + const column: TableColumnSchema = { + id: columnId, + title: inline.text || `Column ${index + 1}`, + type: columnType as TableColumnSchema["type"], + }; + + if (Array.isArray(options)) { + column.options = options as TableColumnSchema["options"]; + } + if (format && typeof format === "object") { + column.format = format as TableColumnSchema["format"]; + } + if (width != null && width !== "" && Number.isFinite(Number(width))) { + column.width = Number(width); + } + if (pinned === "left" || pinned === "right") { + column.pinned = pinned; + } + if (cell.attributes?.["data-col-hidden"] !== undefined) { + column.hidden = cell.attributes["data-col-hidden"] === "true"; + } + if (cell.attributes?.["data-col-readonly"] !== undefined) { + column.readonly = cell.attributes["data-col-readonly"] === "true"; + } + + return column; + }); + + const bodyRows = collectDatabaseBodyRows(node); + const rows = bodyRows.map((row, rowIndex) => { + const cellNodes = (row.children ?? []).filter((child) => child.tagName === "td" || child.tagName === "th"); + const values = Object.fromEntries( + columns.map((column, columnIndex) => { + const raw = parseInlineContent(cellNodes[columnIndex] ?? { type: "element", tagName: "span", children: [] }).text; + return [column.id, coerceImportedCellValue(raw, column)]; + }), + ); + + return { + id: `row-${rowIndex}`, + values, + }; + }); + + return { + type: "database", + props: { + title: "Untitled", + dataSource: "local", + }, + database: { + columns, + rows, + views: undefined, + primaryViewId: null, + }, + }; +} + +function coerceImportedCellValue(raw: string, column: TableColumnSchema): string { + if (!raw || !column.options?.length) { + return raw; + } + if (column.type === "select") { + return normalizeStoredSelectValue(raw, column.options); + } + if (column.type === "multiSelect") { + let parsed: string[]; + try { + const json = JSON.parse(raw); + parsed = Array.isArray(json) ? json.map(String) : [raw]; + } catch { + parsed = raw.split(",").map((s) => s.trim()).filter(Boolean); + } + const normalized = parsed.map((v) => normalizeStoredSelectValue(v, column.options)); + return normalized.length > 0 ? JSON.stringify(normalized) : raw; + } + return raw; +} + +export function parseDatabasePayload( + rawValue: string | undefined, +): { + title?: string; + dataSource?: string; + columns: TableColumnSchema[]; + rows: Array<{ id: string; values: Record }>; + views?: DatabaseViewState[]; + primaryViewId?: string | null; +} | null { + if (!rawValue) { + return null; + } + try { + const parsed = JSON.parse(decodeURIComponent(rawValue)) as { + title?: string; + dataSource?: string; + columns?: TableColumnSchema[]; + rows?: Array<{ id?: string; values?: Record }>; + views?: DatabaseViewState[]; + primaryViewId?: string | null; + }; + if (!Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) { + return null; + } + return { + title: parsed.title, + dataSource: parsed.dataSource, + columns: parsed.columns, + rows: parsed.rows.map((row, index) => ({ + id: + typeof row?.id === "string" && row.id.length > 0 + ? row.id + : `row-${index}`, + values: Object.fromEntries( + Object.entries(row?.values ?? {}).map(([key, value]) => [ + key, + value == null ? "" : String(value), + ]), + ), + })), + views: Array.isArray(parsed.views) ? parsed.views : undefined, + primaryViewId: + typeof parsed.primaryViewId === "string" || parsed.primaryViewId === null + ? parsed.primaryViewId + : undefined, + }; + } catch { + return null; + } +} + +export function collectTableRows(tableNode: DOMNode): DOMNode[] { + const rows: DOMNode[] = []; + for (const child of tableNode.children ?? []) { + if (child.tagName === "tr") { + rows.push(child); + } else if ( + child.tagName === "thead" || + child.tagName === "tbody" || + child.tagName === "tfoot" + ) { + for (const row of child.children ?? []) { + if (row.tagName === "tr") rows.push(row); + } + } + } + return rows; +} + +function collectDatabaseHeaderCells(tableNode: DOMNode): DOMNode[] { + const headerRow = + tableNode.children?.find((child) => child.tagName === "thead")?.children?.find( + (child) => child.tagName === "tr", + ) ?? + collectTableRows(tableNode).find((row) => + (row.children ?? []).some((child) => child.tagName === "th" && child.attributes?.["data-col-type"] != null), + ) ?? + null; + + if (!headerRow) { + return []; + } + + const headerCells = (headerRow.children ?? []).filter((child) => child.tagName === "th"); + const isTyped = headerCells.some( + (cell) => + cell.attributes?.["data-col-type"] != null || + cell.attributes?.["data-col-id"] != null, + ); + return isTyped ? headerCells : []; +} + +function collectDatabaseBodyRows(tableNode: DOMNode): DOMNode[] { + const tbody = tableNode.children?.find((child) => child.tagName === "tbody"); + if (tbody) { + return (tbody.children ?? []).filter((child) => child.tagName === "tr"); + } + + const allRows = collectTableRows(tableNode); + return allRows.slice(1); +} + +function parseEncodedJSON(rawValue: string | undefined): unknown { + if (!rawValue) { + return undefined; + } + + try { + return JSON.parse(decodeURIComponent(rawValue)); + } catch { + return undefined; + } +} + +function extractText(node: DOMNode): string { + if (node.type === "text") return node.textContent ?? ""; + return (node.children ?? []).map(extractText).join(""); +} diff --git a/packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts b/packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts new file mode 100644 index 0000000..4617b43 --- /dev/null +++ b/packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import { blocksToOps, createEditor } from "@pen/core"; +import type { SchemaRegistry } from "@pen/types"; +import { markdownExporter } from "@pen/export-markdown"; +import { createDefaultSchema } from "@pen/schema-default"; +import { markdownImporter, parseMarkdownToBlocks } from "../importer"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const stubRegistry: SchemaRegistry = { + resolve: () => null, + resolveInline: () => null, + resolveApp: () => null, + resolveLayout: () => null, + allBlocks: () => [], + allInlines: () => [], + allApps: () => [], + allBlockDisplays: () => [], +}; + +const defaultRegistry = createDefaultSchema(); + +function convert(md: string, registry: SchemaRegistry = stubRegistry) { + return parseMarkdownToBlocks(md, { + schema: registry, + } as never); +} + +function databaseEditor() { + const editor = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + editor.apply([{ + type: "insert-block", + blockId: "d1", + blockType: "database", + props: { title: "Roadmap", dataSource: "local" }, + position: "last", + }]); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "name", title: "Name", type: "text" }, + { + id: "tags", + title: "Tags", + type: "multiSelect", + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "feature", value: "Feature", color: "blue" }, + ], + }, + { id: "done", title: "Done", type: "checkbox" }, + ], + }]); + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "roadmap-1", + values: { + name: "Ship importer", + tags: JSON.stringify(["Feature"]), + done: "false", + }, + }]); + editor.apply([{ + type: "database-update-view", + blockId: "d1", + patch: { + title: "Main", + type: "table", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + }, + }]); + return editor; +} + +describe("@pen/import-markdown", () => { + it("preserves inline formatting after a markdown callout prefix", () => { + const blocks = convert( + "> **Note:** This is *very* [important](https://example.com)", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "info" }, + content: "This is very important", + }); + + const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); + expect(italicMark).toMatchObject({ start: 8, end: 12 }); + + const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); + expect(linkMark).toMatchObject({ + start: 13, + end: 22, + props: { href: "https://example.com" }, + }); + }); + + it("preserves inline formatting inside a toggle summary HTML block", () => { + const blocks = convert( + "
          Very important
          ", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + props: { open: false }, + content: "Very important", + }); + + const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); + expect(italicMark).toMatchObject({ start: 0, end: 4 }); + + const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); + expect(linkMark).toMatchObject({ + start: 5, + end: 14, + props: { href: "https://example.com" }, + }); + }); + + it("plain blockquote stays blockquote (not callout)", () => { + const blocks = convert("> Just a regular quote", defaultRegistry); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("blockquote"); + }); + + it("keeps parseMarkdownToBlocks parse-only in flow documents", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const blocks = parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); + + expect(blocks.map((block) => block.type)).toEqual(["database", "heading"]); + + source.destroy(); + editor.destroy(); + }); + + it("does not emit normalization diagnostics during parseMarkdownToBlocks", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); + + expect(diagnostics).toEqual([]); + + source.destroy(); + editor.destroy(); + }); + + it("filters flow-disallowed blocks during direct markdown import into flow documents", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + markdownImporter.import(`${markdown}\n\n## Allowed`, editor); + + const blockOrder = editor.documentState.blockOrder; + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), + ).toBe(true); + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), + ).toBe(false); + + source.destroy(); + editor.destroy(); + }); + + it("returns a structured import result for markdown imports with normalization", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const result = markdownImporter.import(`${markdown}\n\n## Allowed`, editor); + + expect(result).toEqual({ + parsedTopLevelBlockCount: 2, + importedTopLevelBlockCount: 1, + droppedBlockCount: 1, + droppedBlockTypes: ["database"], + normalized: true, + }); + + source.destroy(); + editor.destroy(); + }); +}); diff --git a/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts b/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts index 967de06..7457ded 100644 --- a/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts +++ b/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts @@ -426,143 +426,4 @@ describe("@pen/import-markdown", () => { }); }); - it("preserves inline formatting after a markdown callout prefix", () => { - const blocks = convert( - "> **Note:** This is *very* [important](https://example.com)", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "info" }, - content: "This is very important", - }); - - const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); - expect(italicMark).toMatchObject({ start: 8, end: 12 }); - - const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); - expect(linkMark).toMatchObject({ - start: 13, - end: 22, - props: { href: "https://example.com" }, - }); - }); - - it("preserves inline formatting inside a toggle summary HTML block", () => { - const blocks = convert( - "
          Very important
          ", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - props: { open: false }, - content: "Very important", - }); - - const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); - expect(italicMark).toMatchObject({ start: 0, end: 4 }); - - const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); - expect(linkMark).toMatchObject({ - start: 5, - end: 14, - props: { href: "https://example.com" }, - }); - }); - - it("plain blockquote stays blockquote (not callout)", () => { - const blocks = convert("> Just a regular quote", defaultRegistry); - - expect(blocks).toHaveLength(1); - expect(blocks[0].type).toBe("blockquote"); - }); - - it("keeps parseMarkdownToBlocks parse-only in flow documents", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const blocks = parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); - - expect(blocks.map((block) => block.type)).toEqual(["database", "heading"]); - - source.destroy(); - editor.destroy(); - }); - - it("does not emit normalization diagnostics during parseMarkdownToBlocks", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); - - expect(diagnostics).toEqual([]); - - source.destroy(); - editor.destroy(); - }); - - it("filters flow-disallowed blocks during direct markdown import into flow documents", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - markdownImporter.import(`${markdown}\n\n## Allowed`, editor); - - const blockOrder = editor.documentState.blockOrder; - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), - ).toBe(true); - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), - ).toBe(false); - - source.destroy(); - editor.destroy(); - }); - - it("returns a structured import result for markdown imports with normalization", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const result = markdownImporter.import(`${markdown}\n\n## Allowed`, editor); - - expect(result).toEqual({ - parsedTopLevelBlockCount: 2, - importedTopLevelBlockCount: 1, - droppedBlockCount: 1, - droppedBlockTypes: ["database"], - normalized: true, - }); - - source.destroy(); - editor.destroy(); - }); }); diff --git a/packages/extensions/undo/src/undoExtension.ts b/packages/extensions/undo/src/undoExtension.ts index 8397a5f..c7b119d 100644 --- a/packages/extensions/undo/src/undoExtension.ts +++ b/packages/extensions/undo/src/undoExtension.ts @@ -13,6 +13,7 @@ import type { import { defineExtension, FIELD_EDITOR_SLOT_KEY, + getOpOriginType, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, UNDO_HISTORY_RESTORE_SLOT_KEY, } from "@pen/types"; @@ -102,7 +103,7 @@ export function undoExtension(options?: UndoExtensionOptions): Extension { const { adapter, crdtDoc } = ctx.editor.internals; const crdtUndo = adapter.createUndoManager(crdtDoc, { - trackedOrigins: [...trackedOrigins], + trackedOriginTypes: [...trackedOrigins].map(getOpOriginType), captureTimeout: options?.groupTimeout ?? 400, }); diff --git a/packages/extensions/undo/src/undoManager.ts b/packages/extensions/undo/src/undoManager.ts index 6d49d00..9b817c0 100644 --- a/packages/extensions/undo/src/undoManager.ts +++ b/packages/extensions/undo/src/undoManager.ts @@ -4,12 +4,13 @@ import type { OpOrigin, Unsubscribe, } from "@pen/types"; +import { getOpOriginType } from "@pen/types"; const EXPLICIT_GROUP_CAPTURE_TIMEOUT_MS = 2_147_483_647; export class UndoManagerImpl implements UndoManager { private readonly _crdtUndo: CRDTUndoManager; - private readonly _trackedOrigins = new Map(); + private readonly _trackedOriginTypes = new Map(); private readonly _listeners = new Set<() => void>(); private _idleTimer: ReturnType | null = null; private _groupTimeout = 1000; @@ -21,7 +22,7 @@ export class UndoManagerImpl implements UndoManager { constructor(crdtUndo: CRDTUndoManager, trackedOrigins?: Iterable) { this._crdtUndo = crdtUndo; for (const origin of trackedOrigins ?? []) { - this._trackedOrigins.set(origin, 1); + this._trackedOriginTypes.set(getOpOriginType(origin), 1); } } @@ -119,7 +120,7 @@ export class UndoManagerImpl implements UndoManager { } hasTrackedOrigin(origin: OpOrigin): boolean { - return (this._trackedOrigins.get(origin) ?? 0) > 0; + return (this._trackedOriginTypes.get(getOpOriginType(origin)) ?? 0) > 0; } onStackChange(callback: () => void): Unsubscribe { @@ -158,21 +159,23 @@ export class UndoManagerImpl implements UndoManager { } private _incrementTrackedOrigin(origin: OpOrigin): void { - const count = this._trackedOrigins.get(origin) ?? 0; + const originType = getOpOriginType(origin); + const count = this._trackedOriginTypes.get(originType) ?? 0; if (count === 0) { - this._crdtUndo.addTrackedOrigin(origin); + this._crdtUndo.addTrackedOrigin(originType); } - this._trackedOrigins.set(origin, count + 1); + this._trackedOriginTypes.set(originType, count + 1); } private _decrementTrackedOrigin(origin: OpOrigin): void { - const count = this._trackedOrigins.get(origin) ?? 0; + const originType = getOpOriginType(origin); + const count = this._trackedOriginTypes.get(originType) ?? 0; if (count <= 1) { - this._trackedOrigins.delete(origin); - this._crdtUndo.removeTrackedOrigin(origin); + this._trackedOriginTypes.delete(originType); + this._crdtUndo.removeTrackedOrigin(originType); return; } - this._trackedOrigins.set(origin, count - 1); + this._trackedOriginTypes.set(originType, count - 1); } private _clearIdleTimer(): void { diff --git a/packages/rendering/dom/src/field-editor/commands.ts b/packages/rendering/dom/src/field-editor/commands.ts index 2a60d24..4e2598f 100644 --- a/packages/rendering/dom/src/field-editor/commands.ts +++ b/packages/rendering/dom/src/field-editor/commands.ts @@ -1,847 +1,33 @@ -import { INPUT_RULES_ENGINE_SLOT_KEY, generateId } from "@pen/types"; -import type { DocumentOp, Editor } from "@pen/types"; -import { - toggleInlineMark as toggleInlineMarkCommand, - setInlineMark as setInlineMarkCommand, -} from "@pen/shortcuts"; -import { matchListInputRule } from "../utils/listInputRule"; -import { - getAdjacentVisibleBlockId, - isInsideParentIdContainer, -} from "../utils/parentIdTree"; -import { - getEditorFlowCapability, - isContinuousTextFlowCapability, -} from "../utils/flowCapabilities"; - -const ZERO_WIDTH_SPACE = "\u200B"; - -export interface SelectionRange { - start: number; - end: number; -} - -export interface SelectionTarget { - blockId: string; - anchorOffset: number; - focusOffset: number; - selectBlock?: boolean; -} - -type InlineTextLike = { - length: number; - toString(): string; - toDelta?(): Array<{ insert?: string | Record }>; -}; - -type BlockInputRuleEngine = { - tryMatch( - editor: Editor, - blockId: string, - insertedText: string, - options?: { offset?: number }, - ): DocumentOp[] | null; -}; - -// ── Enter action resolution ────────────────────────────────── - -type EnterAction = - | { action: "split"; newBlockType: string | undefined } - | { action: "convert"; newType: string } - | { action: "lift" } - | { action: "insert-text"; text: string }; - -type BackspaceAction = - | { action: "convert"; newType: string } - | { action: "delete"; targetBlockId: string } - | { action: "select-block"; targetBlockId: string } - | { action: "merge"; targetBlockId: string }; - -type DeleteDirection = "backward" | "forward"; - -const LIST_BLOCK_TYPES = new Set([ - "bulletListItem", - "numberedListItem", - "checkListItem", -]); - -const HEADING_TYPES = new Set(["heading"]); - -const CONTAINER_EXIT_TYPES = new Set(["blockquote", "callout"]); -const BACKSPACE_EXIT_TYPES = new Set([ - ...LIST_BLOCK_TYPES, - ...CONTAINER_EXIT_TYPES, - ...HEADING_TYPES, -]); - -function isBlockEmpty(ytext: InlineTextLike): boolean { - return getLogicalInlineLength(ytext) === 0; -} - -function getAdjacentEditableBlock( - editor: Editor, - blockId: string, - direction: "previous" | "next", -): ReturnType { - let adjacentBlockId = getAdjacentVisibleBlockId(editor, blockId, direction); - while (adjacentBlockId) { - const adjacentBlock = editor.getBlock(adjacentBlockId); - if ( - adjacentBlock && - isContinuousTextFlowCapability( - getEditorFlowCapability(editor, adjacentBlock.id), - ) - ) { - return adjacentBlock; - } - adjacentBlockId = getAdjacentVisibleBlockId( - editor, - adjacentBlockId, - direction, - ); - } - return null; -} - -export function getLogicalInlineLength(ytext: InlineTextLike): number { - const delta = ytext.toDelta?.(); - if (delta) { - return delta.reduce((length, entry) => { - if (typeof entry.insert === "string") { - return ( - length + - (entry.insert === ZERO_WIDTH_SPACE - ? 0 - : entry.insert.length) - ); - } - return entry.insert ? length + 1 : length; - }, 0); - } - - const text = ytext.toString(); - if (!text || text === ZERO_WIDTH_SPACE) { - return 0; - } - return ytext.length; -} - -export function normalizeInlineRange( - ytext: InlineTextLike, - range: SelectionRange | null, -): SelectionRange | null { - if (!range) return null; - - return { - start: normalizeInlineOffset(ytext, range.start), - end: normalizeInlineOffset(ytext, range.end), - }; -} - -function getSelectionTarget( - blockId: string, - ytext: InlineTextLike, - range: SelectionRange | null, -): SelectionTarget { - const normalizedRange = normalizeInlineRange(ytext, range); - - return { - blockId, - anchorOffset: normalizedRange?.start ?? 0, - focusOffset: normalizedRange?.end ?? 0, - }; -} - -function isCollapsedRange(range: SelectionRange | null): boolean { - return !range || range.start === range.end; -} - -function getInlineNodeSelectionTarget( - editor: Editor, - options: { - blockId: string; - offset: number; - direction: DeleteDirection; - }, -): SelectionTarget | null { - const block = editor.getBlock(options.blockId); - if (!block) { - return null; - } - - let currentOffset = 0; - for (const delta of block.inlineDeltas()) { - const length = - typeof delta.insert === "string" ? delta.insert.length : 1; - const nextOffset = currentOffset + length; - const isInlineNode = typeof delta.insert !== "string"; - - if ( - isInlineNode && - options.direction === "backward" && - options.offset === nextOffset - ) { - return { - blockId: options.blockId, - anchorOffset: currentOffset, - focusOffset: nextOffset, - }; - } - - if ( - isInlineNode && - options.direction === "forward" && - options.offset === currentOffset - ) { - return { - blockId: options.blockId, - anchorOffset: currentOffset, - focusOffset: nextOffset, - }; - } - - currentOffset = nextOffset; - } - - return null; -} - -function getListIndent( - block: NonNullable>, -): number { - const rawIndent = block.props?.indent; - return typeof rawIndent === "number" && rawIndent >= 0 ? rawIndent : 0; -} - -function isListBlock( - block: ReturnType, -): block is NonNullable> { - return !!block && LIST_BLOCK_TYPES.has(block.type); -} - -export function moveCaretAcrossBlocks( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - direction: "previous" | "next"; - }, -): SelectionTarget | null { - const { blockId, ytext, direction } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!isCollapsedRange(range)) return null; - - const currentOffset = range?.start ?? 0; - const logicalLength = getLogicalInlineLength(ytext); - const isAtBoundary = - direction === "previous" - ? currentOffset === 0 - : currentOffset === logicalLength; - if (!isAtBoundary) return null; - - const immediateId = getAdjacentVisibleBlockId(editor, blockId, direction); - if (!immediateId) return null; - - if ( - !isContinuousTextFlowCapability( - getEditorFlowCapability(editor, immediateId), - ) - ) { - return { - blockId: immediateId, - anchorOffset: 0, - focusOffset: 0, - selectBlock: true, - }; - } - - const adjacentBlock = editor.getBlock(immediateId); - if (!adjacentBlock) return null; - - const targetOffset = direction === "previous" ? adjacentBlock.length() : 0; - return { - blockId: adjacentBlock.id, - anchorOffset: targetOffset, - focusOffset: targetOffset, - }; -} - -export function applyListTabBehavior( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - shiftKey: boolean; - }, -): SelectionTarget | null { - const { blockId, ytext, range, shiftKey } = options; - const block = editor.getBlock(blockId); - if (!isListBlock(block)) { - return null; - } - - const currentIndent = getListIndent(block); - let nextIndent = currentIndent; - - if (shiftKey) { - nextIndent = Math.max(0, currentIndent - 1); - } else { - const previousBlockId = getAdjacentVisibleBlockId( - editor, - blockId, - "previous", - ); - const previousBlock = previousBlockId - ? editor.getBlock(previousBlockId) - : null; - const sharesParent = - previousBlockId !== null && - editor.documentState.parentOf(previousBlockId) === - editor.documentState.parentOf(blockId); - - if ( - isListBlock(previousBlock) && - sharesParent && - getListIndent(previousBlock) >= currentIndent - ) { - nextIndent = currentIndent + 1; - } - } - - if (nextIndent === currentIndent) { - return null; - } - - editor.apply( - [ - { - type: "update-block", - blockId, - props: { indent: nextIndent }, - } as DocumentOp, - ], - { origin: "user" }, - ); - - return getSelectionTarget(blockId, ytext, range); -} - -export function resolveBackspaceAction( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - }, -): BackspaceAction | null { - const { blockId, ytext } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!isCollapsedRange(range)) return null; - if ((range?.start ?? 0) !== 0) return null; - if ( - !isContinuousTextFlowCapability( - getEditorFlowCapability(editor, blockId), - ) - ) { - return null; - } - - const block = editor.getBlock(blockId); - if (!block) return null; - - if ( - isBlockEmpty(ytext) && - block.type === "toggle" && - block.children.length === 0 - ) { - const previousBlock = getAdjacentEditableBlock( - editor, - blockId, - "previous", - ); - if (previousBlock) { - return { - action: "delete", - targetBlockId: previousBlock.id, - }; - } - return { action: "convert", newType: "paragraph" }; - } - - if (isBlockEmpty(ytext) && BACKSPACE_EXIT_TYPES.has(block.type)) { - return { action: "convert", newType: "paragraph" }; - } - - const immediateBlockId = getAdjacentVisibleBlockId( - editor, - blockId, - "previous", - ); - if ( - immediateBlockId && - !isContinuousTextFlowCapability( - getEditorFlowCapability(editor, immediateBlockId), - ) - ) { - return { - action: "select-block", - targetBlockId: immediateBlockId, - }; - } - - const previousBlock = getAdjacentEditableBlock(editor, blockId, "previous"); - if (!previousBlock) return null; - - return { - action: "merge", - targetBlockId: previousBlock.id, - }; -} - -export function applyBackspaceBehavior( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - }, -): SelectionTarget | null { - const { blockId, ytext } = options; - const action = resolveBackspaceAction(editor, options); - if (!action) return null; - - if (action.action === "convert") { - return convertBlock(editor, { - blockId, - newType: action.newType, - }); - } - - if (action.action === "select-block") { - return { - blockId: action.targetBlockId, - anchorOffset: 0, - focusOffset: 0, - selectBlock: true, - }; - } - - const previousBlock = editor.getBlock(action.targetBlockId); - if (!previousBlock) return null; - - const targetOffset = previousBlock.length(); - if (action.action === "delete" || getLogicalInlineLength(ytext) === 0) { - editor.apply([ - { - type: "delete-block", - blockId, - } as DocumentOp, - ]); - } else { - editor.apply([ - { - type: "merge-blocks", - targetBlockId: previousBlock.id, - sourceBlockId: blockId, - } as DocumentOp, - ]); - } - - return { - blockId: previousBlock.id, - anchorOffset: targetOffset, - focusOffset: targetOffset, - }; -} - -function getCollapsedTextSelectionTarget( - editor: Editor, -): SelectionTarget | null { - const selection = editor.selection; - if (!selection || selection.type !== "text") { - return null; - } - - return { - blockId: selection.focus.blockId, - anchorOffset: selection.focus.offset, - focusOffset: selection.focus.offset, - }; -} - -export function applyDeleteBehavior( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - direction: DeleteDirection; - }, -): SelectionTarget | null { - const { blockId, ytext, direction } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!range) return null; - - if (!isCollapsedRange(range)) { - editor.selectText(blockId, range.start, range.end); - editor.deleteSelection({ origin: "user" }); - return ( - getCollapsedTextSelectionTarget(editor) ?? { - blockId, - anchorOffset: range.start, - focusOffset: range.start, - } - ); - } - - const inlineNodeTarget = getInlineNodeSelectionTarget(editor, { - blockId, - offset: range.start, - direction, - }); - if (inlineNodeTarget) { - return inlineNodeTarget; - } - - if (direction === "backward") { - return applyBackspaceBehavior(editor, { - blockId, - ytext, - range, - }); - } - - return null; -} - -export function mergeBackwardAtBlockStart( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - }, -): SelectionTarget | null { - return applyBackspaceBehavior(editor, options); -} - -export function resolveEnterAction( - editor: Editor, - blockId: string, - inputMode: "richtext" | "code" | "table" | "none", - ytext: { length: number; toString(): string }, -): EnterAction | null { - if (inputMode === "code") { - return { action: "insert-text", text: "\n" }; - } - - if (inputMode !== "richtext") { - return null; - } - - const block = editor.getBlock(blockId); - if (!block) return null; - - const blockType = block.type; - const empty = isBlockEmpty(ytext); - - if (empty && LIST_BLOCK_TYPES.has(blockType)) { - return { action: "convert", newType: "paragraph" }; - } - - if (empty && CONTAINER_EXIT_TYPES.has(blockType)) { - return { action: "convert", newType: "paragraph" }; - } - - if (empty && isInsideParentIdContainer(editor, blockId)) { - return { action: "lift" }; - } - - if (HEADING_TYPES.has(blockType)) { - return { action: "split", newBlockType: "paragraph" }; - } - - return { action: "split", newBlockType: undefined }; -} - -// ── Offset normalization ───────────────────────────────────── - -export function normalizeInlineOffset( - ytext: InlineTextLike, - offset: number, -): number { - return Math.max(0, Math.min(offset, getLogicalInlineLength(ytext))); -} - -export function toggleInlineMark(editor: Editor, markType: string): boolean { - return toggleInlineMarkCommand(editor, markType); -} - -export function setInlineMark( - editor: Editor, - markType: string, - value: Record | null, -): boolean { - return setInlineMarkCommand(editor, markType, value); -} - -// ── Commands ───────────────────────────────────────────────── - -export function splitBlockAtOffset( - editor: Editor, - options: { - blockId: string; - offset: number; - newBlockType?: string; - }, -): SelectionTarget { - const { blockId, offset, newBlockType } = options; - const newBlockId = generateId(); - - editor.apply([ - { - type: "split-block", - blockId, - offset, - newBlockId, - newBlockType, - } as DocumentOp, - ]); - - return { - blockId: newBlockId, - anchorOffset: 0, - focusOffset: 0, - }; -} - -export function convertBlock( - editor: Editor, - options: { - blockId: string; - newType: string; - newProps?: Record; - }, -): SelectionTarget { - editor.apply(getConvertBlockOps(editor, options), { origin: "user" }); - - return { - blockId: options.blockId, - anchorOffset: 0, - focusOffset: 0, - }; -} - -export function getConvertBlockOps( - editor: Editor, - options: { - blockId: string; - newType: string; - newProps?: Record; - }, -): DocumentOp[] { - const existingParentId = editor.documentState.parentOf(options.blockId); - const ops: DocumentOp[] = [ - { - type: "convert-block", - blockId: options.blockId, - newType: options.newType, - newProps: options.newProps, - } as DocumentOp, - ]; - - if (existingParentId) { - ops.push({ - type: "update-block", - blockId: options.blockId, - props: { parentId: existingParentId }, - } as DocumentOp); - } - - return ops; -} - -export function insertTextAtRange( - editor: Editor, - options: { - blockId: string; - range: SelectionRange | null; - text: string; - }, -): SelectionTarget { - const { blockId, range, text } = options; - const start = range?.start ?? 0; - const end = range?.end ?? start; - const ops: DocumentOp[] = []; - - if (end > start) { - ops.push({ - type: "delete-text", - blockId, - offset: start, - length: end - start, - }); - } - - if (text.length > 0) { - ops.push({ - type: "insert-text", - blockId, - offset: start, - text, - }); - } - - if (ops.length > 0) { - editor.apply(ops, { origin: "user" }); - } - - const nextOffset = start + text.length; - return { - blockId, - anchorOffset: nextOffset, - focusOffset: nextOffset, - }; -} - -export function applyListInputRule( - editor: Editor, - options: { - blockId: string; - range: SelectionRange | null; - text: string; - }, -): SelectionTarget | null { - const { blockId, range, text } = options; - if (!range || range.start !== range.end) { - return null; - } - - const block = editor.getBlock(blockId); - if (!block) { - return null; - } - - const inputRuleEngine = - editor.internals.getSlot( - INPUT_RULES_ENGINE_SLOT_KEY, - ) ?? null; - if (inputRuleEngine) { - const ops = inputRuleEngine.tryMatch(editor, blockId, text, { - offset: range.start, - }); - if (ops) { - editor.apply(ops, { origin: "input-rule" }); - return { - blockId, - anchorOffset: 0, - focusOffset: 0, - }; - } - } - - if (block.type !== "paragraph") { - return null; - } - - const match = matchListInputRule(block.textContent(), range, text); - if (!match) { - return null; - } - - editor.apply( - [ - { - type: "delete-text", - blockId, - offset: match.deleteRange.start, - length: match.deleteRange.end - match.deleteRange.start, - } as DocumentOp, - { - type: "convert-block", - blockId, - newType: match.blockType, - newProps: match.newProps, - } as DocumentOp, - ], - { origin: "input-rule" }, - ); - - return { - blockId, - anchorOffset: 0, - focusOffset: 0, - }; -} - -export function applyEnterBehavior( - editor: Editor, - options: { - blockId: string; - inputMode: "richtext" | "code" | "table" | "none"; - ytext: { - length: number; - toString(): string; - insert(offset: number, text: string): void; - delete(offset: number, length: number): void; - }; - range: SelectionRange | null; - }, -): SelectionTarget | null { - const { blockId, inputMode, ytext, range } = options; - - const enterAction = resolveEnterAction(editor, blockId, inputMode, ytext); - if (!enterAction) return null; - - switch (enterAction.action) { - case "insert-text": - return insertTextAtRange(editor, { - blockId, - range, - text: enterAction.text, - }); - - case "convert": - return convertBlock(editor, { - blockId, - newType: enterAction.newType, - }); - - case "lift": - return liftBlockOutOfParent(editor, { blockId }); - - case "split": - return splitBlockAtOffset(editor, { - blockId, - offset: normalizeInlineOffset( - ytext, - range?.start ?? ytext.length, - ), - newBlockType: enterAction.newBlockType, - }); - } -} - -function liftBlockOutOfParent( - editor: Editor, - options: { blockId: string }, -): SelectionTarget { - editor.apply( - [ - { - type: "update-block", - blockId: options.blockId, - props: { parentId: null }, - } as DocumentOp, - ], - { origin: "user" }, - ); - - return { - blockId: options.blockId, - anchorOffset: 0, - focusOffset: 0, - }; -} +export type { + InlineTextLike, + SelectionRange, + SelectionTarget, +} from "./commandsShared"; +export { + getLogicalInlineLength, + normalizeInlineRange, +} from "./commandsShared"; +export { + applyListTabBehavior, + moveCaretAcrossBlocks, +} from "./commandsNavigation"; +export { + applyBackspaceBehavior, + applyDeleteBehavior, + mergeBackwardAtBlockStart, + resolveBackspaceAction, +} from "./commandsDelete"; +export { + applyListInputRule, + convertBlock, + getConvertBlockOps, + insertTextAtRange, + normalizeInlineOffset, + setInlineMark, + splitBlockAtOffset, + toggleInlineMark, +} from "./commandsBlock"; +export { + applyEnterBehavior, + resolveEnterAction, +} from "./commandsEnter"; diff --git a/packages/rendering/dom/src/field-editor/commandsBlock.ts b/packages/rendering/dom/src/field-editor/commandsBlock.ts new file mode 100644 index 0000000..56141bd --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsBlock.ts @@ -0,0 +1,222 @@ +import { INPUT_RULES_ENGINE_SLOT_KEY, generateId } from "@pen/types"; +import type { DocumentOp, Editor } from "@pen/types"; +import { + toggleInlineMark as toggleInlineMarkCommand, + setInlineMark as setInlineMarkCommand, +} from "@pen/shortcuts"; +import { matchListInputRule } from "../utils/listInputRule"; +import { + getLogicalInlineLength, + type BlockInputRuleEngine, + type InlineTextLike, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; + +export function normalizeInlineOffset( + ytext: InlineTextLike, + offset: number, +): number { + return Math.max(0, Math.min(offset, getLogicalInlineLength(ytext))); +} + +export function toggleInlineMark(editor: Editor, markType: string): boolean { + return toggleInlineMarkCommand(editor, markType); +} + +export function setInlineMark( + editor: Editor, + markType: string, + value: Record | null, +): boolean { + return setInlineMarkCommand(editor, markType, value); +} + +// ── Commands ───────────────────────────────────────────────── + +export function splitBlockAtOffset( + editor: Editor, + options: { + blockId: string; + offset: number; + newBlockType?: string; + }, +): SelectionTarget { + const { blockId, offset, newBlockType } = options; + const newBlockId = generateId(); + + editor.apply([ + { + type: "split-block", + blockId, + offset, + newBlockId, + newBlockType, + } as DocumentOp, + ]); + + return { + blockId: newBlockId, + anchorOffset: 0, + focusOffset: 0, + }; +} + +export function convertBlock( + editor: Editor, + options: { + blockId: string; + newType: string; + newProps?: Record; + }, +): SelectionTarget { + editor.apply(getConvertBlockOps(editor, options), { origin: "user" }); + + return { + blockId: options.blockId, + anchorOffset: 0, + focusOffset: 0, + }; +} + +export function getConvertBlockOps( + editor: Editor, + options: { + blockId: string; + newType: string; + newProps?: Record; + }, +): DocumentOp[] { + const existingParentId = editor.documentState.parentOf(options.blockId); + const ops: DocumentOp[] = [ + { + type: "convert-block", + blockId: options.blockId, + newType: options.newType, + newProps: options.newProps, + } as DocumentOp, + ]; + + if (existingParentId) { + ops.push({ + type: "update-block", + blockId: options.blockId, + props: { parentId: existingParentId }, + } as DocumentOp); + } + + return ops; +} + +export function insertTextAtRange( + editor: Editor, + options: { + blockId: string; + range: SelectionRange | null; + text: string; + }, +): SelectionTarget { + const { blockId, range, text } = options; + const start = range?.start ?? 0; + const end = range?.end ?? start; + const ops: DocumentOp[] = []; + + if (end > start) { + ops.push({ + type: "delete-text", + blockId, + offset: start, + length: end - start, + }); + } + + if (text.length > 0) { + ops.push({ + type: "insert-text", + blockId, + offset: start, + text, + }); + } + + if (ops.length > 0) { + editor.apply(ops, { origin: "user" }); + } + + const nextOffset = start + text.length; + return { + blockId, + anchorOffset: nextOffset, + focusOffset: nextOffset, + }; +} + +export function applyListInputRule( + editor: Editor, + options: { + blockId: string; + range: SelectionRange | null; + text: string; + }, +): SelectionTarget | null { + const { blockId, range, text } = options; + if (!range || range.start !== range.end) { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + + const inputRuleEngine = + editor.internals.getSlot( + INPUT_RULES_ENGINE_SLOT_KEY, + ) ?? null; + if (inputRuleEngine) { + const ops = inputRuleEngine.tryMatch(editor, blockId, text, { + offset: range.start, + }); + if (ops) { + editor.apply(ops, { origin: "input-rule" }); + return { + blockId, + anchorOffset: 0, + focusOffset: 0, + }; + } + } + + if (block.type !== "paragraph") { + return null; + } + + const match = matchListInputRule(block.textContent(), range, text); + if (!match) { + return null; + } + + editor.apply( + [ + { + type: "delete-text", + blockId, + offset: match.deleteRange.start, + length: match.deleteRange.end - match.deleteRange.start, + } as DocumentOp, + { + type: "convert-block", + blockId, + newType: match.blockType, + newProps: match.newProps, + } as DocumentOp, + ], + { origin: "input-rule" }, + ); + + return { + blockId, + anchorOffset: 0, + focusOffset: 0, + }; +} diff --git a/packages/rendering/dom/src/field-editor/commandsDelete.ts b/packages/rendering/dom/src/field-editor/commandsDelete.ts new file mode 100644 index 0000000..1b31682 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsDelete.ts @@ -0,0 +1,220 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import { getAdjacentVisibleBlockId } from "../utils/parentIdTree"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; +import { + BACKSPACE_EXIT_TYPES, + getAdjacentEditableBlock, + getInlineNodeSelectionTarget, + getLogicalInlineLength, + isBlockEmpty, + isCollapsedRange, + normalizeInlineRange, + type BackspaceAction, + type DeleteDirection, + type InlineTextLike, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; +import { convertBlock } from "./commandsBlock"; + +export function resolveBackspaceAction( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + }, +): BackspaceAction | null { + const { blockId, ytext } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!isCollapsedRange(range)) return null; + if ((range?.start ?? 0) !== 0) return null; + if ( + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, blockId), + ) + ) { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) return null; + + if ( + isBlockEmpty(ytext) && + block.type === "toggle" && + block.children.length === 0 + ) { + const previousBlock = getAdjacentEditableBlock( + editor, + blockId, + "previous", + ); + if (previousBlock) { + return { + action: "delete", + targetBlockId: previousBlock.id, + }; + } + return { action: "convert", newType: "paragraph" }; + } + + if (isBlockEmpty(ytext) && BACKSPACE_EXIT_TYPES.has(block.type)) { + return { action: "convert", newType: "paragraph" }; + } + + const immediateBlockId = getAdjacentVisibleBlockId( + editor, + blockId, + "previous", + ); + if ( + immediateBlockId && + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, immediateBlockId), + ) + ) { + return { + action: "select-block", + targetBlockId: immediateBlockId, + }; + } + + const previousBlock = getAdjacentEditableBlock(editor, blockId, "previous"); + if (!previousBlock) return null; + + return { + action: "merge", + targetBlockId: previousBlock.id, + }; +} + +export function applyBackspaceBehavior( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + }, +): SelectionTarget | null { + const { blockId, ytext } = options; + const action = resolveBackspaceAction(editor, options); + if (!action) return null; + + if (action.action === "convert") { + return convertBlock(editor, { + blockId, + newType: action.newType, + }); + } + + if (action.action === "select-block") { + return { + blockId: action.targetBlockId, + anchorOffset: 0, + focusOffset: 0, + selectBlock: true, + }; + } + + const previousBlock = editor.getBlock(action.targetBlockId); + if (!previousBlock) return null; + + const targetOffset = previousBlock.length(); + if (action.action === "delete" || getLogicalInlineLength(ytext) === 0) { + editor.apply([ + { + type: "delete-block", + blockId, + } as DocumentOp, + ]); + } else { + editor.apply([ + { + type: "merge-blocks", + targetBlockId: previousBlock.id, + sourceBlockId: blockId, + } as DocumentOp, + ]); + } + + return { + blockId: previousBlock.id, + anchorOffset: targetOffset, + focusOffset: targetOffset, + }; +} + +function getCollapsedTextSelectionTarget( + editor: Editor, +): SelectionTarget | null { + const selection = editor.selection; + if (!selection || selection.type !== "text") { + return null; + } + + return { + blockId: selection.focus.blockId, + anchorOffset: selection.focus.offset, + focusOffset: selection.focus.offset, + }; +} + +export function applyDeleteBehavior( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + direction: DeleteDirection; + }, +): SelectionTarget | null { + const { blockId, ytext, direction } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!range) return null; + + if (!isCollapsedRange(range)) { + editor.selectText(blockId, range.start, range.end); + editor.deleteSelection({ origin: "user" }); + return ( + getCollapsedTextSelectionTarget(editor) ?? { + blockId, + anchorOffset: range.start, + focusOffset: range.start, + } + ); + } + + const inlineNodeTarget = getInlineNodeSelectionTarget(editor, { + blockId, + offset: range.start, + direction, + }); + if (inlineNodeTarget) { + return inlineNodeTarget; + } + + if (direction === "backward") { + return applyBackspaceBehavior(editor, { + blockId, + ytext, + range, + }); + } + + return null; +} + +export function mergeBackwardAtBlockStart( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + }, +): SelectionTarget | null { + return applyBackspaceBehavior(editor, options); +} diff --git a/packages/rendering/dom/src/field-editor/commandsEnter.ts b/packages/rendering/dom/src/field-editor/commandsEnter.ts new file mode 100644 index 0000000..0749ad0 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsEnter.ts @@ -0,0 +1,126 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import { isInsideParentIdContainer } from "../utils/parentIdTree"; +import { + CONTAINER_EXIT_TYPES, + HEADING_TYPES, + LIST_BLOCK_TYPES, + isBlockEmpty, + type EnterAction, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; +import { + convertBlock, + insertTextAtRange, + normalizeInlineOffset, + splitBlockAtOffset, +} from "./commandsBlock"; + +export function resolveEnterAction( + editor: Editor, + blockId: string, + inputMode: "richtext" | "code" | "table" | "none", + ytext: { length: number; toString(): string }, +): EnterAction | null { + if (inputMode === "code") { + return { action: "insert-text", text: "\n" }; + } + + if (inputMode !== "richtext") { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) return null; + + const blockType = block.type; + const empty = isBlockEmpty(ytext); + + if (empty && LIST_BLOCK_TYPES.has(blockType)) { + return { action: "convert", newType: "paragraph" }; + } + + if (empty && CONTAINER_EXIT_TYPES.has(blockType)) { + return { action: "convert", newType: "paragraph" }; + } + + if (empty && isInsideParentIdContainer(editor, blockId)) { + return { action: "lift" }; + } + + if (HEADING_TYPES.has(blockType)) { + return { action: "split", newBlockType: "paragraph" }; + } + + return { action: "split", newBlockType: undefined }; +} + +export function applyEnterBehavior( + editor: Editor, + options: { + blockId: string; + inputMode: "richtext" | "code" | "table" | "none"; + ytext: { + length: number; + toString(): string; + insert(offset: number, text: string): void; + delete(offset: number, length: number): void; + }; + range: SelectionRange | null; + }, +): SelectionTarget | null { + const { blockId, inputMode, ytext, range } = options; + + const enterAction = resolveEnterAction(editor, blockId, inputMode, ytext); + if (!enterAction) return null; + + switch (enterAction.action) { + case "insert-text": + return insertTextAtRange(editor, { + blockId, + range, + text: enterAction.text, + }); + + case "convert": + return convertBlock(editor, { + blockId, + newType: enterAction.newType, + }); + + case "lift": + return liftBlockOutOfParent(editor, { blockId }); + + case "split": + return splitBlockAtOffset(editor, { + blockId, + offset: normalizeInlineOffset( + ytext, + range?.start ?? ytext.length, + ), + newBlockType: enterAction.newBlockType, + }); + } +} + +function liftBlockOutOfParent( + editor: Editor, + options: { blockId: string }, +): SelectionTarget { + editor.apply( + [ + { + type: "update-block", + blockId: options.blockId, + props: { parentId: null }, + } as DocumentOp, + ], + { origin: "user" }, + ); + + return { + blockId: options.blockId, + anchorOffset: 0, + focusOffset: 0, + }; +} diff --git a/packages/rendering/dom/src/field-editor/commandsNavigation.ts b/packages/rendering/dom/src/field-editor/commandsNavigation.ts new file mode 100644 index 0000000..a91af5f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsNavigation.ts @@ -0,0 +1,126 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import { getAdjacentVisibleBlockId } from "../utils/parentIdTree"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; +import { + getListIndent, + getLogicalInlineLength, + getSelectionTarget, + isCollapsedRange, + isListBlock, + normalizeInlineRange, + type InlineTextLike, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; + +export function moveCaretAcrossBlocks( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + direction: "previous" | "next"; + }, +): SelectionTarget | null { + const { blockId, ytext, direction } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!isCollapsedRange(range)) return null; + + const currentOffset = range?.start ?? 0; + const logicalLength = getLogicalInlineLength(ytext); + const isAtBoundary = + direction === "previous" + ? currentOffset === 0 + : currentOffset === logicalLength; + if (!isAtBoundary) return null; + + const immediateId = getAdjacentVisibleBlockId(editor, blockId, direction); + if (!immediateId) return null; + + if ( + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, immediateId), + ) + ) { + return { + blockId: immediateId, + anchorOffset: 0, + focusOffset: 0, + selectBlock: true, + }; + } + + const adjacentBlock = editor.getBlock(immediateId); + if (!adjacentBlock) return null; + + const targetOffset = direction === "previous" ? adjacentBlock.length() : 0; + return { + blockId: adjacentBlock.id, + anchorOffset: targetOffset, + focusOffset: targetOffset, + }; +} + +export function applyListTabBehavior( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + shiftKey: boolean; + }, +): SelectionTarget | null { + const { blockId, ytext, range, shiftKey } = options; + const block = editor.getBlock(blockId); + if (!isListBlock(block)) { + return null; + } + + const currentIndent = getListIndent(block); + let nextIndent = currentIndent; + + if (shiftKey) { + nextIndent = Math.max(0, currentIndent - 1); + } else { + const previousBlockId = getAdjacentVisibleBlockId( + editor, + blockId, + "previous", + ); + const previousBlock = previousBlockId + ? editor.getBlock(previousBlockId) + : null; + const sharesParent = + previousBlockId !== null && + editor.documentState.parentOf(previousBlockId) === + editor.documentState.parentOf(blockId); + + if ( + isListBlock(previousBlock) && + sharesParent && + getListIndent(previousBlock) >= currentIndent + ) { + nextIndent = currentIndent + 1; + } + } + + if (nextIndent === currentIndent) { + return null; + } + + editor.apply( + [ + { + type: "update-block", + blockId, + props: { indent: nextIndent }, + } as DocumentOp, + ], + { origin: "user" }, + ); + + return getSelectionTarget(blockId, ytext, range); +} diff --git a/packages/rendering/dom/src/field-editor/commandsShared.ts b/packages/rendering/dom/src/field-editor/commandsShared.ts new file mode 100644 index 0000000..c0016c8 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsShared.ts @@ -0,0 +1,227 @@ +import { INPUT_RULES_ENGINE_SLOT_KEY, generateId } from "@pen/types"; +import type { DocumentOp, Editor } from "@pen/types"; +import { + toggleInlineMark as toggleInlineMarkCommand, + setInlineMark as setInlineMarkCommand, +} from "@pen/shortcuts"; +import { matchListInputRule } from "../utils/listInputRule"; +import { + getAdjacentVisibleBlockId, + isInsideParentIdContainer, +} from "../utils/parentIdTree"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; + +export const ZERO_WIDTH_SPACE = "\u200B"; + +export interface SelectionRange { + start: number; + end: number; +} + +export interface SelectionTarget { + blockId: string; + anchorOffset: number; + focusOffset: number; + selectBlock?: boolean; +} + +export type InlineTextLike = { + length: number; + toString(): string; + toDelta?(): Array<{ insert?: string | Record }>; +}; + +export type BlockInputRuleEngine = { + tryMatch( + editor: Editor, + blockId: string, + insertedText: string, + options?: { offset?: number }, + ): DocumentOp[] | null; +}; + +// ── Enter action resolution ────────────────────────────────── + +export type EnterAction = + | { action: "split"; newBlockType: string | undefined } + | { action: "convert"; newType: string } + | { action: "lift" } + | { action: "insert-text"; text: string }; + +export type BackspaceAction = + | { action: "convert"; newType: string } + | { action: "delete"; targetBlockId: string } + | { action: "select-block"; targetBlockId: string } + | { action: "merge"; targetBlockId: string }; + +export type DeleteDirection = "backward" | "forward"; + +export const LIST_BLOCK_TYPES = new Set([ + "bulletListItem", + "numberedListItem", + "checkListItem", +]); + +export const HEADING_TYPES = new Set(["heading"]); + +export const CONTAINER_EXIT_TYPES = new Set(["blockquote", "callout"]); +export const BACKSPACE_EXIT_TYPES = new Set([ + ...LIST_BLOCK_TYPES, + ...CONTAINER_EXIT_TYPES, + ...HEADING_TYPES, +]); + +export function isBlockEmpty(ytext: InlineTextLike): boolean { + return getLogicalInlineLength(ytext) === 0; +} + +export function getAdjacentEditableBlock( + editor: Editor, + blockId: string, + direction: "previous" | "next", +): ReturnType { + let adjacentBlockId = getAdjacentVisibleBlockId(editor, blockId, direction); + while (adjacentBlockId) { + const adjacentBlock = editor.getBlock(adjacentBlockId); + if ( + adjacentBlock && + isContinuousTextFlowCapability( + getEditorFlowCapability(editor, adjacentBlock.id), + ) + ) { + return adjacentBlock; + } + adjacentBlockId = getAdjacentVisibleBlockId( + editor, + adjacentBlockId, + direction, + ); + } + return null; +} + +export function getLogicalInlineLength(ytext: InlineTextLike): number { + const delta = ytext.toDelta?.(); + if (delta) { + return delta.reduce((length, entry) => { + if (typeof entry.insert === "string") { + return ( + length + + (entry.insert === ZERO_WIDTH_SPACE + ? 0 + : entry.insert.length) + ); + } + return entry.insert ? length + 1 : length; + }, 0); + } + + const text = ytext.toString(); + if (!text || text === ZERO_WIDTH_SPACE) { + return 0; + } + return ytext.length; +} + +export function normalizeInlineOffset( + ytext: InlineTextLike, + offset: number, +): number { + return Math.max(0, Math.min(offset, getLogicalInlineLength(ytext))); +} + +export function normalizeInlineRange( + ytext: InlineTextLike, + range: SelectionRange | null, +): SelectionRange | null { + if (!range) return null; + + return { + start: normalizeInlineOffset(ytext, range.start), + end: normalizeInlineOffset(ytext, range.end), + }; +} + +export function getSelectionTarget( + blockId: string, + ytext: InlineTextLike, + range: SelectionRange | null, +): SelectionTarget { + const normalizedRange = normalizeInlineRange(ytext, range); + + return { + blockId, + anchorOffset: normalizedRange?.start ?? 0, + focusOffset: normalizedRange?.end ?? 0, + }; +} + +export function isCollapsedRange(range: SelectionRange | null): boolean { + return !range || range.start === range.end; +} + +export function getInlineNodeSelectionTarget( + editor: Editor, + options: { + blockId: string; + offset: number; + direction: DeleteDirection; + }, +): SelectionTarget | null { + const block = editor.getBlock(options.blockId); + if (!block) { + return null; + } + + let currentOffset = 0; + for (const delta of block.inlineDeltas()) { + const length = + typeof delta.insert === "string" ? delta.insert.length : 1; + const nextOffset = currentOffset + length; + const isInlineNode = typeof delta.insert !== "string"; + + if ( + isInlineNode && + options.direction === "backward" && + options.offset === nextOffset + ) { + return { + blockId: options.blockId, + anchorOffset: currentOffset, + focusOffset: nextOffset, + }; + } + + if ( + isInlineNode && + options.direction === "forward" && + options.offset === currentOffset + ) { + return { + blockId: options.blockId, + anchorOffset: currentOffset, + focusOffset: nextOffset, + }; + } + + currentOffset = nextOffset; + } + + return null; +} + +export function getListIndent( + block: NonNullable>, +): number { + const rawIndent = block.props?.indent; + return typeof rawIndent === "number" && rawIndent >= 0 ? rawIndent : 0; +} + +export function isListBlock( + block: ReturnType, +): block is NonNullable> { + return !!block && LIST_BLOCK_TYPES.has(block.type); +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index a2a18e7..c524dad 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -1,1397 +1,3 @@ -import type { Editor, InlineDecoration } from "@pen/types"; -import type { FieldEditorInputController } from "./controller"; -import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; -import { - computeTextDiff, - domPointToOffset, - domSelectionToEditor, - editorSelectionToDOM, - extractTextFromDOM, - getSelectionOffsets, -} from "./selectionBridge"; -import { normalizeSelectionFormation } from "../utils/selectionFormation"; -import type { PasteImporters } from "../types/paste"; -import { handlePaste, handleCopy, handleCut } from "./clipboard"; -import { - applyListInputRule, - applyDeleteBehavior, - applyEnterBehavior, - toggleInlineMark, -} from "./commands"; -import { handleFieldEditorKeyDown } from "./keyHandling"; -import { isHistoryTransactionOrigin } from "./historyOrigin"; -import { - buildInlineTextDiffOps, - buildInlineTextEditTransaction, - type InlineTextDiffOp, -} from "./inlineTextTransaction"; -import type { - FieldEditorDelta, - FieldEditorObserver, - FieldEditorTextChangeEvent, - FieldEditorTextLike, -} from "./crdt"; +import { ContentEditableBackendSelection } from "./contenteditableBackendSelection"; -export class ContentEditableBackend { - private element: HTMLElement | null = null; - private ytext: FieldEditorTextLike | null = null; - private observer: FieldEditorObserver | null = null; - private mutationObserver: MutationObserver | null = null; - private isComposing = false; - private compositionStartTimestamp = 0; - private compositionStartText: string | null = null; - private deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }> = []; - private pendingDomSyncFrame: number | null = null; - private editor: Editor; - private fieldEditor: FieldEditorInputController; - - constructor(editor: Editor, fieldEditor: FieldEditorInputController) { - this.editor = editor; - this.fieldEditor = fieldEditor; - } - - activate(element: HTMLElement, ytext: unknown): void { - this.element = element; - this.ytext = ytext as FieldEditorTextLike; - - element.contentEditable = "true"; - this.fieldEditor.resetBackendSelectionAuthority(); - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - this.isComposing = false; - this.compositionStartText = null; - this.fieldEditor.setComposing(false); - - element.addEventListener("beforeinput", this.handleBeforeInput); - element.addEventListener( - "compositionstart", - this.handleCompositionStart, - ); - element.addEventListener("compositionend", this.handleCompositionEnd); - element.addEventListener("keydown", this.handleKeyDown); - element.addEventListener("copy", this.handleCopyEvent); - element.addEventListener("cut", this.handleCutEvent); - element.addEventListener("dragstart", this.handleDragStart); - element.addEventListener("drop", this.handleDrop); - element.addEventListener("pointerdown", this.handlePointerDown); - element.ownerDocument?.addEventListener( - "selectionchange", - this.handleSelectionChange, - ); - - this.mutationObserver = new MutationObserver(this.handleMutations); - this.mutationObserver.observe(element, { - childList: true, - subtree: true, - characterData: true, - characterDataOldValue: true, - }); - - this.observer = (event) => this.handleYTextChange(event); - this.ytext.observe(this.observer); - - fullReconcileToDOM(this.ytext, element, this.editor.schema, { - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled( - this.fieldEditor.focusBlockId ?? undefined, - ); - this.restoreDOMSelectionFromEditor(); - } - - deactivate(): void { - if (this.element) { - this.element.contentEditable = "false"; - this.element.removeEventListener( - "beforeinput", - this.handleBeforeInput, - ); - this.element.removeEventListener( - "compositionstart", - this.handleCompositionStart, - ); - this.element.removeEventListener( - "compositionend", - this.handleCompositionEnd, - ); - this.element.removeEventListener("keydown", this.handleKeyDown); - this.element.removeEventListener("copy", this.handleCopyEvent); - this.element.removeEventListener("cut", this.handleCutEvent); - this.element.removeEventListener("dragstart", this.handleDragStart); - this.element.removeEventListener("drop", this.handleDrop); - this.element.removeEventListener( - "pointerdown", - this.handlePointerDown, - ); - this.element.ownerDocument?.removeEventListener( - "selectionchange", - this.handleSelectionChange, - ); - } - if (this.mutationObserver) { - this.mutationObserver.disconnect(); - this.mutationObserver = null; - } - if (this.pendingDomSyncFrame != null) { - cancelAnimationFrame(this.pendingDomSyncFrame); - this.pendingDomSyncFrame = null; - } - if (this.observer && this.ytext) { - this.ytext.unobserve(this.observer); - } - this.element = null; - this.ytext = null; - this.observer = null; - this.deferredRemoteDeltas = []; - this.fieldEditor.resetBackendSelectionAuthority(); - this.isComposing = false; - this.compositionStartText = null; - this.fieldEditor.setComposing(false); - } - - updateSelection(_relPos: unknown): void { - this.restoreDOMSelectionFromEditor(); - } - - private _getActiveCellCoord(blockId: string): { - blockId: string; - row: number; - col: number; - } | null { - const coord = this.fieldEditor.activeCellCoord; - if (!coord || coord.blockId !== blockId) { - return null; - } - return coord; - } - - applyInlineTextEdit(options: { - blockId: string; - range: { start: number; end: number }; - text: string; - marks?: Record; - }): void { - const { blockId, range, text, marks } = options; - const cellCoord = this._getActiveCellCoord(blockId); - const transaction = buildInlineTextEditTransaction({ - blockId, - range, - text, - marks, - cellCoord, - }); - this.fieldEditor.setBackendSelectionAuthority( - "programmatic", - transaction.selection, - ); - - if (transaction.ops.length > 0) { - this.editor.apply(transaction.ops, { origin: "user" }); - } - - if (cellCoord) { - this.fieldEditor.setBackendSelectionAuthority( - "cell", - transaction.selection, - ); - } else { - this.fieldEditor.syncTextSelection( - blockId, - transaction.selection.anchorOffset, - transaction.selection.focusOffset, - ); - } - this.ensureActiveDOMMatchesYText(); - this.restoreDOMSelectionFromEditor(); - this.scheduleActiveDOMMatchCheck(); - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - } - - applyListInputRule(options: { - blockId: string; - range: { start: number; end: number }; - text: string; - }): boolean { - const target = applyListInputRule(this.editor, options); - if (!target) return false; - - this.fieldEditor.setBackendSelectionAuthority("programmatic", { - blockId: target.blockId, - anchorOffset: target.anchorOffset, - focusOffset: target.focusOffset, - }); - - this.fieldEditor.syncTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - this.restoreDOMSelectionFromEditor(); - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - return true; - } - - restoreDOMSelectionFromEditor(): void { - if (!this.element) return; - - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - const selection = this.editor.selection; - - const pendingSelection = this.fieldEditor.getBackendSelectionAuthority( - "programmatic", - blockId, - ); - const activeCell = this._getActiveCellCoord(blockId); - if ( - activeCell && - (!pendingSelection || - (pendingSelection.cell?.row === activeCell.row && - pendingSelection.cell?.col === activeCell.col)) - ) { - const activeSelection = - pendingSelection ?? - this.fieldEditor.getBackendSelectionAuthority("cell", blockId) ?? - (selection?.type === "text" && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? { - anchorOffset: selection.anchor.offset, - focusOffset: selection.focus.offset, - } - : null); - if (!activeSelection) return; - const start = activeSelection.anchorOffset; - const end = activeSelection.focusOffset; - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - setSelectionOffsets(this.element, start, end); - return; - } - const anchor = - pendingSelection != null - ? { - blockId: pendingSelection.blockId, - offset: pendingSelection.anchorOffset, - } - : selection?.type === "text" - ? selection.anchor - : null; - const focus = - pendingSelection != null - ? { - blockId: pendingSelection.blockId, - offset: pendingSelection.focusOffset, - } - : selection?.type === "text" - ? selection.focus - : null; - - if (!anchor || !focus) return; - if (anchor.blockId !== blockId || focus.blockId !== blockId) { - return; - } - if ( - pendingSelection == null && - anchor.offset === focus.offset && - selection?.type === "text" && - selection.isCollapsed - ) { - this.fieldEditor.setBackendSelectionAuthority("programmatic", { - blockId, - anchorOffset: anchor.offset, - focusOffset: focus.offset, - }); - } - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - editorSelectionToDOM(root, anchor, focus); - } - - // ── Direct input handling ───────────────────────────────── - - private handleBeforeInput = (event: InputEvent): void => { - if (this.isComposing) return; - if (!this.ytext || !this.element) return; - - const blockId = this.fieldEditor.focusBlockId; - if (!blockId || !this.editor.getBlock(blockId)) { - this.fieldEditor.deactivate(); - return; - } - - const handler = DIRECT_HANDLERS[event.inputType]; - if (handler) { - if ( - requiresResolvedInputRange(event.inputType) && - !this.ensureResolvableInputRange(event) - ) { - return; - } - - event.preventDefault(); - handler( - event, - this.editor, - this.ytext, - this.fieldEditor, - this.element, - this, - ); - return; - } - - // Let the mutation observer reconcile input types we do not handle directly. - }; - - private ensureResolvableInputRange(event: InputEvent): boolean { - if (!this.element) { - return false; - } - if (canResolveInputRange(event, this.element)) { - return true; - } - - this.restoreDOMSelectionFromEditor(); - - return canResolveInputRange(event, this.element); - } - - // ── Composition handling ────────────────────────────────── - - private handleCompositionStart = (): void => { - this.isComposing = true; - this.compositionStartTimestamp = Date.now(); - this.compositionStartText = this.ytext?.toString() ?? ""; - this.deferredRemoteDeltas = []; - this.fieldEditor.setComposing(true); - }; - - private handleCompositionEnd = (): void => { - this.isComposing = false; - this.fieldEditor.setComposing(false); - - const elapsed = Date.now() - this.compositionStartTimestamp; - - // GBoard rapid composition optimization: skip full diff for single-char - // compositions under 50ms — treat as direct insert. - if (elapsed < 50 && this.element) { - const domText = extractTextFromDOM(this.element); - const crdtText = this.ytext?.toString() ?? ""; - if (Math.abs(domText.length - crdtText.length) <= 1) { - this.reconcileAfterComposition(); - return; - } - } - - // Safari may fire compositionend before the final DOM mutation. - requestAnimationFrame(() => { - if (this.isComposing) return; - this.reconcileAfterComposition(); - }); - }; - - private reconcileAfterComposition(): void { - if (!this.element || !this.ytext) return; - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - - const domText = extractTextFromDOM(this.element); - const baseText = this.compositionStartText ?? this.ytext.toString(); - - if (domText !== baseText) { - const diff = rebaseTextDiffOps( - computeTextDiff(baseText, domText), - this.deferredRemoteDeltas, - ); - this.applyTextDiffAsOps(blockId, diff); - } - - if (this.deferredRemoteDeltas.length > 0) { - this.deferredRemoteDeltas = []; - fullReconcileToDOM(this.ytext, this.element!, this.editor.schema, { - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled( - this.fieldEditor.focusBlockId ?? undefined, - ); - } - - this.compositionStartText = null; - this.restoreDOMSelectionFromEditor(); - } - - // ── Mutation observation fallback ───────────────────────── - - private handleMutations = (_mutations: MutationRecord[]): void => { - if (this.isComposing) return; - if (!this.element || !this.ytext) return; - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - - const domText = extractTextFromDOM(this.element); - const crdtText = this.ytext.toString(); - - if (domText !== crdtText) { - const diff = computeTextDiff(crdtText, domText); - this.applyTextDiffAsOps(blockId, diff); - } - }; - - // ── CRDT→DOM reconciliation ─────────────────────────────── - - private handleYTextChange = (event: FieldEditorTextChangeEvent): void => { - if (this.isComposing) { - if ( - event.transaction?.origin === "remote" || - event.transaction?.origin === "collaborator" - ) { - this.deferredRemoteDeltas.push({ delta: event.delta }); - } - return; - } - - if (!this.element || !this.ytext) return; - const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); - if (isHistory) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled( - this.fieldEditor.focusBlockId ?? undefined, - ); - this.restoreDOMSelectionFromEditor(); - return; - } - - const blockId = this.fieldEditor.focusBlockId; - const isActiveCell = blockId - ? !!this._getActiveCellCoord(blockId) - : false; - if (isActiveCell) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled(blockId ?? undefined); - if ( - this.fieldEditor.hasBackendSelectionAuthority("programmatic") || - event.transaction?.origin === "remote" || - event.transaction?.origin === "collaborator" - ) { - this.restoreDOMSelectionFromEditor(); - } - return; - } - - const applied = applyDeltaToDOM( - event.delta, - this.element, - this.editor.schema, - ); - if (!applied) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled(blockId ?? undefined); - } - - if ( - this.fieldEditor.hasBackendSelectionAuthority("programmatic") || - event.transaction?.origin === "remote" || - event.transaction?.origin === "collaborator" - ) { - this.restoreDOMSelectionFromEditor(); - } - }; - - private applyTextDiffAsOps( - blockId: string, - diff: InlineTextDiffOp[], - ): void { - if (diff.length === 0) return; - const ytext = this.ytext; - if (!ytext) return; - - const cellCoord = this._getActiveCellCoord(blockId); - const ops = buildInlineTextDiffOps({ - blockId, - diff, - ytext, - resolveInsertMarks: (sourceText, offset) => - this.fieldEditor.resolveInsertMarks(sourceText, offset), - cellCoord, - }); - - if (ops.length === 0) return; - - const range = this.element ? getSelectionOffsets(this.element) : null; - if (range) { - this.fieldEditor.setBackendSelectionAuthority("programmatic", { - blockId, - anchorOffset: range.start, - focusOffset: range.end, - cell: cellCoord - ? { row: cellCoord.row, col: cellCoord.col } - : undefined, - }); - } - - this.editor.apply(ops, { origin: "user" }); - - if (range) { - if (cellCoord) { - this.fieldEditor.setBackendSelectionAuthority("cell", { - blockId, - anchorOffset: range.start, - focusOffset: range.end, - cell: { row: cellCoord.row, col: cellCoord.col }, - }); - } else { - this.fieldEditor.syncTextSelection( - blockId, - range.start, - range.end, - ); - } - } - this.ensureActiveDOMMatchesYText(); - this.restoreDOMSelectionFromEditor(); - this.scheduleActiveDOMMatchCheck(); - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - } - - private ensureActiveDOMMatchesYText(): boolean { - if (!this.element || !this.ytext) return false; - if (extractTextFromDOM(this.element) === this.ytext.toString()) { - return false; - } - - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled( - this.fieldEditor.focusBlockId ?? undefined, - ); - return true; - } - - private scheduleActiveDOMMatchCheck(): void { - if (this.pendingDomSyncFrame != null) { - cancelAnimationFrame(this.pendingDomSyncFrame); - } - - this.pendingDomSyncFrame = requestAnimationFrame(() => { - this.pendingDomSyncFrame = null; - if (this.ensureActiveDOMMatchesYText()) { - this.restoreDOMSelectionFromEditor(); - } - }); - } - - private getInlineDecorationsForBlock(): readonly InlineDecoration[] { - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) { - return []; - } - return this.editor - .getDecorations() - .forBlock(blockId) - .filter( - (decoration): decoration is InlineDecoration => - decoration.type === "inline", - ); - } - - // ── Keyboard shortcuts ──────────────────────────────────── - - private handleKeyDown = (event: KeyboardEvent): void => { - if (!this.ytext) return; - if (isNavigationSelectionKey(event)) { - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - this.fieldEditor.clearBackendSelectionAuthority("user-dom"); - } - - const handled = handleFieldEditorKeyDown({ - event, - editor: this.editor, - fieldEditor: this.fieldEditor, - ytext: this.ytext, - range: this.element ? getSelectionOffsets(this.element) : null, - }); - if (handled) { - event.preventDefault(); - return; - } - }; - - resolveCurrentInputRange(): { - start: number; - end: number; - } | null { - const blockId = this.fieldEditor.focusBlockId; - const liveRange = this.element - ? getSelectionOffsets(this.element) - : null; - return ( - this.fieldEditor.resolveProgrammaticInputRange( - blockId, - liveRange, - ) ?? - liveRange - ); - } - - private handleSelectionChange = (): void => { - if (!this.element) return; - const isApplyingSelection = - this.fieldEditor.getBackendSelectionApplicationDepth(); - if ( - !this.fieldEditor.shouldHandleDomSelectionChange( - isApplyingSelection, - ) - ) { - if (this.shouldRestoreSuppressedFullBlockSelection()) { - this.restoreDOMSelectionFromEditor(); - } - return; - } - - const focusBlockId = this.fieldEditor.focusBlockId; - const activeCell = focusBlockId - ? this._getActiveCellCoord(focusBlockId) - : null; - if (activeCell) { - const range = getSelectionOffsets(this.element); - if (!range) return; - this.fieldEditor.setBackendSelectionAuthority("cell", { - blockId: activeCell.blockId, - anchorOffset: range.start, - focusOffset: range.end, - cell: { row: activeCell.row, col: activeCell.col }, - }); - return; - } - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - - const selection = domSelectionToEditor(root); - if (!selection) return; - const normalizedSelection = normalizeSelectionFormation( - this.editor, - selection, - ); - - if (this.shouldRestoreStaleFullBlockSelection(normalizedSelection)) { - this.restoreDOMSelectionFromEditor(); - return; - } - - if (this.shouldRestoreStaleProjectedSelection(normalizedSelection)) { - this.restoreDOMSelectionFromEditor(); - return; - } - - if (normalizedSelection.type === "block") { - this.fieldEditor.deactivate(); - this.editor.setSelection({ - type: "block", - blockIds: normalizedSelection.blockIds, - }); - return; - } - - if ( - this.fieldEditor.shouldIgnoreDomTextSelection( - normalizedSelection.anchor, - normalizedSelection.focus, - ) - ) { - this.restoreDOMSelectionFromEditor(); - return; - } - - this.fieldEditor.setBackendSelectionAuthority("user-dom", { - blockId: normalizedSelection.anchor.blockId, - anchorOffset: normalizedSelection.anchor.offset, - focusOffset: normalizedSelection.focus.offset, - }); - const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( - "programmatic", - normalizedSelection.anchor.blockId, - ); - if ( - !projectedSelection || - projectedSelection.anchorOffset !== normalizedSelection.anchor.offset || - projectedSelection.focusOffset !== normalizedSelection.focus.offset - ) { - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - } - this.fieldEditor.applyDomTextSelection( - normalizedSelection.anchor, - normalizedSelection.focus, - ); - }; - - private shouldRestoreStaleFullBlockSelection( - selection: ReturnType, - ): boolean { - if (selection.type === "block") { - return false; - } - if (selection.anchor.blockId !== selection.focus.blockId) { - return false; - } - - const currentSelection = this.fieldEditor.selection; - if ( - currentSelection?.type !== "text" || - !currentSelection.isCollapsed || - currentSelection.focus.blockId !== selection.anchor.blockId - ) { - return false; - } - - const block = this.editor.getBlock(selection.anchor.blockId); - const blockLength = block?.length() ?? null; - if (blockLength == null) { - return false; - } - - const selectionStart = Math.min( - selection.anchor.offset, - selection.focus.offset, - ); - const selectionEnd = Math.max( - selection.anchor.offset, - selection.focus.offset, - ); - return selectionStart === 0 && selectionEnd === blockLength; - } - - private shouldRestoreStaleProjectedSelection( - selection: ReturnType, - ): boolean { - if ( - selection.type === "block" || - selection.anchor.blockId !== selection.focus.blockId || - selection.anchor.offset !== selection.focus.offset - ) { - return false; - } - const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( - "programmatic", - selection.anchor.blockId, - ) ?? this.fieldEditor.getBackendSelectionAuthority( - "user-dom", - selection.anchor.blockId, - ); - if (!projectedSelection) { - return false; - } - return ( - selection.anchor.offset !== projectedSelection.anchorOffset || - selection.focus.offset !== projectedSelection.focusOffset - ); - } - - private shouldRestoreSuppressedFullBlockSelection(): boolean { - if (!this.element) { - return false; - } - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) { - return false; - } - - const selection = domSelectionToEditor(root); - if (!selection) { - return false; - } - - return this.shouldRestoreStaleFullBlockSelection( - normalizeSelectionFormation(this.editor, selection), - ); - } - - // ── Clipboard events ────────────────────────────────────── - - private handleCopyEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCopy(this.editor, event); - }; - - private handleCutEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCut(this.editor, event); - }; - - private handleDragStart = (event: DragEvent): void => { - event.preventDefault(); - }; - - private handleDrop = (event: DragEvent): void => { - event.preventDefault(); - }; - - private handlePointerDown = (): void => { - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - }; -} - -// ── Direct input handlers ────────────────────────────────── - -type DirectHandler = ( - event: InputEvent, - editor: Editor, - ytext: FieldEditorTextLike, - fieldEditor: FieldEditorInputController, - element: HTMLElement, - backend: ContentEditableBackend, -) => void; - -const DIRECT_HANDLERS: Record = { - insertText: (event, editor, ytext, fe, element, backend) => { - const text = event.data ?? ""; - if (!text) return; - if (hasMultiBlockTextSelection(editor)) { - editor.replaceSelection(text); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = backend.resolveCurrentInputRange(); - if (!range) return; - if (backend.applyListInputRule({ blockId, range, text })) { - return; - } - const marks = fe.resolveInsertMarks(ytext, range.start); - backend.applyInlineTextEdit({ - blockId, - range, - text, - marks, - }); - }, - - insertReplacementText: (event, editor, ytext, fe, element, backend) => { - const text = event.data ?? ""; - if (!text) return; - if (hasMultiBlockTextSelection(editor)) { - editor.replaceSelection(text); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const targetRanges = event.getTargetRanges?.(); - const range = targetRanges?.length - ? staticRangeToOffsets(targetRanges[0], element) - : backend.resolveCurrentInputRange(); - if (!range) return; - if (backend.applyListInputRule({ blockId, range, text })) { - return; - } - const marks = fe.resolveInsertMarks(ytext, range.start); - backend.applyInlineTextEdit({ - blockId, - range, - text, - marks, - }); - }, - - deleteContentBackward: (_event, editor, ytext, fe, element, backend) => { - if (hasMultiBlockTextSelection(editor)) { - editor.deleteSelection(); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = backend.resolveCurrentInputRange(); - if (!range) return; - - const target = applyDeleteBehavior(editor, { - blockId, - ytext, - range, - direction: "backward", - }); - if (target) { - if (target.selectBlock) { - fe.deactivate(); - editor.selectBlock(target.blockId); - } else { - fe.activateTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - } - return; - } - - if (range.start !== range.end) { - backend.applyInlineTextEdit({ - blockId, - range, - text: "", - }); - return; - } - - if (range.start > 0) { - backend.applyInlineTextEdit({ - blockId, - range: { start: range.start - 1, end: range.start }, - text: "", - }); - } - }, - - deleteContentForward: (_event, editor, ytext, fe, element, backend) => { - if (hasMultiBlockTextSelection(editor)) { - editor.deleteSelection(); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = backend.resolveCurrentInputRange(); - if (!range) return; - - const target = applyDeleteBehavior(editor, { - blockId, - ytext, - range, - direction: "forward", - }); - if (target) { - if (target.selectBlock) { - fe.deactivate(); - editor.selectBlock(target.blockId); - } else { - fe.activateTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - } - return; - } - - if (range.start < ytext.length) { - backend.applyInlineTextEdit({ - blockId, - range: { start: range.start, end: range.start + 1 }, - text: "", - }); - } - }, - - deleteByCut: (_event, editor, _ytext, fe, element, backend) => { - if (hasMultiBlockTextSelection(editor)) { - editor.deleteSelection(); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = backend.resolveCurrentInputRange(); - if (!range || range.start === range.end) return; - - backend.applyInlineTextEdit({ - blockId, - range, - text: "", - }); - }, - - deleteWordBackward: (_event, editor, ytext, fe, element, backend) => { - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = backend.resolveCurrentInputRange(); - if (!range) return; - - if (range.start !== range.end) { - backend.applyInlineTextEdit({ - blockId, - range, - text: "", - }); - return; - } - - const text = ytext.toString(); - let pos = range.start; - while (pos > 0 && /\s/.test(text[pos - 1])) pos--; - while (pos > 0 && !/\s/.test(text[pos - 1])) pos--; - if (pos < range.start) { - backend.applyInlineTextEdit({ - blockId, - range: { start: pos, end: range.start }, - text: "", - }); - } - }, - - deleteWordForward: (_event, editor, ytext, fe, element, backend) => { - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = backend.resolveCurrentInputRange(); - if (!range) return; - - if (range.start !== range.end) { - backend.applyInlineTextEdit({ - blockId, - range, - text: "", - }); - return; - } - - const text = ytext.toString(); - let pos = range.end; - while (pos < text.length && /\s/.test(text[pos])) pos++; - while (pos < text.length && !/\s/.test(text[pos])) pos++; - if (pos > range.end) { - backend.applyInlineTextEdit({ - blockId, - range: { start: range.end, end: pos }, - text: "", - }); - } - }, - - insertParagraph: (_event, editor, ytext, fe, element, backend) => { - const blockId = fe.focusBlockId; - if (!blockId) return; - const target = applyEnterBehavior(editor, { - blockId, - inputMode: fe.inputMode, - ytext, - range: backend.resolveCurrentInputRange(), - }); - if (!target) return; - - fe.activateTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - }, - - insertLineBreak: (_event, _editor, ytext, fe, element, backend) => { - const range = backend.resolveCurrentInputRange(); - if (!range) return; - const blockId = fe.focusBlockId; - if (!blockId) return; - backend.applyInlineTextEdit({ - blockId, - range, - text: "\n", - marks: fe.resolveInsertMarks(ytext, range.start), - }); - }, - - historyUndo: (_event, editor) => { - editor.undoManager.undo(); - }, - - historyRedo: (_event, editor) => { - editor.undoManager.redo(); - }, - - insertFromPaste: (event, editor, _ytext, fe) => { - const importers = - editor.internals.getSlot("paste:importers"); - handlePaste(event, editor, fe, importers ?? undefined); - }, - - formatBold: (_event, editor) => { - toggleInlineMark(editor, "bold"); - }, - - formatItalic: (_event, editor) => { - toggleInlineMark(editor, "italic"); - }, - - formatUnderline: (_event, editor) => { - toggleInlineMark(editor, "underline"); - }, - - formatStrikeThrough: (_event, editor) => { - toggleInlineMark(editor, "strikethrough"); - }, -}; - -function hasMultiBlockTextSelection(editor: Editor): boolean { - const selection = editor.selection; - return selection?.type === "text" && selection.isMultiBlock; -} - -function requiresResolvedInputRange(inputType: string): boolean { - return ( - inputType === "insertText" || - inputType === "insertReplacementText" || - inputType === "deleteContentBackward" || - inputType === "deleteContentForward" || - inputType === "deleteByCut" || - inputType === "deleteWordBackward" || - inputType === "deleteWordForward" || - inputType === "insertLineBreak" - ); -} - -function canResolveInputRange( - event: InputEvent, - element: HTMLElement, -): boolean { - if (event.inputType === "insertReplacementText") { - const targetRanges = event.getTargetRanges?.(); - if (targetRanges?.length) { - return staticRangeToOffsets(targetRanges[0], element) !== null; - } - } - - return getSelectionOffsets(element) !== null; -} - -/** - * Convert a StaticRange (from getTargetRanges) to character offsets - * within the inline content element. - */ -function staticRangeToOffsets( - staticRange: StaticRange, - element: HTMLElement, -): { start: number; end: number } | null { - if ( - (staticRange.startContainer !== element && - !element.contains(staticRange.startContainer)) || - (staticRange.endContainer !== element && - !element.contains(staticRange.endContainer)) - ) { - return null; - } - - const startOffset = domPointToOffset( - element, - staticRange.startContainer, - staticRange.startOffset, - ); - const endOffset = domPointToOffset( - element, - staticRange.endContainer, - staticRange.endOffset, - ); - - return { - start: Math.min(startOffset, endOffset), - end: Math.max(startOffset, endOffset), - }; -} - -function setSelectionOffsets( - element: HTMLElement, - startOffset: number, - endOffset: number, -): void { - const selection = element.ownerDocument?.getSelection(); - if (!selection) return; - - const startPoint = resolveDomPointForOffset(element, startOffset); - const endPoint = resolveDomPointForOffset(element, endOffset); - if (!startPoint || !endPoint) return; - - selection.removeAllRanges(); - - const setBaseAndExtent = ( - selection as Selection & { - setBaseAndExtent?: ( - anchorNode: Node, - anchorOffset: number, - focusNode: Node, - focusOffset: number, - ) => void; - } - ).setBaseAndExtent; - if (typeof setBaseAndExtent === "function") { - try { - setBaseAndExtent.call( - selection, - startPoint.node, - startPoint.offset, - endPoint.node, - endPoint.offset, - ); - return; - } catch { - // Fall back to the range-based path in non-browser test environments. - } - } - - const collapseRange = element.ownerDocument.createRange(); - collapseRange.setStart(startPoint.node, startPoint.offset); - collapseRange.collapse(true); - selection.addRange(collapseRange); - - if ( - (startPoint.node !== endPoint.node || - startPoint.offset !== endPoint.offset) && - typeof selection.extend === "function" - ) { - selection.extend(endPoint.node, endPoint.offset); - return; - } - - selection.removeAllRanges(); - const range = element.ownerDocument.createRange(); - range.setStart(startPoint.node, startPoint.offset); - range.setEnd(endPoint.node, endPoint.offset); - selection.addRange(range); -} - -function resolveDomPointForOffset( - element: HTMLElement, - targetOffset: number, -): { node: Node; offset: number } | null { - const walker = element.ownerDocument.createTreeWalker( - element, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = Math.max(0, targetOffset); - let textNode = walker.nextNode() as Text | null; - - while (textNode) { - const length = textNode.textContent?.length ?? 0; - if (remaining <= length) { - return { node: textNode, offset: remaining }; - } - remaining -= length; - textNode = walker.nextNode() as Text | null; - } - - if (element.lastChild) { - if (element.lastChild.nodeType === Node.TEXT_NODE) { - const textLength = element.lastChild.textContent?.length ?? 0; - return { - node: element.lastChild, - offset: textLength, - }; - } - const childCount = element.lastChild.childNodes.length; - return { node: element.lastChild, offset: childCount }; - } - - return { node: element, offset: 0 }; -} - -function rebaseTextDiffOps( - ops: Array< - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number } - >, - deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, -): Array< - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number } -> { - if (deferredRemoteDeltas.length === 0 || ops.length === 0) { - return ops; - } - - return ops - .map((op) => { - if (op.type === "insert") { - return { - type: "insert" as const, - offset: mapOffsetThroughRemoteDeltas( - op.offset, - deferredRemoteDeltas, - ), - text: op.text, - }; - } - - const start = mapOffsetThroughRemoteDeltas( - op.offset, - deferredRemoteDeltas, - ); - const end = mapOffsetThroughRemoteDeltas( - op.offset + op.length, - deferredRemoteDeltas, - ); - return { - type: "delete" as const, - offset: start, - length: Math.max(0, end - start), - }; - }) - .filter((op) => { - if (op.type === "insert") { - return true; - } - return op.length > 0; - }); -} - -function mapOffsetThroughRemoteDeltas( - originalOffset: number, - deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, -): number { - let mappedOffset = originalOffset; - - for (const { delta } of deferredRemoteDeltas) { - let cursor = 0; - for (const part of delta) { - if (part.retain != null) { - cursor += part.retain; - continue; - } - - if (part.delete != null) { - if (cursor < mappedOffset) { - const deletedBeforeOffset = Math.min( - part.delete, - mappedOffset - cursor, - ); - mappedOffset -= deletedBeforeOffset; - } - continue; - } - - if (part.insert != null) { - const insertedLength = - typeof part.insert === "string" ? part.insert.length : 1; - if (cursor <= mappedOffset) { - mappedOffset += insertedLength; - } - cursor += insertedLength; - } - } - } - - return mappedOffset; -} - -function isNavigationSelectionKey(event: KeyboardEvent): boolean { - return ( - event.key === "ArrowLeft" || - event.key === "ArrowRight" || - event.key === "ArrowUp" || - event.key === "ArrowDown" || - event.key === "Home" || - event.key === "End" || - event.key === "PageUp" || - event.key === "PageDown" - ); -} +export class ContentEditableBackend extends ContentEditableBackendSelection {} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts b/packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts new file mode 100644 index 0000000..bfa2b61 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts @@ -0,0 +1,316 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + computeTextDiff, + domPointToOffset, + domSelectionToEditor, + editorSelectionToDOM, + extractTextFromDOM, + getSelectionOffsets, +} from "./selectionBridge"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import type { PasteImporters } from "../types/paste"; +import { handlePaste, handleCopy, handleCut } from "./clipboard"; +import { + applyListInputRule, + applyDeleteBehavior, + applyEnterBehavior, + toggleInlineMark, +} from "./commands"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import type { InlineTextDiffOp } from "./inlineTextTransaction"; +import { + applyInlineTextDiffInput, + applyInlineTextInput, +} from "./textInputPipeline"; +import type { + FieldEditorDelta, + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { setSelectionOffsets } from "./contenteditableDomHelpers"; + +export abstract class ContentEditableBackendCore { + protected element: HTMLElement | null = null; + protected ytext: FieldEditorTextLike | null = null; + protected observer: FieldEditorObserver | null = null; + protected mutationObserver: MutationObserver | null = null; + protected isComposing = false; + protected compositionStartTimestamp = 0; + protected compositionStartText: string | null = null; + protected deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }> = []; + protected pendingDomSyncFrame: number | null = null; + protected editor: Editor; + protected fieldEditor: FieldEditorInputController; + + constructor(editor: Editor, fieldEditor: FieldEditorInputController) { + this.editor = editor; + this.fieldEditor = fieldEditor; + } + + activate(element: HTMLElement, ytext: unknown): void { + this.element = element; + this.ytext = ytext as FieldEditorTextLike; + + element.contentEditable = "true"; + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + this.isComposing = false; + this.compositionStartText = null; + this.fieldEditor.setComposing(false); + + element.addEventListener("beforeinput", this.handleBeforeInput); + element.addEventListener( + "compositionstart", + this.handleCompositionStart, + ); + element.addEventListener("compositionend", this.handleCompositionEnd); + element.addEventListener("keydown", this.handleKeyDown); + element.addEventListener("copy", this.handleCopyEvent); + element.addEventListener("cut", this.handleCutEvent); + element.addEventListener("dragstart", this.handleDragStart); + element.addEventListener("drop", this.handleDrop); + element.addEventListener("pointerdown", this.handlePointerDown); + element.ownerDocument?.addEventListener( + "selectionchange", + this.handleSelectionChange, + ); + + this.mutationObserver = new MutationObserver(this.handleMutations); + this.mutationObserver.observe(element, { + childList: true, + subtree: true, + characterData: true, + characterDataOldValue: true, + }); + + this.observer = (event) => this.handleYTextChange(event); + this.ytext.observe(this.observer); + + fullReconcileToDOM(this.ytext, element, this.editor.schema, { + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.restoreDOMSelectionFromEditor(); + } + + deactivate(): void { + if (this.element) { + this.element.contentEditable = "false"; + this.element.removeEventListener( + "beforeinput", + this.handleBeforeInput, + ); + this.element.removeEventListener( + "compositionstart", + this.handleCompositionStart, + ); + this.element.removeEventListener( + "compositionend", + this.handleCompositionEnd, + ); + this.element.removeEventListener("keydown", this.handleKeyDown); + this.element.removeEventListener("copy", this.handleCopyEvent); + this.element.removeEventListener("cut", this.handleCutEvent); + this.element.removeEventListener("dragstart", this.handleDragStart); + this.element.removeEventListener("drop", this.handleDrop); + this.element.removeEventListener( + "pointerdown", + this.handlePointerDown, + ); + this.element.ownerDocument?.removeEventListener( + "selectionchange", + this.handleSelectionChange, + ); + } + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + if (this.pendingDomSyncFrame != null) { + cancelAnimationFrame(this.pendingDomSyncFrame); + this.pendingDomSyncFrame = null; + } + if (this.observer && this.ytext) { + this.ytext.unobserve(this.observer); + } + this.element = null; + this.ytext = null; + this.observer = null; + this.deferredRemoteDeltas = []; + this.fieldEditor.resetBackendSelectionAuthority(); + this.isComposing = false; + this.compositionStartText = null; + this.fieldEditor.setComposing(false); + } + + updateSelection(_relPos: unknown): void { + this.restoreDOMSelectionFromEditor(); + } + + protected _getActiveCellCoord(blockId: string): { + blockId: string; + row: number; + col: number; + } | null { + const coord = this.fieldEditor.activeCellCoord; + if (!coord || coord.blockId !== blockId) { + return null; + } + return coord; + } + + applyInlineTextEdit(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + marks?: Record; + }): void { + const { blockId, range, text, marks } = options; + const cellCoord = this._getActiveCellCoord(blockId); + applyInlineTextInput({ + editor: this.editor, + fieldEditor: this.fieldEditor, + blockId, + range, + text, + marks, + cellCoord, + }); + this.ensureActiveDOMMatchesYText(); + this.restoreDOMSelectionFromEditor(); + this.scheduleActiveDOMMatchCheck(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } + + applyListInputRule(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + }): boolean { + const target = applyListInputRule(this.editor, options); + if (!target) return false; + + this.fieldEditor.setBackendSelectionAuthority("programmatic", { + blockId: target.blockId, + anchorOffset: target.anchorOffset, + focusOffset: target.focusOffset, + }); + + this.fieldEditor.syncTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + this.restoreDOMSelectionFromEditor(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + return true; + } + + restoreDOMSelectionFromEditor(): void { + if (!this.element) return; + + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + const selection = this.editor.selection; + + const pendingSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + blockId, + ); + const activeCell = this._getActiveCellCoord(blockId); + if ( + activeCell && + (!pendingSelection || + (pendingSelection.cell?.row === activeCell.row && + pendingSelection.cell?.col === activeCell.col)) + ) { + const activeSelection = + pendingSelection ?? + this.fieldEditor.getBackendSelectionAuthority("cell", blockId) ?? + (selection?.type === "text" && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ? { + anchorOffset: selection.anchor.offset, + focusOffset: selection.focus.offset, + } + : null); + if (!activeSelection) return; + const start = activeSelection.anchorOffset; + const end = activeSelection.focusOffset; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + setSelectionOffsets(this.element, start, end); + return; + } + const anchor = + pendingSelection != null + ? { + blockId: pendingSelection.blockId, + offset: pendingSelection.anchorOffset, + } + : selection?.type === "text" + ? selection.anchor + : null; + const focus = + pendingSelection != null + ? { + blockId: pendingSelection.blockId, + offset: pendingSelection.focusOffset, + } + : selection?.type === "text" + ? selection.focus + : null; + + if (!anchor || !focus) return; + if (anchor.blockId !== blockId || focus.blockId !== blockId) { + return; + } + if ( + pendingSelection == null && + anchor.offset === focus.offset && + selection?.type === "text" && + selection.isCollapsed + ) { + this.fieldEditor.setBackendSelectionAuthority("programmatic", { + blockId, + anchorOffset: anchor.offset, + focusOffset: focus.offset, + }); + } + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + editorSelectionToDOM(root, anchor, focus); + } + + protected abstract handleBeforeInput: (event: InputEvent) => void; + protected abstract handleCompositionStart: () => void; + protected abstract handleCompositionEnd: () => void; + protected abstract handleKeyDown: (event: KeyboardEvent) => void; + protected abstract handleCopyEvent: (event: ClipboardEvent) => void; + protected abstract handleCutEvent: (event: ClipboardEvent) => void; + protected abstract handleDragStart: (event: DragEvent) => void; + protected abstract handleDrop: (event: DragEvent) => void; + protected abstract handlePointerDown: () => void; + protected abstract handleSelectionChange: () => void; + protected abstract handleMutations: (mutations: MutationRecord[]) => void; + protected abstract handleYTextChange(event: FieldEditorTextChangeEvent): void; + abstract resolveCurrentInputRange(): { start: number; end: number } | null; + protected abstract applyTextDiffAsOps( + blockId: string, + diff: InlineTextDiffOp[], + ): void; + protected abstract ensureActiveDOMMatchesYText(): boolean; + protected abstract scheduleActiveDOMMatchCheck(): void; + protected abstract getInlineDecorationsForBlock(): readonly InlineDecoration[]; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts b/packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts new file mode 100644 index 0000000..e5bcb3a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts @@ -0,0 +1,212 @@ +import type { InlineDecoration } from "@pen/types"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { computeTextDiff, extractTextFromDOM } from "./selectionBridge"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import type { FieldEditorTextChangeEvent } from "./crdt"; +import type { InlineTextDiffOp } from "./inlineTextTransaction"; +import { ContentEditableBackendCore } from "./contenteditableBackendCore"; +import { DIRECT_HANDLERS } from "./contenteditableDirectHandlers"; +import { + canResolveInputRange, + rebaseTextDiffOps, + requiresResolvedInputRange, +} from "./contenteditableDomHelpers"; + +export abstract class ContentEditableBackendEvents extends ContentEditableBackendCore { + protected handleBeforeInput = (event: InputEvent): void => { + if (this.isComposing) return; + if (!this.ytext || !this.element) return; + + const blockId = this.fieldEditor.focusBlockId; + if (!blockId || !this.editor.getBlock(blockId)) { + this.fieldEditor.deactivate(); + return; + } + + const handler = DIRECT_HANDLERS[event.inputType]; + if (handler) { + if ( + requiresResolvedInputRange(event.inputType) && + !this.ensureResolvableInputRange(event) + ) { + return; + } + + event.preventDefault(); + handler( + event, + this.editor, + this.ytext, + this.fieldEditor, + this.element, + this, + ); + return; + } + + // Let the mutation observer reconcile input types we do not handle directly. + }; + + protected ensureResolvableInputRange(event: InputEvent): boolean { + if (!this.element) { + return false; + } + if (canResolveInputRange(event, this.element)) { + return true; + } + + this.restoreDOMSelectionFromEditor(); + + return canResolveInputRange(event, this.element); + } + + // ── Composition handling ────────────────────────────────── + + protected handleCompositionStart = (): void => { + this.isComposing = true; + this.compositionStartTimestamp = Date.now(); + this.compositionStartText = this.ytext?.toString() ?? ""; + this.deferredRemoteDeltas = []; + this.fieldEditor.setComposing(true); + }; + + protected handleCompositionEnd = (): void => { + this.isComposing = false; + this.fieldEditor.setComposing(false); + + const elapsed = Date.now() - this.compositionStartTimestamp; + + // GBoard rapid composition optimization: skip full diff for single-char + // compositions under 50ms — treat as direct insert. + if (elapsed < 50 && this.element) { + const domText = extractTextFromDOM(this.element); + const crdtText = this.ytext?.toString() ?? ""; + if (Math.abs(domText.length - crdtText.length) <= 1) { + this.reconcileAfterComposition(); + return; + } + } + + // Safari may fire compositionend before the final DOM mutation. + requestAnimationFrame(() => { + if (this.isComposing) return; + this.reconcileAfterComposition(); + }); + }; + + protected reconcileAfterComposition(): void { + if (!this.element || !this.ytext) return; + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + + const domText = extractTextFromDOM(this.element); + const baseText = this.compositionStartText ?? this.ytext.toString(); + + if (domText !== baseText) { + const diff = rebaseTextDiffOps( + computeTextDiff(baseText, domText), + this.deferredRemoteDeltas, + ); + this.applyTextDiffAsOps(blockId, diff); + } + + if (this.deferredRemoteDeltas.length > 0) { + this.deferredRemoteDeltas = []; + fullReconcileToDOM(this.ytext, this.element!, this.editor.schema, { + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + } + + this.compositionStartText = null; + this.restoreDOMSelectionFromEditor(); + } + + // ── Mutation observation fallback ───────────────────────── + + protected handleMutations = (_mutations: MutationRecord[]): void => { + if (this.isComposing) return; + if (!this.element || !this.ytext) return; + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + + const domText = extractTextFromDOM(this.element); + const crdtText = this.ytext.toString(); + + if (domText !== crdtText) { + const diff = computeTextDiff(crdtText, domText); + this.applyTextDiffAsOps(blockId, diff); + } + }; + + // ── CRDT→DOM reconciliation ─────────────────────────────── + + protected handleYTextChange = (event: FieldEditorTextChangeEvent): void => { + if (this.isComposing) { + if ( + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.deferredRemoteDeltas.push({ delta: event.delta }); + } + return; + } + + if (!this.element || !this.ytext) return; + const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); + if (isHistory) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.restoreDOMSelectionFromEditor(); + return; + } + + const blockId = this.fieldEditor.focusBlockId; + const isActiveCell = blockId + ? !!this._getActiveCellCoord(blockId) + : false; + if (isActiveCell) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + if ( + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.restoreDOMSelectionFromEditor(); + } + return; + } + + const applied = applyDeltaToDOM( + event.delta, + this.element, + this.editor.schema, + ); + if (!applied) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + } + + if ( + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.restoreDOMSelectionFromEditor(); + } + }; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts b/packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts new file mode 100644 index 0000000..c07e268 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts @@ -0,0 +1,338 @@ +import type { InlineDecoration } from "@pen/types"; +import { fullReconcileToDOM } from "./reconciler"; +import { + domSelectionToEditor, + extractTextFromDOM, + getSelectionOffsets, +} from "./selectionBridge"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleCopy, handleCut } from "./clipboard"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import type { InlineTextDiffOp } from "./inlineTextTransaction"; +import { applyInlineTextDiffInput } from "./textInputPipeline"; +import { ContentEditableBackendEvents } from "./contenteditableBackendEvents"; +import { + isNavigationSelectionKey, + setSelectionOffsets, +} from "./contenteditableDomHelpers"; + +export class ContentEditableBackendSelection extends ContentEditableBackendEvents { + protected applyTextDiffAsOps( + blockId: string, + diff: InlineTextDiffOp[], + ): void { + if (diff.length === 0) return; + const ytext = this.ytext; + if (!ytext) return; + + const cellCoord = this._getActiveCellCoord(blockId); + const range = this.element ? getSelectionOffsets(this.element) : null; + const selection = range + ? { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + cell: cellCoord + ? { row: cellCoord.row, col: cellCoord.col } + : undefined, + } + : null; + const result = applyInlineTextDiffInput({ + editor: this.editor, + fieldEditor: this.fieldEditor, + blockId, + diff, + ytext, + selection, + cellCoord, + }); + if (!result.applied) return; + this.ensureActiveDOMMatchesYText(); + this.restoreDOMSelectionFromEditor(); + this.scheduleActiveDOMMatchCheck(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } + + protected ensureActiveDOMMatchesYText(): boolean { + if (!this.element || !this.ytext) return false; + if (extractTextFromDOM(this.element) === this.ytext.toString()) { + return false; + } + + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + return true; + } + + protected scheduleActiveDOMMatchCheck(): void { + if (this.pendingDomSyncFrame != null) { + cancelAnimationFrame(this.pendingDomSyncFrame); + } + + this.pendingDomSyncFrame = requestAnimationFrame(() => { + this.pendingDomSyncFrame = null; + if (this.ensureActiveDOMMatchesYText()) { + this.restoreDOMSelectionFromEditor(); + } + }); + } + + protected getInlineDecorationsForBlock(): readonly InlineDecoration[] { + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) { + return []; + } + return this.editor + .getDecorations() + .forBlock(blockId) + .filter( + (decoration): decoration is InlineDecoration => + decoration.type === "inline", + ); + } + + // ── Keyboard shortcuts ──────────────────────────────────── + + protected handleKeyDown = (event: KeyboardEvent): void => { + if (!this.ytext) return; + if (isNavigationSelectionKey(event)) { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + this.fieldEditor.clearBackendSelectionAuthority("user-dom"); + } + + const handled = handleFieldEditorKeyDown({ + event, + editor: this.editor, + fieldEditor: this.fieldEditor, + ytext: this.ytext, + range: this.element ? getSelectionOffsets(this.element) : null, + }); + if (handled) { + event.preventDefault(); + return; + } + }; + + resolveCurrentInputRange(): { + start: number; + end: number; + } | null { + const blockId = this.fieldEditor.focusBlockId; + const liveRange = this.element + ? getSelectionOffsets(this.element) + : null; + return ( + this.fieldEditor.resolveProgrammaticInputRange( + blockId, + liveRange, + ) ?? + liveRange + ); + } + + protected handleSelectionChange = (): void => { + if (!this.element) return; + const isApplyingSelection = + this.fieldEditor.getBackendSelectionApplicationDepth(); + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + isApplyingSelection, + ) + ) { + if (this.shouldRestoreSuppressedFullBlockSelection()) { + this.restoreDOMSelectionFromEditor(); + } + return; + } + + const focusBlockId = this.fieldEditor.focusBlockId; + const activeCell = focusBlockId + ? this._getActiveCellCoord(focusBlockId) + : null; + if (activeCell) { + const range = getSelectionOffsets(this.element); + if (!range) return; + this.fieldEditor.setBackendSelectionAuthority("cell", { + blockId: activeCell.blockId, + anchorOffset: range.start, + focusOffset: range.end, + cell: { row: activeCell.row, col: activeCell.col }, + }); + return; + } + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + + const selection = domSelectionToEditor(root); + if (!selection) return; + const normalizedSelection = normalizeSelectionFormation( + this.editor, + selection, + ); + + if (this.shouldRestoreStaleFullBlockSelection(normalizedSelection)) { + this.restoreDOMSelectionFromEditor(); + return; + } + + if (this.shouldRestoreStaleProjectedSelection(normalizedSelection)) { + this.restoreDOMSelectionFromEditor(); + return; + } + + if (normalizedSelection.type === "block") { + this.fieldEditor.deactivate(); + this.editor.setSelection({ + type: "block", + blockIds: normalizedSelection.blockIds, + }); + return; + } + + if ( + this.fieldEditor.shouldIgnoreDomTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ) + ) { + this.restoreDOMSelectionFromEditor(); + return; + } + + this.fieldEditor.setBackendSelectionAuthority("user-dom", { + blockId: normalizedSelection.anchor.blockId, + anchorOffset: normalizedSelection.anchor.offset, + focusOffset: normalizedSelection.focus.offset, + }); + const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + normalizedSelection.anchor.blockId, + ); + if ( + !projectedSelection || + projectedSelection.anchorOffset !== normalizedSelection.anchor.offset || + projectedSelection.focusOffset !== normalizedSelection.focus.offset + ) { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } + this.fieldEditor.applyDomTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ); + }; + + protected shouldRestoreStaleFullBlockSelection( + selection: ReturnType, + ): boolean { + if (selection.type === "block") { + return false; + } + if (selection.anchor.blockId !== selection.focus.blockId) { + return false; + } + + const currentSelection = this.fieldEditor.selection; + if ( + currentSelection?.type !== "text" || + !currentSelection.isCollapsed || + currentSelection.focus.blockId !== selection.anchor.blockId + ) { + return false; + } + + const block = this.editor.getBlock(selection.anchor.blockId); + const blockLength = block?.length() ?? null; + if (blockLength == null) { + return false; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + return selectionStart === 0 && selectionEnd === blockLength; + } + + protected shouldRestoreStaleProjectedSelection( + selection: ReturnType, + ): boolean { + if ( + selection.type === "block" || + selection.anchor.blockId !== selection.focus.blockId || + selection.anchor.offset !== selection.focus.offset + ) { + return false; + } + const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + selection.anchor.blockId, + ) ?? this.fieldEditor.getBackendSelectionAuthority( + "user-dom", + selection.anchor.blockId, + ); + if (!projectedSelection) { + return false; + } + return ( + selection.anchor.offset !== projectedSelection.anchorOffset || + selection.focus.offset !== projectedSelection.focusOffset + ); + } + + protected shouldRestoreSuppressedFullBlockSelection(): boolean { + if (!this.element) { + return false; + } + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) { + return false; + } + + const selection = domSelectionToEditor(root); + if (!selection) { + return false; + } + + return this.shouldRestoreStaleFullBlockSelection( + normalizeSelectionFormation(this.editor, selection), + ); + } + + // ── Clipboard events ────────────────────────────────────── + + protected handleCopyEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCopy(this.editor, event); + }; + + protected handleCutEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCut(this.editor, event); + }; + + protected handleDragStart = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handleDrop = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handlePointerDown = (): void => { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + }; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts b/packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts new file mode 100644 index 0000000..1bd2514 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts @@ -0,0 +1,312 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import type { PasteImporters } from "../types/paste"; +import { + applyDeleteBehavior, + applyEnterBehavior, + toggleInlineMark, +} from "./commands"; +import { handlePaste } from "./clipboard"; +import { staticRangeToOffsets } from "./contenteditableDomHelpers"; + +export interface ContentEditableDirectInputBackend { + resolveCurrentInputRange(): { start: number; end: number } | null; + applyListInputRule(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + }): boolean; + applyInlineTextEdit(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + marks?: Record; + }): void; +} + +export type DirectHandler = ( + event: InputEvent, + editor: Editor, + ytext: FieldEditorTextLike, + fieldEditor: FieldEditorInputController, + element: HTMLElement, + backend: ContentEditableDirectInputBackend, +) => void; + +export const DIRECT_HANDLERS: Record = { + insertText: (event, editor, ytext, fe, element, backend) => { + const text = event.data ?? ""; + if (!text) return; + if (hasMultiBlockTextSelection(editor)) { + editor.replaceSelection(text); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + if (backend.applyListInputRule({ blockId, range, text })) { + return; + } + const marks = fe.resolveInsertMarks(ytext, range.start); + backend.applyInlineTextEdit({ + blockId, + range, + text, + marks, + }); + }, + + insertReplacementText: (event, editor, ytext, fe, element, backend) => { + const text = event.data ?? ""; + if (!text) return; + if (hasMultiBlockTextSelection(editor)) { + editor.replaceSelection(text); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const targetRanges = event.getTargetRanges?.(); + const range = targetRanges?.length + ? staticRangeToOffsets(targetRanges[0], element) + : backend.resolveCurrentInputRange(); + if (!range) return; + if (backend.applyListInputRule({ blockId, range, text })) { + return; + } + const marks = fe.resolveInsertMarks(ytext, range.start); + backend.applyInlineTextEdit({ + blockId, + range, + text, + marks, + }); + }, + + deleteContentBackward: (_event, editor, ytext, fe, element, backend) => { + if (hasMultiBlockTextSelection(editor)) { + editor.deleteSelection(); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + const target = applyDeleteBehavior(editor, { + blockId, + ytext, + range, + direction: "backward", + }); + if (target) { + if (target.selectBlock) { + fe.deactivate(); + editor.selectBlock(target.blockId); + } else { + fe.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + } + return; + } + + if (range.start !== range.end) { + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + return; + } + + if (range.start > 0) { + backend.applyInlineTextEdit({ + blockId, + range: { start: range.start - 1, end: range.start }, + text: "", + }); + } + }, + + deleteContentForward: (_event, editor, ytext, fe, element, backend) => { + if (hasMultiBlockTextSelection(editor)) { + editor.deleteSelection(); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + const target = applyDeleteBehavior(editor, { + blockId, + ytext, + range, + direction: "forward", + }); + if (target) { + if (target.selectBlock) { + fe.deactivate(); + editor.selectBlock(target.blockId); + } else { + fe.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + } + return; + } + + if (range.start < ytext.length) { + backend.applyInlineTextEdit({ + blockId, + range: { start: range.start, end: range.start + 1 }, + text: "", + }); + } + }, + + deleteByCut: (_event, editor, _ytext, fe, element, backend) => { + if (hasMultiBlockTextSelection(editor)) { + editor.deleteSelection(); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range || range.start === range.end) return; + + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + }, + + deleteWordBackward: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + if (range.start !== range.end) { + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + return; + } + + const text = ytext.toString(); + let pos = range.start; + while (pos > 0 && /\s/.test(text[pos - 1])) pos--; + while (pos > 0 && !/\s/.test(text[pos - 1])) pos--; + if (pos < range.start) { + backend.applyInlineTextEdit({ + blockId, + range: { start: pos, end: range.start }, + text: "", + }); + } + }, + + deleteWordForward: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + if (range.start !== range.end) { + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + return; + } + + const text = ytext.toString(); + let pos = range.end; + while (pos < text.length && /\s/.test(text[pos])) pos++; + while (pos < text.length && !/\s/.test(text[pos])) pos++; + if (pos > range.end) { + backend.applyInlineTextEdit({ + blockId, + range: { start: range.end, end: pos }, + text: "", + }); + } + }, + + insertParagraph: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; + const target = applyEnterBehavior(editor, { + blockId, + inputMode: fe.inputMode, + ytext, + range: backend.resolveCurrentInputRange(), + }); + if (!target) return; + + fe.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + }, + + insertLineBreak: (_event, _editor, ytext, fe, element, backend) => { + const range = backend.resolveCurrentInputRange(); + if (!range) return; + const blockId = fe.focusBlockId; + if (!blockId) return; + backend.applyInlineTextEdit({ + blockId, + range, + text: "\n", + marks: fe.resolveInsertMarks(ytext, range.start), + }); + }, + + historyUndo: (_event, editor) => { + editor.undoManager.undo(); + }, + + historyRedo: (_event, editor) => { + editor.undoManager.redo(); + }, + + insertFromPaste: (event, editor, _ytext, fe) => { + const importers = + editor.internals.getSlot("paste:importers"); + handlePaste(event, editor, fe, importers ?? undefined); + }, + + formatBold: (_event, editor) => { + toggleInlineMark(editor, "bold"); + }, + + formatItalic: (_event, editor) => { + toggleInlineMark(editor, "italic"); + }, + + formatUnderline: (_event, editor) => { + toggleInlineMark(editor, "underline"); + }, + + formatStrikeThrough: (_event, editor) => { + toggleInlineMark(editor, "strikethrough"); + }, +}; + +function hasMultiBlockTextSelection(editor: Editor): boolean { + const selection = editor.selection; + return selection?.type === "text" && selection.isMultiBlock; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts b/packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts new file mode 100644 index 0000000..7bd2d4e --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts @@ -0,0 +1,261 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorDelta } from "./crdt"; +import { domPointToOffset, getSelectionOffsets } from "./selectionBridge"; + +export function requiresResolvedInputRange(inputType: string): boolean { + return ( + inputType === "insertText" || + inputType === "insertReplacementText" || + inputType === "deleteContentBackward" || + inputType === "deleteContentForward" || + inputType === "deleteByCut" || + inputType === "deleteWordBackward" || + inputType === "deleteWordForward" || + inputType === "insertLineBreak" + ); +} + +export function canResolveInputRange( + event: InputEvent, + element: HTMLElement, +): boolean { + if (event.inputType === "insertReplacementText") { + const targetRanges = event.getTargetRanges?.(); + if (targetRanges?.length) { + return staticRangeToOffsets(targetRanges[0], element) !== null; + } + } + + return getSelectionOffsets(element) !== null; +} + +/** + * Convert a StaticRange (from getTargetRanges) to character offsets + * within the inline content element. + */ +export function staticRangeToOffsets( + staticRange: StaticRange, + element: HTMLElement, +): { start: number; end: number } | null { + if ( + (staticRange.startContainer !== element && + !element.contains(staticRange.startContainer)) || + (staticRange.endContainer !== element && + !element.contains(staticRange.endContainer)) + ) { + return null; + } + + const startOffset = domPointToOffset( + element, + staticRange.startContainer, + staticRange.startOffset, + ); + const endOffset = domPointToOffset( + element, + staticRange.endContainer, + staticRange.endOffset, + ); + + return { + start: Math.min(startOffset, endOffset), + end: Math.max(startOffset, endOffset), + }; +} + +export function setSelectionOffsets( + element: HTMLElement, + startOffset: number, + endOffset: number, +): void { + const selection = element.ownerDocument?.getSelection(); + if (!selection) return; + + const startPoint = resolveDomPointForOffset(element, startOffset); + const endPoint = resolveDomPointForOffset(element, endOffset); + if (!startPoint || !endPoint) return; + + selection.removeAllRanges(); + + const setBaseAndExtent = ( + selection as Selection & { + setBaseAndExtent?: ( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, + ) => void; + } + ).setBaseAndExtent; + if (typeof setBaseAndExtent === "function") { + try { + setBaseAndExtent.call( + selection, + startPoint.node, + startPoint.offset, + endPoint.node, + endPoint.offset, + ); + return; + } catch { + // Fall back to the range-based path in non-browser test environments. + } + } + + const collapseRange = element.ownerDocument.createRange(); + collapseRange.setStart(startPoint.node, startPoint.offset); + collapseRange.collapse(true); + selection.addRange(collapseRange); + + if ( + (startPoint.node !== endPoint.node || + startPoint.offset !== endPoint.offset) && + typeof selection.extend === "function" + ) { + selection.extend(endPoint.node, endPoint.offset); + return; + } + + selection.removeAllRanges(); + const range = element.ownerDocument.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + selection.addRange(range); +} + +function resolveDomPointForOffset( + element: HTMLElement, + targetOffset: number, +): { node: Node; offset: number } | null { + const walker = element.ownerDocument.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + ); + let remaining = Math.max(0, targetOffset); + let textNode = walker.nextNode() as Text | null; + + while (textNode) { + const length = textNode.textContent?.length ?? 0; + if (remaining <= length) { + return { node: textNode, offset: remaining }; + } + remaining -= length; + textNode = walker.nextNode() as Text | null; + } + + if (element.lastChild) { + if (element.lastChild.nodeType === Node.TEXT_NODE) { + const textLength = element.lastChild.textContent?.length ?? 0; + return { + node: element.lastChild, + offset: textLength, + }; + } + const childCount = element.lastChild.childNodes.length; + return { node: element.lastChild, offset: childCount }; + } + + return { node: element, offset: 0 }; +} + +export function rebaseTextDiffOps( + ops: Array< + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number } + >, + deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, +): Array< + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number } +> { + if (deferredRemoteDeltas.length === 0 || ops.length === 0) { + return ops; + } + + return ops + .map((op) => { + if (op.type === "insert") { + return { + type: "insert" as const, + offset: mapOffsetThroughRemoteDeltas( + op.offset, + deferredRemoteDeltas, + ), + text: op.text, + }; + } + + const start = mapOffsetThroughRemoteDeltas( + op.offset, + deferredRemoteDeltas, + ); + const end = mapOffsetThroughRemoteDeltas( + op.offset + op.length, + deferredRemoteDeltas, + ); + return { + type: "delete" as const, + offset: start, + length: Math.max(0, end - start), + }; + }) + .filter((op) => { + if (op.type === "insert") { + return true; + } + return op.length > 0; + }); +} + +function mapOffsetThroughRemoteDeltas( + originalOffset: number, + deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, +): number { + let mappedOffset = originalOffset; + + for (const { delta } of deferredRemoteDeltas) { + let cursor = 0; + for (const part of delta) { + if (part.retain != null) { + cursor += part.retain; + continue; + } + + if (part.delete != null) { + if (cursor < mappedOffset) { + const deletedBeforeOffset = Math.min( + part.delete, + mappedOffset - cursor, + ); + mappedOffset -= deletedBeforeOffset; + } + continue; + } + + if (part.insert != null) { + const insertedLength = + typeof part.insert === "string" ? part.insert.length : 1; + if (cursor <= mappedOffset) { + mappedOffset += insertedLength; + } + cursor += insertedLength; + } + } + } + + return mappedOffset; +} + +export function isNavigationSelectionKey(event: KeyboardEvent): boolean { + return ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" + ); +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index 99e4e6e..995c8f7 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -1,1278 +1,3 @@ -import { INPUT_RULES_ENGINE_SLOT_KEY } from "@pen/types"; -import type { DocumentOp, Editor, InlineDecoration } from "@pen/types"; -import { supportsInlineInputRules } from "@pen/types"; -import type { FieldEditorInputController } from "./controller"; -import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; -import { - domSelectionToEditor, - editorSelectionToDOM, - getDirectionalSelectionOffsets, -} from "./selectionBridge"; -import { - collapsedSelectionOffset, - rangesEqual, - resolveEditContextKeyDownRange, - resolveEditContextTextUpdateRange, - type DirectionalSelectionOffsets, - type EditContextRange, - type EditContextSelection, - type KeyDownRangeResolution, -} from "./editContextSelectionAuthority"; -import { normalizeSelectionFormation } from "../utils/selectionFormation"; -import { handleFieldEditorKeyDown } from "./keyHandling"; -import { isHistoryTransactionOrigin } from "./historyOrigin"; -import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; -import type { PasteImporters } from "../types/paste"; -import { applyListInputRule } from "./commands"; -import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; -import { buildInlineTextEditTransaction } from "./inlineTextTransaction"; -import type { - FieldEditorObserver, - FieldEditorTextChangeEvent, - FieldEditorTextLike, - InlineInputRuleEngine, -} from "./crdt"; -import { matchInlineInputRule } from "../utils/inlineInputRule"; +import { EditContextBackendRuntime } from "./editContextBackendRuntime"; -type EditContextTextUpdateEvent = Event & { - updateRangeStart: number; - updateRangeEnd: number; - text: string; - selectionStart?: number; - selectionEnd?: number; -}; - -type EditContextSelectionOptions = { - source?: "text-update"; -}; - -type EditContextTextFormat = { - rangeStart: number; - rangeEnd: number; - underlineStyle?: string; - underlineThickness?: string; -}; - -type EditContextTextFormatUpdateEvent = Event & { - getTextFormats?(): EditContextTextFormat[]; -}; - -type EditContextCharacterBoundsUpdateEvent = Event & { - rangeStart: number; - rangeEnd: number; -}; - -const ZERO_WIDTH_SPACE = "\u200B"; - -declare class EditContext { - constructor(options?: { - text?: string; - selectionStart?: number; - selectionEnd?: number; - }); - updateText(start: number, end: number, text: string): void; - updateSelection(start: number, end: number): void; - updateCharacterBounds(start: number, rects: DOMRect[]): void; - addEventListener(type: string, handler: (event: Event) => void): void; - removeEventListener(type: string, handler: (event: Event) => void): void; - readonly text: string; - readonly selectionStart: number; - readonly selectionEnd: number; -} - -type EditContextConstructor = typeof EditContext; -type EditContextGlobal = typeof globalThis & { - EditContext?: EditContextConstructor; -}; - -export class EditContextBackend { - private editContext: EditContext | null = null; - private element: HTMLElement | null = null; - private ytext: FieldEditorTextLike | null = null; - private observer: FieldEditorObserver | null = null; - private editor: Editor; - private fieldEditor: FieldEditorInputController; - - constructor(editor: Editor, fieldEditor: FieldEditorInputController) { - this.editor = editor; - this.fieldEditor = fieldEditor; - } - - activate(element: HTMLElement, ytext: unknown): void { - this.element = element; - this.ytext = ytext as FieldEditorTextLike; - this.fieldEditor.setComposing(false); - - const editContextConstructor = (globalThis as EditContextGlobal) - .EditContext; - if (!editContextConstructor) { - throw new Error( - "EditContext is not available in this environment.", - ); - } - - const initialText = this.ytext.toString(); - const initialEditContextText = toEditContextText(initialText); - const initialSelectionOffset = isLogicallyEmptyText(initialText) - ? 0 - : initialEditContextText.length; - this.editContext = new editContextConstructor({ - text: initialEditContextText, - selectionStart: initialSelectionOffset, - selectionEnd: initialSelectionOffset, - }); - - const ec = this.editContext!; - - ( - element as HTMLElement & { editContext: EditContext | null } - ).editContext = ec; - - element.addEventListener("keydown", this.handleKeyDown); - element.addEventListener("copy", this.handleCopyEvent); - element.addEventListener("cut", this.handleCutEvent); - element.addEventListener("paste", this.handlePasteEvent); - element.addEventListener("dragstart", this.handleDragStart); - element.addEventListener("drop", this.handleDrop); - element.addEventListener("pointerdown", this.handlePointerDown); - ec.addEventListener("textupdate", this.handleTextUpdate); - ec.addEventListener("textformatupdate", this.handleTextFormatUpdate); - ec.addEventListener( - "characterboundsupdate", - this.handleCharacterBoundsUpdate, - ); - element.ownerDocument?.addEventListener( - "selectionchange", - this.handleSelectionChange, - ); - - this.observer = (event) => this.handleYTextChange(event); - this.ytext.observe(this.observer); - - fullReconcileToDOM(this.ytext, element, this.editor.schema, { - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled( - this.fieldEditor.focusBlockId ?? undefined, - ); - this.fieldEditor.resetBackendSelectionAuthority(); - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - this.updateSelection(); - this.fieldEditor.requestDomFocus(element, "backend-activate", { - preventScroll: true, - }); - } - - deactivate(): void { - if (this.editContext) { - this.editContext.removeEventListener( - "textupdate", - this.handleTextUpdate, - ); - this.editContext.removeEventListener( - "textformatupdate", - this.handleTextFormatUpdate, - ); - this.editContext.removeEventListener( - "characterboundsupdate", - this.handleCharacterBoundsUpdate, - ); - } - if (this.observer && this.ytext) { - this.ytext.unobserve(this.observer); - } - if (this.element) { - this.element.removeEventListener("keydown", this.handleKeyDown); - this.element.removeEventListener("copy", this.handleCopyEvent); - this.element.removeEventListener("cut", this.handleCutEvent); - this.element.removeEventListener("paste", this.handlePasteEvent); - this.element.removeEventListener("dragstart", this.handleDragStart); - this.element.removeEventListener("drop", this.handleDrop); - this.element.removeEventListener( - "pointerdown", - this.handlePointerDown, - ); - this.element.ownerDocument?.removeEventListener( - "selectionchange", - this.handleSelectionChange, - ); - ( - this.element as HTMLElement & { - editContext: EditContext | null; - } - ).editContext = null; - } - this.editContext = null; - this.element = null; - this.ytext = null; - this.observer = null; - this.fieldEditor.resetBackendSelectionAuthority(); - this.fieldEditor.setComposing(false); - } - - updateSelection(): void { - if (!this.editContext || !this.ytext) return; - - const selection = this.fieldEditor.selection; - const blockId = this.fieldEditor.focusBlockId; - if ( - selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ) { - const anchorOffset = this.resolveEditContextOffset( - selection.anchor.offset, - ); - const focusOffset = this.resolveEditContextOffset( - selection.focus.offset, - ); - this.setEditContextSelection({ - blockId, - anchorOffset, - focusOffset, - }); - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - this.projectDOMSelection(blockId, anchorOffset, focusOffset); - return; - } - - const len = isLogicallyEmptyText(this.ytext.toString()) - ? 0 - : this.ytext.length; - this.editContext.updateSelection(len, len); - this.fieldEditor.setEditContextSelectionSnapshot( - blockId - ? { - blockId, - anchorOffset: len, - focusOffset: len, - } - : null, - ); - } - - private projectDOMSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - if (!this.element) return; - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - editorSelectionToDOM( - root, - { blockId, offset: anchorOffset }, - { blockId, offset: focusOffset }, - ); - } - - private handleTextUpdate = (event: Event): void => { - if (!this.ytext) return; - const { - updateRangeStart, - updateRangeEnd, - text, - selectionStart, - selectionEnd, - } = event as EditContextTextUpdateEvent; - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - - const block = this.editor.getBlock(blockId); - if (!block) { - this.fieldEditor.deactivate(); - return; - } - - const resolvedTextUpdate = this.resolveTextUpdateRange({ - blockId, - updateRangeStart, - updateRangeEnd, - text, - selectionStart, - selectionEnd, - }); - const { range } = resolvedTextUpdate; - const listInputRuleTarget = applyListInputRule(this.editor, { - blockId, - range, - text, - }); - if (listInputRuleTarget) { - const nextSelection = { - blockId: listInputRuleTarget.blockId, - anchorOffset: listInputRuleTarget.anchorOffset, - focusOffset: listInputRuleTarget.focusOffset, - }; - this.fieldEditor.setBackendSelectionAuthority( - "programmatic", - nextSelection, - ); - this.setEditContextSelection(nextSelection, { - source: "text-update", - }); - this.fieldEditor.syncTextSelection( - listInputRuleTarget.blockId, - listInputRuleTarget.anchorOffset, - listInputRuleTarget.focusOffset, - ); - this.restoreDOMCaret(); - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - return; - } - - const inlineInputRuleTarget = this.applyInlineInputRule( - blockId, - range.start, - text, - ); - if (inlineInputRuleTarget) { - this.fieldEditor.setBackendSelectionAuthority( - "programmatic", - inlineInputRuleTarget, - ); - this.setEditContextSelection(inlineInputRuleTarget, { - source: "text-update", - }); - this.fieldEditor.syncTextSelection( - inlineInputRuleTarget.blockId, - inlineInputRuleTarget.anchorOffset, - inlineInputRuleTarget.focusOffset, - ); - this.restoreDOMCaret(); - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - return; - } - - this.fieldEditor.setBackendSelectionAuthority( - "programmatic", - resolvedTextUpdate.selection, - ); - - const transaction = buildInlineTextEditTransaction({ - blockId, - range, - text, - marks: this.fieldEditor.resolveInsertMarks( - this.ytext, - range.start, - ), - }); - if (transaction.ops.length > 0) { - this.editor.apply(transaction.ops, { origin: "user" }); - } - - if (resolvedTextUpdate.selection) { - this.setEditContextSelection(resolvedTextUpdate.selection, { - source: "text-update", - }); - this.fieldEditor.syncTextSelection( - blockId, - resolvedTextUpdate.selection.anchorOffset, - resolvedTextUpdate.selection.focusOffset, - ); - this.restoreDOMCaret(); - } - - this.fieldEditor.clearBackendSelectionAuthority("programmatic"); - }; - - private resolveTextUpdateRange(input: { - blockId: string; - updateRangeStart: number; - updateRangeEnd: number; - text: string; - selectionStart?: number; - selectionEnd?: number; - }): { - range: { start: number; end: number }; - selection: EditContextSelection | null; - } { - const selection = this.fieldEditor.selection; - const editorCaret = - selection?.type === "text" && - selection.isCollapsed && - selection.focus.blockId === input.blockId - ? selection.focus.offset - : null; - - return resolveEditContextTextUpdateRange({ - ...input, - isLogicallyEmpty: isLogicallyEmptyText( - this.ytext?.toString() ?? "", - ), - editorSelectionRange: this.resolveEditorSelectionRange( - input.blockId, - ), - programmaticInputRange: - this.fieldEditor.resolveProgrammaticInputRange(input.blockId, { - start: input.updateRangeStart, - end: input.updateRangeEnd, - }), - editContextSelection: - this.fieldEditor.getEditContextSelectionSnapshot( - input.blockId, - ), - authoritativeTextInputSelection: - this.fieldEditor.getBackendSelectionAuthority( - "edit-context-textupdate", - input.blockId, - ), - editorCaret, - }); - } - - private setEditContextSelection( - selection: EditContextSelection, - options?: EditContextSelectionOptions, - ): void { - const resolvedSelection = { - blockId: selection.blockId, - anchorOffset: this.resolveEditContextOffset( - selection.anchorOffset, - options, - ), - focusOffset: this.resolveEditContextOffset( - selection.focusOffset, - options, - ), - }; - this.fieldEditor.setEditContextSelectionSnapshot(resolvedSelection); - if (options?.source === "text-update") { - this.fieldEditor.setBackendSelectionAuthority( - "edit-context-textupdate", - resolvedSelection, - ); - } - this.editContext?.updateSelection( - resolvedSelection.anchorOffset, - resolvedSelection.focusOffset, - ); - } - - private resolveEditContextOffset( - offset: number, - options?: EditContextSelectionOptions, - ): number { - return options?.source !== "text-update" && - isLogicallyEmptyText(this.ytext?.toString() ?? "") - ? 0 - : offset; - } - - private resolveEditorSelectionRange( - blockId: string, - ): EditContextRange | null { - const selection = this.fieldEditor.selection; - if ( - selection?.type !== "text" || - selection.isCollapsed || - selection.anchor.blockId !== blockId || - selection.focus.blockId !== blockId - ) { - return null; - } - - return { - start: Math.min(selection.anchor.offset, selection.focus.offset), - end: Math.max(selection.anchor.offset, selection.focus.offset), - }; - } - - private shouldIgnoreStaleCollapsedDomSelection( - selection: ReturnType, - ): boolean { - if (selection.type === "block") { - return false; - } - if ( - selection.anchor.blockId !== selection.focus.blockId || - selection.anchor.offset !== selection.focus.offset - ) { - return false; - } - - const editorSelectionRange = - this.resolveEditorSelectionRange(selection.anchor.blockId) ?? - this.resolveCollapsedEditorSelectionRange(selection.anchor.blockId); - if (!editorSelectionRange) { - return false; - } - - return ( - selection.anchor.offset !== editorSelectionRange.start || - selection.focus.offset !== editorSelectionRange.end - ); - } - - private applyInlineInputRule( - blockId: string, - offset: number, - text: string, - ): { - blockId: string; - anchorOffset: number; - focusOffset: number; - } | null { - if (text.length !== 1) { - return null; - } - - const block = this.editor.getBlock(blockId); - if (!block) { - return null; - } - - const blockSchema = this.editor.schema.resolve(block.type); - if (!supportsInlineInputRules(blockSchema)) { - return null; - } - - const inputRuleEngine = - this.editor.internals.getSlot( - INPUT_RULES_ENGINE_SLOT_KEY, - ) ?? null; - const ops = - inputRuleEngine?.tryMatchInline(this.editor, blockId, text, { - offset, - }) ?? - this.resolveFallbackInlineInputRule( - blockId, - block.textContent(), - offset, - text, - ); - if (!ops) { - return null; - } - - const selectionTarget = resolveInlineSelectionTarget(blockId, ops); - if (!selectionTarget) { - return null; - } - - this.editor.apply(ops, { origin: "input-rule" }); - return selectionTarget; - } - - private resolveFallbackInlineInputRule( - blockId: string, - blockText: string, - offset: number, - text: string, - ): DocumentOp[] | null { - const match = matchInlineInputRule(blockText, offset, text); - if (!match) { - return null; - } - - const markType = Object.keys(match.marks)[0]; - if (!markType || !this.editor.schema.resolveInline(markType)) { - return null; - } - - return [ - { - type: "delete-text", - blockId, - offset: match.deleteRange.start, - length: match.deleteRange.end - match.deleteRange.start, - }, - { - type: "insert-text", - blockId, - offset: match.deleteRange.start, - text: match.text, - marks: match.marks, - }, - ]; - } - - private handleTextFormatUpdate = (event: Event): void => { - // IME composition underline rendering. - // The textformatupdate event provides ranges with underline styles - // for visual feedback during IME composition. These are rendered - // as ephemeral decorations (not CRDT marks) and cleared when - // textupdate confirms the final text. - if (!this.element) return; - - const ranges = - (event as EditContextTextFormatUpdateEvent).getTextFormats?.() ?? - []; - for (const fmt of ranges) { - const { rangeStart, rangeEnd, underlineStyle, underlineThickness } = - fmt; - if (!underlineStyle) continue; - - // Apply inline decoration-style attributes via mark wrappers. - // This is a visual-only effect that doesn't modify the CRDT. - const inlineEls = this.element.querySelectorAll( - "[data-pen-inline-content]", - ); - for (const el of inlineEls) { - const walker = document.createTreeWalker( - el, - NodeFilter.SHOW_TEXT, - null, - ); - let offset = 0; - let textNode: Text | null; - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - const segStart = offset; - const segEnd = offset + len; - if (segEnd > rangeStart && segStart < rangeEnd) { - const parentEl = textNode.parentElement; - if (parentEl) { - parentEl.style.textDecoration = underlineStyle; - if (underlineThickness) { - parentEl.style.textDecorationThickness = - underlineThickness; - } - } - } - offset += len; - } - } - } - }; - - private handleCharacterBoundsUpdate = (event: Event): void => { - if (!this.element || !this.editContext) return; - - const { rangeStart, rangeEnd } = - event as EditContextCharacterBoundsUpdateEvent; - const rects: DOMRect[] = []; - - for (let i = rangeStart; i < rangeEnd; i++) { - const rect = getCharacterRect(this.element, i); - rects.push(rect); - } - - this.editContext.updateCharacterBounds(rangeStart, rects); - }; - - private handleSelectionChange = (): void => { - if (!this.element || !this.editContext) return; - const isApplyingSelection = - this.fieldEditor.getBackendSelectionApplicationDepth(); - if ( - !this.fieldEditor.shouldHandleDomSelectionChange( - isApplyingSelection, - ) - ) { - if (isApplyingSelection === 0) { - this.restoreDOMCaret(); - } - return; - } - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - - const mappedSelection = domSelectionToEditor(root); - if (!mappedSelection) return; - const normalizedSelection = normalizeSelectionFormation( - this.editor, - mappedSelection, - ); - - if (this.shouldIgnoreStaleCollapsedDomSelection(normalizedSelection)) { - this.restoreDOMCaret(); - return; - } - - if (normalizedSelection.type === "block") { - this.fieldEditor.deactivate(); - this.editor.setSelection({ - type: "block", - blockIds: normalizedSelection.blockIds, - }); - return; - } - - if ( - normalizedSelection.anchor.blockId !== - normalizedSelection.focus.blockId - ) { - this.fieldEditor.applyDocumentTextSelection( - normalizedSelection.anchor, - normalizedSelection.focus, - ); - return; - } - - if ( - normalizedSelection.anchor.blockId !== this.fieldEditor.focusBlockId - ) { - this.fieldEditor.activateTextSelection( - normalizedSelection.anchor.blockId, - normalizedSelection.anchor.offset, - normalizedSelection.focus.offset, - ); - return; - } - - const selection = this.element.ownerDocument?.getSelection(); - if (!selection?.rangeCount) return; - if (!this.element.contains(selection.anchorNode)) return; - if (!this.element.contains(selection.focusNode)) return; - - const offsets = getDirectionalSelectionOffsets(this.element); - if (!offsets) return; - const editorSelectionRange = this.resolveEditorSelectionRange( - normalizedSelection.anchor.blockId, - ); - if ( - editorSelectionRange && - offsets.anchor === offsets.focus && - (offsets.start !== editorSelectionRange.start || - offsets.end !== editorSelectionRange.end) - ) { - this.setEditContextSelection({ - blockId: normalizedSelection.anchor.blockId, - anchorOffset: editorSelectionRange.start, - focusOffset: editorSelectionRange.end, - }); - this.restoreDOMCaret(); - return; - } - const authoritativeSelection = this.getAuthoritativeTextInputSelection( - normalizedSelection.anchor.blockId, - ); - if ( - authoritativeSelection && - offsets.anchor === offsets.focus && - (offsets.anchor !== authoritativeSelection.anchorOffset || - offsets.focus !== authoritativeSelection.focusOffset) - ) { - this.setEditContextSelection(authoritativeSelection, { - source: "text-update", - }); - this.restoreDOMCaret(); - return; - } - - this.editContext.updateSelection(offsets.start, offsets.end); - const nextSelection = { - blockId: normalizedSelection.anchor.blockId, - anchorOffset: offsets.anchor, - focusOffset: offsets.focus, - }; - this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); - this.fieldEditor.setBackendSelectionAuthority("user-dom", nextSelection); - this.fieldEditor.syncTextSelection( - normalizedSelection.anchor.blockId, - offsets.anchor, - offsets.focus, - ); - }; - - private handleYTextChange = (event: FieldEditorTextChangeEvent): void => { - if (!this.editContext || !this.element || !this.ytext) return; - const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); - if (isHistory) { - this.fieldEditor.clearBackendSelectionAuthority( - "edit-context-textupdate", - ); - const nextText = toEditContextText(this.ytext?.toString?.() ?? ""); - this.editContext.updateText( - 0, - this.editContext.text.length, - nextText, - ); - const clampedSelectionStart = Math.min( - this.editContext.selectionStart, - nextText.length, - ); - const clampedSelectionEnd = Math.min( - this.editContext.selectionEnd, - nextText.length, - ); - this.editContext.updateSelection( - clampedSelectionStart, - clampedSelectionEnd, - ); - const blockId = this.fieldEditor.focusBlockId; - this.fieldEditor.setEditContextSelectionSnapshot( - blockId - ? { - blockId, - anchorOffset: clampedSelectionStart, - focusOffset: clampedSelectionEnd, - } - : null, - ); - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled(blockId ?? undefined); - this.restoreDOMCaret(); - return; - } - - const applied = applyDeltaToDOM( - event.delta, - this.element, - this.editor.schema, - ); - if (!applied) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.fieldEditor.notifyDomReconciled( - this.fieldEditor.focusBlockId ?? undefined, - ); - } - - if ( - shouldReplaceEditContextText( - event.delta, - this.editContext.text.length, - ) - ) { - const nextText = toEditContextText(this.ytext.toString()); - this.editContext.updateText( - 0, - this.editContext.text.length, - nextText, - ); - } else { - const delta = event.delta; - let offset = 0; - for (const entry of delta) { - if (entry.retain != null) { - offset += entry.retain; - } else if (typeof entry.insert === "string") { - this.editContext.updateText(offset, offset, entry.insert); - offset += entry.insert.length; - } else if (entry.delete != null) { - this.editContext.updateText( - offset, - offset + entry.delete, - "", - ); - } - } - } - - const pendingSelection = this.fieldEditor.focusBlockId - ? this.fieldEditor.getBackendSelectionAuthority( - "programmatic", - this.fieldEditor.focusBlockId, - ) - : null; - if (pendingSelection) { - this.setEditContextSelection(pendingSelection, { - source: "text-update", - }); - } - this.restoreDOMCaret(); - }; - - private restoreDOMCaret(): void { - if (!this.editContext || !this.element) return; - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - const selection = this.fieldEditor.selection; - const blockId = this.fieldEditor.focusBlockId; - const pendingSelection = - blockId != null - ? this.fieldEditor.getBackendSelectionAuthority( - "programmatic", - blockId, - ) - : null; - const authoritativeInputSelection = - blockId != null - ? this.fieldEditor.getBackendSelectionAuthority( - "edit-context-textupdate", - blockId, - ) - : null; - const editContextSelection = - this.fieldEditor.getEditContextSelectionSnapshot(blockId); - const editorSelection = - selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? selection - : null; - const anchorOffset = - pendingSelection?.anchorOffset ?? - authoritativeInputSelection?.anchorOffset ?? - editorSelection?.anchor.offset ?? - editContextSelection?.anchorOffset ?? - null; - const focusOffset = - pendingSelection?.focusOffset ?? - authoritativeInputSelection?.focusOffset ?? - editorSelection?.focus.offset ?? - editContextSelection?.focusOffset ?? - null; - if (root && blockId && anchorOffset != null && focusOffset != null) { - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - editorSelectionToDOM( - root, - { blockId, offset: anchorOffset }, - { blockId, offset: focusOffset }, - ); - return; - } - - const start = this.editContext.selectionStart; - const end = this.editContext.selectionEnd; - - const anchorPoint = findTextPosition(this.element, start); - const focusPoint = - start === end ? anchorPoint : findTextPosition(this.element, end); - if (!anchorPoint || !focusPoint) return; - - const sel = this.element.ownerDocument?.getSelection(); - if (!sel) return; - - this.fieldEditor.applyBackendSelectionUntilNextFrame(); - sel.removeAllRanges(); - const range = document.createRange(); - range.setStart(anchorPoint.node, anchorPoint.offset); - range.setEnd(focusPoint.node, focusPoint.offset); - sel.addRange(range); - } - - private getInlineDecorationsForBlock(): readonly InlineDecoration[] { - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) { - return []; - } - return this.editor - .getDecorations() - .forBlock(blockId) - .filter( - (decoration): decoration is InlineDecoration => - decoration.type === "inline", - ); - } - - private handleKeyDown = (event: KeyboardEvent): void => { - if (!this.editContext || !this.element || !this.ytext) return; - if (isNavigationSelectionKey(event)) { - this.fieldEditor.clearBackendSelectionAuthority( - "edit-context-textupdate", - ); - } - - const blockId = this.fieldEditor.focusBlockId; - const liveDomOffsets = getDirectionalSelectionOffsets(this.element); - const { range, nextSelection, shouldSyncEditContextSelection } = - this.resolveKeyDownRange(blockId, event, liveDomOffsets); - - if (shouldSyncEditContextSelection) { - this.editContext.updateSelection(range.start, range.end); - this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); - } - - const handled = handleFieldEditorKeyDown({ - event, - editor: this.editor, - fieldEditor: this.fieldEditor, - ytext: this.ytext, - range, - }); - if (handled) { - event.preventDefault(); - } - }; - - private resolveKeyDownRange( - blockId: string | null, - event: KeyboardEvent, - liveDomOffsets: DirectionalSelectionOffsets | null, - ): KeyDownRangeResolution { - const isTextEditingKey = isFieldEditorTextEditingKey(event); - const liveRange = liveDomOffsets - ? { - start: liveDomOffsets.start, - end: liveDomOffsets.end, - } - : null; - return resolveEditContextKeyDownRange({ - blockId, - isTextEditingKey, - liveDomOffsets, - editContextRange: this.resolveEditContextSelectionRange(), - editorSelectionRange: blockId - ? this.resolveEditorSelectionRange(blockId) - : null, - programmaticInputRange: - blockId && isTextEditingKey - ? this.fieldEditor.resolveProgrammaticInputRange( - blockId, - liveRange, - ) - : null, - authoritativeTextInputSelection: blockId - ? this.getAuthoritativeTextInputSelection(blockId) - : null, - collapsedEditorSelectionRange: blockId - ? this.resolveCollapsedEditorSelectionRange(blockId) - : null, - projectedTextSelection: blockId - ? this.getProjectedTextSelection(blockId) - : null, - synchronizedEditContextRange: blockId - ? this.resolveSynchronizedEditContextRange(blockId) - : null, - }); - } - - private resolveEditContextSelectionRange(): EditContextRange { - if (!this.editContext) { - return { start: 0, end: 0 }; - } - - return { - start: Math.min( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - end: Math.max( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - }; - } - - private getProjectedTextSelection( - blockId: string, - ): EditContextSelection | null { - return this.fieldEditor.getEditContextSelectionSnapshot(blockId); - } - - private resolveCollapsedEditorSelectionRange( - blockId: string, - ): EditContextRange | null { - const selection = this.fieldEditor.selection; - if ( - selection?.type === "text" && - selection.isCollapsed && - selection.focus.blockId === blockId - ) { - return { - start: selection.focus.offset, - end: selection.focus.offset, - }; - } - - return null; - } - - private resolveSynchronizedEditContextRange( - blockId: string, - ): EditContextRange | null { - if (!this.editContext) { - return null; - } - - const editContextRange = { - start: Math.min( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - end: Math.max( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - }; - const editorRange = - this.resolveEditorSelectionRange(blockId) ?? - this.resolveCollapsedEditorSelectionRange(blockId); - - if (editorRange && rangesEqual(editContextRange, editorRange)) { - return editContextRange; - } - - return null; - } - - private handleCopyEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCopy(this.editor, event); - }; - - private handleCutEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCut(this.editor, event); - }; - - private handlePasteEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - const importers = - this.editor.internals.getSlot("paste:importers"); - handleClipboardPaste( - event, - this.editor, - this.fieldEditor, - importers ?? undefined, - ); - }; - - private handleDragStart = (event: DragEvent): void => { - event.preventDefault(); - }; - - private handleDrop = (event: DragEvent): void => { - event.preventDefault(); - }; - - private handlePointerDown = (): void => { - this.fieldEditor.clearBackendSelectionAuthority( - "edit-context-textupdate", - ); - }; - - private getAuthoritativeTextInputSelection( - blockId: string, - ): EditContextSelection | null { - const selection = - this.fieldEditor.getBackendSelectionAuthority( - "edit-context-textupdate", - blockId, - ); - if (!selection || selection.anchorOffset !== selection.focusOffset) { - return null; - } - return selection; - } -} - -function resolveInlineSelectionTarget( - blockId: string, - ops: DocumentOp[], -): { - blockId: string; - anchorOffset: number; - focusOffset: number; -} | null { - let nextOffset: number | null = null; - for (const op of ops) { - if (op.type === "insert-text" && op.blockId === blockId) { - nextOffset = op.offset + op.text.length; - } - } - - if (nextOffset == null) { - return null; - } - - return { - blockId, - anchorOffset: nextOffset, - focusOffset: nextOffset, - }; -} - -/** - * Get the DOMRect for a character at the given offset within the element. - * Walks text nodes to locate the character, then uses Range.getBoundingClientRect(). - */ -function getCharacterRect(element: HTMLElement, charOffset: number): DOMRect { - const walker = document.createTreeWalker( - element, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = charOffset; - let textNode: Text | null; - - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining < len) { - const range = document.createRange(); - range.setStart(textNode, remaining); - range.setEnd(textNode, remaining + 1); - return range.getBoundingClientRect(); - } - remaining -= len; - } - - // Fallback: return the element's bounding rect - return element.getBoundingClientRect(); -} - -function findTextPosition( - container: HTMLElement, - charOffset: number, -): { node: Node; offset: number } | null { - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = charOffset; - let textNode: Text | null; - - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining <= len) { - return { node: textNode, offset: remaining }; - } - remaining -= len; - } - - const last = container.lastChild; - if (last) { - return { node: last, offset: last.textContent?.length ?? 0 }; - } - return { node: container, offset: 0 }; -} - -function isLogicallyEmptyText(text: string): boolean { - return text.length === 0 || text === ZERO_WIDTH_SPACE; -} - -function toEditContextText(text: string): string { - return text === ZERO_WIDTH_SPACE ? "" : text; -} - -function shouldReplaceEditContextText( - delta: FieldEditorTextChangeEvent["delta"], - editContextTextLength: number, -): boolean { - let offset = 0; - for (const entry of delta) { - if (entry.retain != null) { - offset += entry.retain; - if (offset > editContextTextLength) return true; - } else if (typeof entry.insert === "string") { - if (entry.insert === ZERO_WIDTH_SPACE) return true; - if (offset > editContextTextLength) return true; - offset += entry.insert.length; - } else if (entry.delete != null) { - if (offset + entry.delete > editContextTextLength) return true; - } - } - return false; -} - -function isNavigationSelectionKey(event: KeyboardEvent): boolean { - return ( - event.key === "ArrowLeft" || - event.key === "ArrowRight" || - event.key === "ArrowUp" || - event.key === "ArrowDown" || - event.key === "Home" || - event.key === "End" || - event.key === "PageUp" || - event.key === "PageDown" - ); -} +export class EditContextBackend extends EditContextBackendRuntime {} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendCore.ts b/packages/rendering/dom/src/field-editor/editContextBackendCore.ts new file mode 100644 index 0000000..3efdb83 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendCore.ts @@ -0,0 +1,266 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; + +export type EditContextSelectionOptions = { + source?: "text-update"; +}; + +export abstract class EditContextBackendCore { + protected editContext: EditContext | null = null; + protected element: HTMLElement | null = null; + protected ytext: FieldEditorTextLike | null = null; + protected observer: FieldEditorObserver | null = null; + protected editor: Editor; + protected fieldEditor: FieldEditorInputController; + + constructor(editor: Editor, fieldEditor: FieldEditorInputController) { + this.editor = editor; + this.fieldEditor = fieldEditor; + } + + activate(element: HTMLElement, ytext: unknown): void { + this.element = element; + this.ytext = ytext as FieldEditorTextLike; + this.fieldEditor.setComposing(false); + + const editContextConstructor = (globalThis as EditContextGlobal) + .EditContext; + if (!editContextConstructor) { + throw new Error( + "EditContext is not available in this environment.", + ); + } + + const initialText = this.ytext.toString(); + const initialEditContextText = toEditContextText(initialText); + const initialSelectionOffset = isLogicallyEmptyText(initialText) + ? 0 + : initialEditContextText.length; + this.editContext = new editContextConstructor({ + text: initialEditContextText, + selectionStart: initialSelectionOffset, + selectionEnd: initialSelectionOffset, + }); + + const ec = this.editContext!; + + ( + element as HTMLElement & { editContext: EditContext | null } + ).editContext = ec; + + element.addEventListener("keydown", this.handleKeyDown); + element.addEventListener("copy", this.handleCopyEvent); + element.addEventListener("cut", this.handleCutEvent); + element.addEventListener("paste", this.handlePasteEvent); + element.addEventListener("dragstart", this.handleDragStart); + element.addEventListener("drop", this.handleDrop); + element.addEventListener("pointerdown", this.handlePointerDown); + ec.addEventListener("textupdate", this.handleTextUpdate); + ec.addEventListener("textformatupdate", this.handleTextFormatUpdate); + ec.addEventListener( + "characterboundsupdate", + this.handleCharacterBoundsUpdate, + ); + element.ownerDocument?.addEventListener( + "selectionchange", + this.handleSelectionChange, + ); + + this.observer = (event) => this.handleYTextChange(event); + this.ytext.observe(this.observer); + + fullReconcileToDOM(this.ytext, element, this.editor.schema, { + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + this.updateSelection(); + this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }); + } + + deactivate(): void { + if (this.editContext) { + this.editContext.removeEventListener( + "textupdate", + this.handleTextUpdate, + ); + this.editContext.removeEventListener( + "textformatupdate", + this.handleTextFormatUpdate, + ); + this.editContext.removeEventListener( + "characterboundsupdate", + this.handleCharacterBoundsUpdate, + ); + } + if (this.observer && this.ytext) { + this.ytext.unobserve(this.observer); + } + if (this.element) { + this.element.removeEventListener("keydown", this.handleKeyDown); + this.element.removeEventListener("copy", this.handleCopyEvent); + this.element.removeEventListener("cut", this.handleCutEvent); + this.element.removeEventListener("paste", this.handlePasteEvent); + this.element.removeEventListener("dragstart", this.handleDragStart); + this.element.removeEventListener("drop", this.handleDrop); + this.element.removeEventListener( + "pointerdown", + this.handlePointerDown, + ); + this.element.ownerDocument?.removeEventListener( + "selectionchange", + this.handleSelectionChange, + ); + ( + this.element as HTMLElement & { + editContext: EditContext | null; + } + ).editContext = null; + } + this.editContext = null; + this.element = null; + this.ytext = null; + this.observer = null; + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.setComposing(false); + } + + updateSelection(): void { + if (!this.editContext || !this.ytext) return; + + const selection = this.fieldEditor.selection; + const blockId = this.fieldEditor.focusBlockId; + if ( + selection?.type === "text" && + blockId && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ) { + const anchorOffset = this.resolveEditContextOffset( + selection.anchor.offset, + ); + const focusOffset = this.resolveEditContextOffset( + selection.focus.offset, + ); + this.setEditContextSelection({ + blockId, + anchorOffset, + focusOffset, + }); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + this.projectDOMSelection(blockId, anchorOffset, focusOffset); + return; + } + + const len = isLogicallyEmptyText(this.ytext.toString()) + ? 0 + : this.ytext.length; + this.editContext.updateSelection(len, len); + this.fieldEditor.setEditContextSelectionSnapshot( + blockId + ? { + blockId, + anchorOffset: len, + focusOffset: len, + } + : null, + ); + } + + protected projectDOMSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + if (!this.element) return; + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + editorSelectionToDOM( + root, + { blockId, offset: anchorOffset }, + { blockId, offset: focusOffset }, + ); + } + + protected abstract handleTextUpdate: (event: Event) => void; + protected abstract handleTextFormatUpdate: (event: Event) => void; + protected abstract handleCharacterBoundsUpdate: (event: Event) => void; + protected abstract handleSelectionChange: () => void; + protected abstract handleYTextChange: (event: FieldEditorTextChangeEvent) => void; + protected abstract handleKeyDown: (event: KeyboardEvent) => void; + protected abstract handleCopyEvent: (event: ClipboardEvent) => void; + protected abstract handleCutEvent: (event: ClipboardEvent) => void; + protected abstract handlePasteEvent: (event: ClipboardEvent) => void; + protected abstract handleDragStart: (event: DragEvent) => void; + protected abstract handleDrop: (event: DragEvent) => void; + protected abstract handlePointerDown: () => void; + protected abstract restoreDOMCaret(): void; + protected abstract getInlineDecorationsForBlock(): readonly InlineDecoration[]; + protected abstract setEditContextSelection( + selection: EditContextSelection, + options?: EditContextSelectionOptions, + ): void; + protected abstract resolveEditContextOffset( + offset: number, + options?: EditContextSelectionOptions, + ): number; + protected abstract resolveCollapsedEditorSelectionRange( + blockId: string, + ): EditContextRange | null; + protected abstract getAuthoritativeTextInputSelection( + blockId: string, + ): EditContextSelection | null; +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendInput.ts b/packages/rendering/dom/src/field-editor/editContextBackendInput.ts new file mode 100644 index 0000000..5172958 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendInput.ts @@ -0,0 +1,310 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { + EditContextBackendCore, + type EditContextSelectionOptions, +} from "./editContextBackendCore"; + +export abstract class EditContextBackendInput extends EditContextBackendCore { + protected handleTextUpdate = (event: Event): void => { + if (!this.ytext) return; + const { + updateRangeStart, + updateRangeEnd, + text, + selectionStart, + selectionEnd, + } = event as EditContextTextUpdateEvent; + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + + const block = this.editor.getBlock(blockId); + if (!block) { + this.fieldEditor.deactivate(); + return; + } + + const resolvedTextUpdate = this.resolveTextUpdateRange({ + blockId, + updateRangeStart, + updateRangeEnd, + text, + selectionStart, + selectionEnd, + }); + const { range } = resolvedTextUpdate; + const listInputRuleTarget = applyListInputRule(this.editor, { + blockId, + range, + text, + }); + if (listInputRuleTarget) { + const nextSelection = { + blockId: listInputRuleTarget.blockId, + anchorOffset: listInputRuleTarget.anchorOffset, + focusOffset: listInputRuleTarget.focusOffset, + }; + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + nextSelection, + ); + this.setEditContextSelection(nextSelection, { + source: "text-update", + }); + this.fieldEditor.syncTextSelection( + listInputRuleTarget.blockId, + listInputRuleTarget.anchorOffset, + listInputRuleTarget.focusOffset, + ); + this.restoreDOMCaret(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + return; + } + + const inlineInputRuleTarget = applyInlineInputRule(this.editor, { + blockId, + offset: range.start, + text, + }); + if (inlineInputRuleTarget) { + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + inlineInputRuleTarget, + ); + this.setEditContextSelection(inlineInputRuleTarget, { + source: "text-update", + }); + this.fieldEditor.syncTextSelection( + inlineInputRuleTarget.blockId, + inlineInputRuleTarget.anchorOffset, + inlineInputRuleTarget.focusOffset, + ); + this.restoreDOMCaret(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + return; + } + + const selection = applyInlineTextInput({ + editor: this.editor, + fieldEditor: this.fieldEditor, + blockId, + range, + text, + marks: this.fieldEditor.resolveInsertMarks( + this.ytext, + range.start, + ), + selection: resolvedTextUpdate.selection, + syncSelection: resolvedTextUpdate.selection != null, + }); + + if (resolvedTextUpdate.selection) { + this.setEditContextSelection(selection, { + source: "text-update", + }); + this.fieldEditor.syncTextSelection( + blockId, + selection.anchorOffset, + selection.focusOffset, + ); + this.restoreDOMCaret(); + } + + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + }; + + protected resolveTextUpdateRange(input: { + blockId: string; + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; + }): { + range: { start: number; end: number }; + selection: EditContextSelection | null; + } { + const selection = this.fieldEditor.selection; + const editorCaret = + selection?.type === "text" && + selection.isCollapsed && + selection.focus.blockId === input.blockId + ? selection.focus.offset + : null; + + return resolveEditContextTextUpdateRange({ + ...input, + isLogicallyEmpty: isLogicallyEmptyText( + this.ytext?.toString() ?? "", + ), + editorSelectionRange: this.resolveEditorSelectionRange( + input.blockId, + ), + programmaticInputRange: + this.fieldEditor.resolveProgrammaticInputRange(input.blockId, { + start: input.updateRangeStart, + end: input.updateRangeEnd, + }), + editContextSelection: + this.fieldEditor.getEditContextSelectionSnapshot( + input.blockId, + ), + authoritativeTextInputSelection: + this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + input.blockId, + ), + editorCaret, + }); + } + + protected setEditContextSelection( + selection: EditContextSelection, + options?: EditContextSelectionOptions, + ): void { + const resolvedSelection = { + blockId: selection.blockId, + anchorOffset: this.resolveEditContextOffset( + selection.anchorOffset, + options, + ), + focusOffset: this.resolveEditContextOffset( + selection.focusOffset, + options, + ), + }; + this.fieldEditor.setEditContextSelectionSnapshot(resolvedSelection); + if (options?.source === "text-update") { + this.fieldEditor.setBackendSelectionAuthority( + "edit-context-textupdate", + resolvedSelection, + ); + } + this.editContext?.updateSelection( + resolvedSelection.anchorOffset, + resolvedSelection.focusOffset, + ); + } + + protected resolveEditContextOffset( + offset: number, + options?: EditContextSelectionOptions, + ): number { + return options?.source !== "text-update" && + isLogicallyEmptyText(this.ytext?.toString() ?? "") + ? 0 + : offset; + } + + protected resolveEditorSelectionRange( + blockId: string, + ): EditContextRange | null { + const selection = this.fieldEditor.selection; + if ( + selection?.type !== "text" || + selection.isCollapsed || + selection.anchor.blockId !== blockId || + selection.focus.blockId !== blockId + ) { + return null; + } + + return { + start: Math.min(selection.anchor.offset, selection.focus.offset), + end: Math.max(selection.anchor.offset, selection.focus.offset), + }; + } + + protected shouldIgnoreStaleCollapsedDomSelection( + selection: ReturnType, + ): boolean { + if (selection.type === "block") { + return false; + } + if ( + selection.anchor.blockId !== selection.focus.blockId || + selection.anchor.offset !== selection.focus.offset + ) { + return false; + } + + const editorSelectionRange = + this.resolveEditorSelectionRange(selection.anchor.blockId) ?? + this.resolveCollapsedEditorSelectionRange(selection.anchor.blockId); + if (!editorSelectionRange) { + return false; + } + + return ( + selection.anchor.offset !== editorSelectionRange.start || + selection.focus.offset !== editorSelectionRange.end + ); + } + + protected handleTextFormatUpdate = (event: Event): void => { + if (!this.element) return; + + const ranges = + (event as EditContextTextFormatUpdateEvent).getTextFormats?.() ?? + []; + applyEditContextTextFormats(this.element, ranges); + }; + + protected handleCharacterBoundsUpdate = (event: Event): void => { + if (!this.element || !this.editContext) return; + + const { rangeStart, rangeEnd } = + event as EditContextCharacterBoundsUpdateEvent; + this.editContext.updateCharacterBounds( + rangeStart, + buildEditContextCharacterBounds(this.element, rangeStart, rangeEnd), + ); + }; + +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts b/packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts new file mode 100644 index 0000000..da934bd --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts @@ -0,0 +1,246 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { EditContextBackendSelection } from "./editContextBackendSelection"; + +export class EditContextBackendRuntime extends EditContextBackendSelection { + protected handleKeyDown = (event: KeyboardEvent): void => { + if (!this.editContext || !this.element || !this.ytext) return; + if (isNavigationSelectionKey(event)) { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); + } + + const blockId = this.fieldEditor.focusBlockId; + const liveDomOffsets = getDirectionalSelectionOffsets(this.element); + const { range, nextSelection, shouldSyncEditContextSelection } = + this.resolveKeyDownRange(blockId, event, liveDomOffsets); + + if (shouldSyncEditContextSelection) { + this.editContext.updateSelection(range.start, range.end); + this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); + } + + const handled = handleFieldEditorKeyDown({ + event, + editor: this.editor, + fieldEditor: this.fieldEditor, + ytext: this.ytext, + range, + }); + if (handled) { + event.preventDefault(); + } + }; + + protected resolveKeyDownRange( + blockId: string | null, + event: KeyboardEvent, + liveDomOffsets: DirectionalSelectionOffsets | null, + ): KeyDownRangeResolution { + const isTextEditingKey = isFieldEditorTextEditingKey(event); + const liveRange = liveDomOffsets + ? { + start: liveDomOffsets.start, + end: liveDomOffsets.end, + } + : null; + return resolveEditContextKeyDownRange({ + blockId, + isTextEditingKey, + liveDomOffsets, + editContextRange: this.resolveEditContextSelectionRange(), + editorSelectionRange: blockId + ? this.resolveEditorSelectionRange(blockId) + : null, + programmaticInputRange: + blockId && isTextEditingKey + ? this.fieldEditor.resolveProgrammaticInputRange( + blockId, + liveRange, + ) + : null, + authoritativeTextInputSelection: blockId + ? this.getAuthoritativeTextInputSelection(blockId) + : null, + collapsedEditorSelectionRange: blockId + ? this.resolveCollapsedEditorSelectionRange(blockId) + : null, + projectedTextSelection: blockId + ? this.getProjectedTextSelection(blockId) + : null, + synchronizedEditContextRange: blockId + ? this.resolveSynchronizedEditContextRange(blockId) + : null, + }); + } + + protected resolveEditContextSelectionRange(): EditContextRange { + if (!this.editContext) { + return { start: 0, end: 0 }; + } + + return { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + } + + protected getProjectedTextSelection( + blockId: string, + ): EditContextSelection | null { + return this.fieldEditor.getEditContextSelectionSnapshot(blockId); + } + + protected resolveCollapsedEditorSelectionRange( + blockId: string, + ): EditContextRange | null { + const selection = this.fieldEditor.selection; + if ( + selection?.type === "text" && + selection.isCollapsed && + selection.focus.blockId === blockId + ) { + return { + start: selection.focus.offset, + end: selection.focus.offset, + }; + } + + return null; + } + + protected resolveSynchronizedEditContextRange( + blockId: string, + ): EditContextRange | null { + if (!this.editContext) { + return null; + } + + const editContextRange = { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + const editorRange = + this.resolveEditorSelectionRange(blockId) ?? + this.resolveCollapsedEditorSelectionRange(blockId); + + if (editorRange && rangesEqual(editContextRange, editorRange)) { + return editContextRange; + } + + return null; + } + + protected handleCopyEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCopy(this.editor, event); + }; + + protected handleCutEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCut(this.editor, event); + }; + + protected handlePasteEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + const importers = + this.editor.internals.getSlot("paste:importers"); + handleClipboardPaste( + event, + this.editor, + this.fieldEditor, + importers ?? undefined, + ); + }; + + protected handleDragStart = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handleDrop = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handlePointerDown = (): void => { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); + }; + + protected getAuthoritativeTextInputSelection( + blockId: string, + ): EditContextSelection | null { + const selection = + this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + blockId, + ); + if (!selection || selection.anchorOffset !== selection.focusOffset) { + return null; + } + return { + blockId: selection.blockId, + anchorOffset: selection.anchorOffset, + focusOffset: selection.focusOffset, + }; + } +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendSelection.ts b/packages/rendering/dom/src/field-editor/editContextBackendSelection.ts new file mode 100644 index 0000000..b7caadc --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendSelection.ts @@ -0,0 +1,360 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { EditContextBackendInput } from "./editContextBackendInput"; + +export abstract class EditContextBackendSelection extends EditContextBackendInput { + protected handleSelectionChange = (): void => { + if (!this.element || !this.editContext) return; + const isApplyingSelection = + this.fieldEditor.getBackendSelectionApplicationDepth(); + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + isApplyingSelection, + ) + ) { + if (isApplyingSelection === 0) { + this.restoreDOMCaret(); + } + return; + } + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + + const mappedSelection = domSelectionToEditor(root); + if (!mappedSelection) return; + const normalizedSelection = normalizeSelectionFormation( + this.editor, + mappedSelection, + ); + + if (this.shouldIgnoreStaleCollapsedDomSelection(normalizedSelection)) { + this.restoreDOMCaret(); + return; + } + + if (normalizedSelection.type === "block") { + this.fieldEditor.deactivate(); + this.editor.setSelection({ + type: "block", + blockIds: normalizedSelection.blockIds, + }); + return; + } + + if ( + normalizedSelection.anchor.blockId !== + normalizedSelection.focus.blockId + ) { + this.fieldEditor.applyDocumentTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ); + return; + } + + if ( + normalizedSelection.anchor.blockId !== this.fieldEditor.focusBlockId + ) { + this.fieldEditor.activateTextSelection( + normalizedSelection.anchor.blockId, + normalizedSelection.anchor.offset, + normalizedSelection.focus.offset, + ); + return; + } + + const selection = this.element.ownerDocument?.getSelection(); + if (!selection?.rangeCount) return; + if (!this.element.contains(selection.anchorNode)) return; + if (!this.element.contains(selection.focusNode)) return; + + const offsets = getDirectionalSelectionOffsets(this.element); + if (!offsets) return; + const editorSelectionRange = this.resolveEditorSelectionRange( + normalizedSelection.anchor.blockId, + ); + if ( + editorSelectionRange && + offsets.anchor === offsets.focus && + (offsets.start !== editorSelectionRange.start || + offsets.end !== editorSelectionRange.end) + ) { + this.setEditContextSelection({ + blockId: normalizedSelection.anchor.blockId, + anchorOffset: editorSelectionRange.start, + focusOffset: editorSelectionRange.end, + }); + this.restoreDOMCaret(); + return; + } + const authoritativeSelection = this.getAuthoritativeTextInputSelection( + normalizedSelection.anchor.blockId, + ); + if ( + authoritativeSelection && + offsets.anchor === offsets.focus && + (offsets.anchor !== authoritativeSelection.anchorOffset || + offsets.focus !== authoritativeSelection.focusOffset) + ) { + this.setEditContextSelection(authoritativeSelection, { + source: "text-update", + }); + this.restoreDOMCaret(); + return; + } + + this.editContext.updateSelection(offsets.start, offsets.end); + const nextSelection = { + blockId: normalizedSelection.anchor.blockId, + anchorOffset: offsets.anchor, + focusOffset: offsets.focus, + }; + this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); + this.fieldEditor.setBackendSelectionAuthority("user-dom", nextSelection); + this.fieldEditor.syncTextSelection( + normalizedSelection.anchor.blockId, + offsets.anchor, + offsets.focus, + ); + }; + + protected handleYTextChange = (event: FieldEditorTextChangeEvent): void => { + if (!this.editContext || !this.element || !this.ytext) return; + const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); + if (isHistory) { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); + const nextText = toEditContextText(this.ytext?.toString?.() ?? ""); + this.editContext.updateText( + 0, + this.editContext.text.length, + nextText, + ); + const clampedSelectionStart = Math.min( + this.editContext.selectionStart, + nextText.length, + ); + const clampedSelectionEnd = Math.min( + this.editContext.selectionEnd, + nextText.length, + ); + this.editContext.updateSelection( + clampedSelectionStart, + clampedSelectionEnd, + ); + const blockId = this.fieldEditor.focusBlockId; + this.fieldEditor.setEditContextSelectionSnapshot( + blockId + ? { + blockId, + anchorOffset: clampedSelectionStart, + focusOffset: clampedSelectionEnd, + } + : null, + ); + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + this.restoreDOMCaret(); + return; + } + + const applied = applyDeltaToDOM( + event.delta, + this.element, + this.editor.schema, + ); + if (!applied) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + } + + if ( + shouldReplaceEditContextText( + event.delta, + this.editContext.text.length, + ) + ) { + const nextText = toEditContextText(this.ytext.toString()); + this.editContext.updateText( + 0, + this.editContext.text.length, + nextText, + ); + } else { + const delta = event.delta; + let offset = 0; + for (const entry of delta) { + if (entry.retain != null) { + offset += entry.retain; + } else if (typeof entry.insert === "string") { + this.editContext.updateText(offset, offset, entry.insert); + offset += entry.insert.length; + } else if (entry.delete != null) { + this.editContext.updateText( + offset, + offset + entry.delete, + "", + ); + } + } + } + + const pendingSelection = this.fieldEditor.focusBlockId + ? this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + this.fieldEditor.focusBlockId, + ) + : null; + if (pendingSelection) { + this.setEditContextSelection(pendingSelection, { + source: "text-update", + }); + } + this.restoreDOMCaret(); + }; + + protected restoreDOMCaret(): void { + if (!this.editContext || !this.element) return; + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const selection = this.fieldEditor.selection; + const blockId = this.fieldEditor.focusBlockId; + const pendingSelection = + blockId != null + ? this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + blockId, + ) + : null; + const authoritativeInputSelection = + blockId != null + ? this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + blockId, + ) + : null; + const editContextSelection = + this.fieldEditor.getEditContextSelectionSnapshot(blockId); + const editorSelection = + selection?.type === "text" && + blockId && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ? selection + : null; + const anchorOffset = + pendingSelection?.anchorOffset ?? + authoritativeInputSelection?.anchorOffset ?? + editorSelection?.anchor.offset ?? + editContextSelection?.anchorOffset ?? + null; + const focusOffset = + pendingSelection?.focusOffset ?? + authoritativeInputSelection?.focusOffset ?? + editorSelection?.focus.offset ?? + editContextSelection?.focusOffset ?? + null; + if (root && blockId && anchorOffset != null && focusOffset != null) { + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + editorSelectionToDOM( + root, + { blockId, offset: anchorOffset }, + { blockId, offset: focusOffset }, + ); + return; + } + + const start = this.editContext.selectionStart; + const end = this.editContext.selectionEnd; + + const anchorPoint = findTextPosition(this.element, start); + const focusPoint = + start === end ? anchorPoint : findTextPosition(this.element, end); + if (!anchorPoint || !focusPoint) return; + + const sel = this.element.ownerDocument?.getSelection(); + if (!sel) return; + + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(anchorPoint.node, anchorPoint.offset); + range.setEnd(focusPoint.node, focusPoint.offset); + sel.addRange(range); + } + + protected getInlineDecorationsForBlock(): readonly InlineDecoration[] { + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) { + return []; + } + return this.editor + .getDecorations() + .forBlock(blockId) + .filter( + (decoration): decoration is InlineDecoration => + decoration.type === "inline", + ); + } + +} diff --git a/packages/rendering/dom/src/field-editor/editContextDom.ts b/packages/rendering/dom/src/field-editor/editContextDom.ts new file mode 100644 index 0000000..fc0d597 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextDom.ts @@ -0,0 +1,151 @@ +import type { FieldEditorTextChangeEvent } from "./crdt"; + +export type EditContextTextFormat = { + rangeStart: number; + rangeEnd: number; + underlineStyle?: string; + underlineThickness?: string; +}; + +const ZERO_WIDTH_SPACE = "\u200B"; + +export function applyEditContextTextFormats( + element: HTMLElement, + ranges: readonly EditContextTextFormat[], +): void { + for (const fmt of ranges) { + const { rangeStart, rangeEnd, underlineStyle, underlineThickness } = + fmt; + if (!underlineStyle) continue; + + const inlineEls = element.querySelectorAll("[data-pen-inline-content]"); + for (const el of inlineEls) { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let offset = 0; + let textNode: Text | null; + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0; + const segStart = offset; + const segEnd = offset + len; + if (segEnd > rangeStart && segStart < rangeEnd) { + const parentEl = textNode.parentElement; + if (parentEl) { + parentEl.style.textDecoration = underlineStyle; + if (underlineThickness) { + parentEl.style.textDecorationThickness = + underlineThickness; + } + } + } + offset += len; + } + } + } +} + +export function buildEditContextCharacterBounds( + element: HTMLElement, + rangeStart: number, + rangeEnd: number, +): DOMRect[] { + const rects: DOMRect[] = []; + for (let index = rangeStart; index < rangeEnd; index += 1) { + rects.push(getCharacterRect(element, index)); + } + return rects; +} + +export function findTextPosition( + container: HTMLElement, + charOffset: number, +): { node: Node; offset: number } | null { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + ); + let remaining = charOffset; + let textNode: Text | null; + + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0; + if (remaining <= len) { + return { node: textNode, offset: remaining }; + } + remaining -= len; + } + + const last = container.lastChild; + if (last) { + return { node: last, offset: last.textContent?.length ?? 0 }; + } + return { node: container, offset: 0 }; +} + +export function isLogicallyEmptyText(text: string): boolean { + return text.length === 0 || text === ZERO_WIDTH_SPACE; +} + +export function toEditContextText(text: string): string { + return text === ZERO_WIDTH_SPACE ? "" : text; +} + +export function shouldReplaceEditContextText( + delta: FieldEditorTextChangeEvent["delta"], + editContextTextLength: number, +): boolean { + let offset = 0; + for (const entry of delta) { + if (entry.retain != null) { + offset += entry.retain; + if (offset > editContextTextLength) return true; + } else if (typeof entry.insert === "string") { + if (entry.insert === ZERO_WIDTH_SPACE) return true; + if (offset > editContextTextLength) return true; + offset += entry.insert.length; + } else if (entry.delete != null) { + if (offset + entry.delete > editContextTextLength) return true; + } + } + return false; +} + +export function isNavigationSelectionKey(event: KeyboardEvent): boolean { + return ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" + ); +} + +function getCharacterRect(element: HTMLElement, charOffset: number): DOMRect { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + ); + let remaining = charOffset; + let textNode: Text | null; + + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0; + if (remaining < len) { + const range = document.createRange(); + range.setStart(textNode, remaining); + range.setEnd(textNode, remaining + 1); + return range.getBoundingClientRect(); + } + remaining -= len; + } + + return element.getBoundingClientRect(); +} diff --git a/packages/rendering/dom/src/field-editor/editContextTypes.ts b/packages/rendering/dom/src/field-editor/editContextTypes.ts new file mode 100644 index 0000000..84e97f1 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextTypes.ts @@ -0,0 +1,39 @@ +import type { EditContextTextFormat } from "./editContextDom"; + +export type EditContextTextUpdateEvent = Event & { + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; +}; + +export type EditContextTextFormatUpdateEvent = Event & { + getTextFormats?(): EditContextTextFormat[]; +}; + +export type EditContextCharacterBoundsUpdateEvent = Event & { + rangeStart: number; + rangeEnd: number; +}; + +export interface EditContext { + updateText(start: number, end: number, text: string): void; + updateSelection(start: number, end: number): void; + updateCharacterBounds(start: number, rects: DOMRect[]): void; + addEventListener(type: string, handler: (event: Event) => void): void; + removeEventListener(type: string, handler: (event: Event) => void): void; + readonly text: string; + readonly selectionStart: number; + readonly selectionEnd: number; +} + +export type EditContextConstructor = new (options?: { + text?: string; + selectionStart?: number; + selectionEnd?: number; +}) => EditContext; + +export type EditContextGlobal = typeof globalThis & { + EditContext?: EditContextConstructor; +}; diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index 7029a33..f93f782 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -1,1443 +1,4 @@ -import type { - FieldEditor, - Editor, - BlockSchema, - HistoryAppliedEvent, - SelectionState, - Unsubscribe, -} from "@pen/types"; -import { DocumentRangeImpl } from "@pen/core"; -import { - hasFieldEditorSurface, - resolveFieldEditorInputMode, - usesInlineTextSelection, -} from "@pen/types"; -import { EditContextBackend } from "./editContextBackend"; -import { ContentEditableBackend } from "./contenteditableBackend"; -import { - BackendLifecycleController, - type InputBackendConstructor, -} from "./backendLifecycleController"; -import { CellEditingController } from "./cellEditingController"; -import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; -import { FocusController } from "./focusController"; -import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; -import { PendingMarkController } from "./pendingMarkController"; -import { SelectAllController } from "./selectAllController"; -import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; -import type { - FieldEditorSelectionSnapshot, - FieldEditorSelectionSource, -} from "./selectionAuthority"; -import { SessionReconciler } from "./sessionReconciler"; -import { classifySelectionSurface } from "./crossBlock"; -import type { - ActiveCellCoord, - FieldEditorFocusReason, - FieldEditorInputController, - FieldEditorSession, - PenFieldEditorFocusOptions, - PenFocusLifecycleEvent, - PenFocusLifecycleListener, - PenFocusPolicy, -} from "./controller"; -import { getCellYText, getResolvedYText } from "./contentResolution"; -import type { FieldEditorTextLike } from "./crdt"; -import { - domSelectionToEditor, - queryBlockElement, - queryInlineElement, -} from "./selectionBridge"; -import { - getEditorBlockSelectionLength, - getEditorBlockSelectionRole, -} from "../utils/blockSelectionSemantics"; -import { - getEditorFlowCapability, - shouldForceBlockScopedSelectAll, -} from "../utils/flowCapabilities"; -import type { FieldEditorStoreSnapshot } from "./store"; -import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import type { FieldEditorSession } from "./controller"; +import { FieldEditorImplRuntime } from "./fieldEditorImplRuntime"; -type FieldEditorOptions = { - selectAllBehavior?: EditorSelectAllBehavior; - focusPolicy?: PenFocusPolicy; -}; - -export class FieldEditorImpl implements FieldEditorSession { - private _focusBlockId: string | null = null; - private _activeBlockIds: string[] = []; - private _attachedElement: HTMLElement | null = null; - private _isEditing = false; - private _isFocused = false; - private _isComposing = false; - private _suppressNextBackendActivationFocus = false; - private _inputMode: "richtext" | "code" | "table" | "none" = "none"; - private _mode: "inactive" | "single" | "expanded" | "block" = "inactive"; - private _editor: Editor; - private _rootElement: HTMLElement | null = null; - private _activateListeners = new Set<(blockIds: string[]) => void>(); - private _deactivateListeners = new Set<(blockIds: string[]) => void>(); - private _storeListeners = new Set<() => void>(); - private _unsubscribeSelection: Unsubscribe | null = null; - private _unsubscribeHistoryApplied: Unsubscribe | null = null; - private _domSyncVersion = 0; - private readonly _sessionReconciler: SessionReconciler; - private readonly _backendLifecycle: BackendLifecycleController; - private readonly _focusController: FocusController; - private readonly _cellEditingController: CellEditingController; - private readonly _historySelectionCoordinator: HistorySelectionCoordinator; - private readonly _pendingMarkController: PendingMarkController; - private readonly _selectAllController: SelectAllController; - private readonly _selectionCoordinator: FieldEditorSelectionCoordinator; - - constructor(editor: Editor, options?: FieldEditorOptions) { - this._editor = editor; - this._backendLifecycle = new BackendLifecycleController( - this._editor, - this, - ); - this._selectAllController = new SelectAllController( - options?.selectAllBehavior, - ); - this._focusController = new FocusController({ - editor: this._editor, - getRootElement: () => this._findEditorRoot(), - getFocusBlockId: () => this._focusBlockId, - getAttachedElement: () => this._attachedElement, - }); - this._focusController.setFocusPolicy(options?.focusPolicy); - this._cellEditingController = new CellEditingController({ - getRootElement: () => this._findEditorRoot(), - getYTextForCell: (blockId, row, col) => - this._getYTextForCell(blockId, row, col), - attachElement: (element) => this.attachElement(element), - requestDomFocus: (target, reason, focusOptions, policyOptions) => - this.requestDomFocus( - target, - reason, - focusOptions, - policyOptions, - ), - }); - this._pendingMarkController = new PendingMarkController({ - editor: this._editor, - getFocusBlockId: () => this._focusBlockId, - getYText: (blockId) => this._getYText(blockId), - emitStateChange: () => this._emitStateChange(), - }); - this._historySelectionCoordinator = new HistorySelectionCoordinator( - this._editor, - ); - this._selectionCoordinator = new FieldEditorSelectionCoordinator({ - historySelectionCoordinator: this._historySelectionCoordinator, - isEditing: () => this._isEditing, - getMode: () => this._mode, - getFocusBlockId: () => this._focusBlockId, - getAttachedElement: () => this._attachedElement, - getRootElement: () => this._findEditorRoot(), - findExpandedHost: () => this._findExpandedHost(), - resolveInlineElement: (blockId) => - this._resolveInlineElement(blockId), - attachElement: (element, focusOptions) => - this.attachElement(element, focusOptions), - requestDomFocus: (target, reason, focusOptions, policyOptions) => - this.requestDomFocus( - target, - reason, - focusOptions, - policyOptions, - ), - updateBackendSelection: () => { - this._backendLifecycle.updateSelection(null); - }, - setTextSelection: (blockId, anchorOffset, focusOffset) => - this.setTextSelection(blockId, anchorOffset, focusOffset), - activate: (blockId) => this.activate(blockId), - emitSelectionProjected: () => { - this._emitFocusLifecycle({ - type: "selection-projected", - editor: this._editor, - blockId: this._focusBlockId, - }); - }, - }); - this._unsubscribeSelection = this._editor.onSelectionChange( - (selection) => { - this._selectAllController.consumeShouldPreserveCycle( - selection, - (cycle, nextSelection) => - this._selectionMatchesSelectAllCycle( - cycle, - nextSelection, - ), - ); - if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock - ) { - this._pendingMarkController.clear(true); - } - const suppressSelectionSync = - this._selectionCoordinator.consumeDomSelectionProjectionSuppression() || - this._selectionCoordinator.shouldSuppressSelectionSync(); - this._recomputeSurfaceFromSelection({ - syncSelectionToBackend: !suppressSelectionSync, - }); - }, - ); - this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( - (event) => { - this._handleHistoryApplied(event); - }, - ); - this._sessionReconciler = new SessionReconciler(this._editor, { - getSnapshot: () => this.getSnapshot(), - getAttachedElement: () => this._attachedElement, - getInlineElement: (blockId) => this._resolveInlineElement(blockId), - getYText: (blockId) => this._getYText(blockId), - shouldPreserveSelection: () => - this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), - shouldProjectSelection: () => - this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), - projectSelection: () => - this._selectionCoordinator.syncDomSelectionOnce(), - notifyDomReconciled: (blockId) => this.notifyDomReconciled(blockId), - }); - } - - get focusBlockId(): string | null { - return this._focusBlockId; - } - get activeBlockIds(): readonly string[] { - return this._activeBlockIds; - } - get isEditing(): boolean { - return this._isEditing; - } - get isFocused(): boolean { - return this._isFocused; - } - get isComposing(): boolean { - return this._isComposing; - } - get inputMode(): "richtext" | "code" | "table" | "none" { - return this._inputMode; - } - get selection(): SelectionState | null { - return this._isEditing ? this._editor.selection : null; - } - set selection(sel: SelectionState | null) { - this._editor.setSelection(sel); - this._emitStateChange(); - } - get activeCellCoord(): ActiveCellCoord | null { - return this._cellEditingController.activeCellCoord; - } - - setSelectAllBehavior(behavior: EditorSelectAllBehavior): void { - this._selectAllController.setBehavior(behavior); - } - - setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { - this._focusController.setFocusPolicy(focusPolicy); - } - - // ── Lifecycle ───────────────────────────────────────────── - - activate(blockId: string): void { - if (this._focusBlockId === blockId) return; - this._startSession(blockId, { - stopCapturing: true, - syncSelectionToBackend: true, - attachImmediately: true, - }); - } - - activateCell(blockId: string, row: number, col: number): void { - this._activateCell(blockId, row, col); - this._attachedElement = null; - this._cellEditingController.trySyncBackend(); - } - - activateCellFromElement( - blockId: string, - row: number, - col: number, - element: HTMLElement, - ): void { - this._activateCell(blockId, row, col); - this.attachElement(element); - this._cellEditingController.placeCaretInCell(element); - } - - private _activateCell(blockId: string, row: number, col: number): void { - this._cellEditingController.setActiveCell(blockId, row, col); - if (!this._isEditing || this._focusBlockId !== blockId) { - this._startSession(blockId, { - stopCapturing: true, - syncSelectionToBackend: false, - attachImmediately: false, - }); - } - this._inputMode = "table"; - this._emitStateChange(); - } - - deactivate(): void { - this._deactivate({ restoreFocus: true }); - } - - selectAll(rootElement?: HTMLElement | null): boolean { - const activeCellElement = this._resolveActiveCellElement(rootElement); - if (activeCellElement) { - const activeCellBlockId = - this._cellEditingController.activeCellCoord?.blockId ?? - this._resolveSelectAllBlockId(rootElement); - const shouldSelectCellContents = - !isDomSelectionCoveringElementContents(activeCellElement) || - !this._selectAllController.hasScope(activeCellBlockId, "cell"); - if (shouldSelectCellContents) { - if ( - this._attachedElement !== activeCellElement || - !this._attachedElement?.isConnected - ) { - this.attachElement(activeCellElement); - } - this._selectElementContents(activeCellElement); - if (activeCellBlockId) { - this._selectAllController.recordScope( - activeCellBlockId, - "cell", - ); - } - return true; - } - } - - if (this._selectAllController.getBehavior() === "document-first") { - const activeBlockId = this._resolveSelectAllBlockId(rootElement); - const activeCapability = activeBlockId - ? getEditorFlowCapability(this._editor, activeBlockId) - : null; - if ( - !shouldForceBlockScopedSelectAll( - this._editor.documentProfile, - activeCapability, - ) - ) { - return this._selectEntireDocument(); - } - } - - const blockId = this._resolveSelectAllBlockId(rootElement); - if (blockId) { - const blockLength = getEditorBlockSelectionLength( - this._editor, - blockId, - ); - const blockRole = getEditorBlockSelectionRole( - this._editor, - blockId, - ); - const shouldSelectDocument = - blockLength === 0 || - this._selectAllController.hasScope(blockId, "block"); - const nextScope = shouldSelectDocument ? "document" : "block"; - if (nextScope === "block") { - if (blockRole && blockRole !== "editable-inline") { - this.deactivate(); - this._editor.selectBlock(blockId); - this._selectAllController.recordScope(blockId, "block"); - return true; - } - this.commitProgrammaticTextSelection(blockId, 0, blockLength); - this._selectAllController.recordScope(blockId, "block"); - return true; - } - } - - return this._selectEntireDocument(blockId ?? null); - } - - private _selectEntireDocument(blockId?: string | null): boolean { - const range = getFullDocumentTextRange(this._editor); - if (!range) { - return true; - } - - if (range.start.blockId === range.end.blockId) { - this.commitProgrammaticTextSelection( - range.start.blockId, - range.start.offset, - range.end.offset, - ); - } else { - if (!this._isEditing) { - this.activate(range.focusBlockId); - } - this._editor.selectTextRange(range.start, range.end); - } - this._recomputeSurfaceFromSelection(); - if (this._selectAllController.getBehavior() === "block-first") { - this._selectAllController.recordScope( - blockId ?? range.focusBlockId, - "document", - ); - } - this._syncSelectionToDOM(); - return true; - } - - suspendForPointerSelection(): void { - if (this._isComposing) return; - this._deactivate({ restoreFocus: false }); - } - - beginPointerSelection(): void { - this._selectionCoordinator.beginPointerSelection(); - } - - endPointerSelection(): void { - this._selectionCoordinator.endPointerSelection(); - } - - setComposing(composing: boolean): void { - if (this._isComposing === composing) return; - this._isComposing = composing; - this._emitStateChange(); - } - - private _deactivate(options: { restoreFocus: boolean }): void { - if (!this._isEditing) return; - - const blockIds = [...this._activeBlockIds]; - const focusTargetId = this._focusBlockId ?? blockIds[0] ?? null; - this._backendLifecycle.deactivate(); - this._attachedElement = null; - this._cellEditingController.clear(); - - this._focusBlockId = null; - this._activeBlockIds = []; - this._isEditing = false; - this._isComposing = false; - this._historySelectionCoordinator.reset(); - this._selectionCoordinator.reset(); - this._inputMode = "none"; - this._mode = "inactive"; - this._pendingMarkController.reset(); - - for (const cb of this._deactivateListeners) cb(blockIds); - this._emitFocusLifecycle({ - type: "activation-changed", - editor: this._editor, - activeBlockIds: [], - isEditing: false, - }); - if (options.restoreFocus) { - this._restoreFocusAfterDeactivate(focusTargetId); - } - this._emitStateChange(); - } - - focus(options: PenFieldEditorFocusOptions = {}): boolean { - if (!this._isEditing || !this._focusBlockId) return false; - const root = this._findEditorRoot(); - - if (!root) return false; - - const blockEl = queryBlockElement(root, this._focusBlockId); - const inlineEl = blockEl?.querySelector( - "[data-pen-inline-content]", - ) as HTMLElement | null; - - if (!inlineEl) return false; - - const selection = this._editor.selection; - if ( - !this.requestDomFocus( - inlineEl, - "activate", - { - preventScroll: false, - }, - options, - ) - ) { - return false; - } - - if ( - selection?.type === "text" && - selection.anchor.blockId === this._focusBlockId && - selection.focus.blockId === this._focusBlockId - ) { - this._backendLifecycle.updateSelection(null); - return true; - } - - const nativeSelection = root.ownerDocument?.getSelection(); - if (!nativeSelection) return true; - - const range = root.ownerDocument.createRange(); - range.selectNodeContents(inlineEl); - range.collapse(false); - - nativeSelection.removeAllRanges(); - nativeSelection.addRange(range); - return true; - } - - blur(): void { - this._focusController.blur(); - } - - requestDomFocus( - target: HTMLElement, - reason: FieldEditorFocusReason, - options?: FocusOptions, - policyOptions: PenFieldEditorFocusOptions = {}, - ): boolean { - if ( - reason === "backend-activate" && - this._suppressNextBackendActivationFocus - ) { - return true; - } - return this._focusController.requestDomFocus( - target, - reason, - options, - policyOptions, - ); - } - - requestActivation( - target: HTMLElement, - reason: FieldEditorFocusReason, - options: PenFieldEditorFocusOptions = {}, - ): boolean { - return this._focusController.requestActivation(target, reason, options); - } - - requestRootFocus( - target: HTMLElement, - reason: FieldEditorFocusReason, - options?: FocusOptions, - ): boolean { - return this._focusController.requestRootFocus(target, reason, options); - } - - setRootElement(element: HTMLElement | null): void { - this._rootElement = element; - if (element) { - this._focusController.notifyRootAttached(element); - } - if (element && this._isEditing) { - this._syncActiveElement(false); - } - } - - setFocused(focused: boolean): void { - if (this._isFocused === focused) return; - this._isFocused = focused; - this._emitStateChange(); - } - - private _findEditorRoot(): HTMLElement | null { - if (!this._rootElement?.isConnected) return null; - return this._rootElement; - } - - private _findExpandedHost(): HTMLElement | null { - const root = this._findEditorRoot(); - if (!root) return null; - return root.querySelector( - "[data-pen-editor-blocks-host]", - ) as HTMLElement | null; - } - - attachElement( - element: HTMLElement, - options: PenFieldEditorFocusOptions = {}, - ): boolean { - if (!this._focusBlockId) return false; - if (this._attachedElement === element && this._backendLifecycle.current) - return true; - if (!this.requestActivation(element, "backend-attach", options)) - return false; - this._emitFocusLifecycle({ - type: "backend-attach-started", - editor: this._editor, - target: element, - blockId: this._focusBlockId, - }); - this._backendLifecycle.replace(this._resolveBackendClass()); - - const ytext = this._getYText(this._focusBlockId); - if (!ytext) return false; - - this._suppressNextBackendActivationFocus = - options.domFocus === false || options.passive === true; - try { - this._backendLifecycle.activate(element, ytext); - } finally { - this._suppressNextBackendActivationFocus = false; - } - this._attachedElement = element; - this._emitFocusLifecycle({ - type: "backend-attach-completed", - editor: this._editor, - target: element, - blockId: this._focusBlockId, - }); - this._focusController.resolveAttachmentWaiters(); - return true; - } - - syncTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - if (!this._isEditing) return; - if (this._focusBlockId !== blockId) return; - - if ( - this._selectionCoordinator.prepareSyncedTextSelection( - this._editor.selection, - blockId, - anchorOffset, - focusOffset, - ) === "skip" - ) { - return; - } - this.setTextSelection(blockId, anchorOffset, focusOffset); - } - - applyDocumentTextSelection( - anchor: { blockId: string; offset: number }, - focus: { blockId: string; offset: number }, - ): void { - this._selectionCoordinator.recordUserSelectionIntent(); - this._selectionCoordinator.suppressNextDomSelectionProjection(); - - if (!this._isEditing || !this._focusBlockId) { - this._startSession(anchor.blockId, { - stopCapturing: false, - syncSelectionToBackend: false, - attachImmediately: false, - }); - } else { - const blockRange = new DocumentRangeImpl( - anchor, - focus, - this._editor.internals.doc, - ).blockRange; - if (!blockRange.includes(this._focusBlockId)) { - this._focusBlockId = anchor.blockId; - } - } - - this._editor.selectTextRange(anchor, focus); - this._emitStateChange(); - } - - applyDomTextSelection( - anchor: { blockId: string; offset: number }, - focus: { blockId: string; offset: number }, - options?: { - focusBlockId?: string; - }, - ): void { - if (anchor.blockId !== focus.blockId) { - this.applyDocumentTextSelection(anchor, focus); - return; - } - - const isProgrammaticDomSelection = - this._selectionCoordinator.isProgrammaticDomTextSelection( - anchor, - focus, - ); - if (!isProgrammaticDomSelection) { - this._selectionCoordinator.recordUserSelectionIntent(); - } - this._selectionCoordinator.suppressNextDomSelectionProjection(); - - if ( - anchor.blockId === focus.blockId && - (!this._isEditing || this._focusBlockId !== anchor.blockId) - ) { - this._startSession(anchor.blockId, { - stopCapturing: false, - syncSelectionToBackend: false, - attachImmediately: false, - }); - } - - if (anchor.blockId === focus.blockId) { - this.setTextSelection(anchor.blockId, anchor.offset, focus.offset); - return; - } - - if (options?.focusBlockId) { - this._focusBlockId = options.focusBlockId; - } - this._editor.selectTextRange(anchor, focus); - this._emitStateChange(); - } - - shouldHandleDomSelectionChange(isApplyingSelection: number): boolean { - return this._selectionCoordinator.shouldHandleDomSelectionChange( - this._focusBlockId, - isApplyingSelection, - ); - } - - resetBackendSelectionAuthority(): void { - this._selectionCoordinator.resetAuthority(); - } - - setBackendSelectionAuthority( - source: FieldEditorSelectionSource, - selection: FieldEditorSelectionSnapshot | null, - ): void { - this._selectionCoordinator.setAuthoritySelection(source, selection); - } - - getBackendSelectionAuthority( - source: FieldEditorSelectionSource, - blockId?: string | null, - ): FieldEditorSelectionSnapshot | null { - return this._selectionCoordinator.getAuthoritySelection( - source, - blockId, - ); - } - - hasBackendSelectionAuthority(source: FieldEditorSelectionSource): boolean { - return this._selectionCoordinator.hasAuthoritySelection(source); - } - - clearBackendSelectionAuthority(source: FieldEditorSelectionSource): void { - this._selectionCoordinator.clearAuthoritySelection(source); - } - - applyBackendSelectionUntilNextFrame(): void { - this._selectionCoordinator.applySelectionUntilNextFrame(); - } - - getBackendSelectionApplicationDepth(): number { - return this._selectionCoordinator.isApplyingSelection; - } - - setEditContextSelectionSnapshot( - selection: FieldEditorSelectionSnapshot | null, - ): void { - this._selectionCoordinator.setEditContextSelection(selection); - } - - getEditContextSelectionSnapshot( - blockId?: string | null, - ): FieldEditorSelectionSnapshot | null { - return this._selectionCoordinator.getEditContextSelection(blockId); - } - - resolveProgrammaticInputRange( - blockId: string | null, - liveRange: { start: number; end: number } | null, - ): { start: number; end: number } | null { - return this._selectionCoordinator.resolveProgrammaticInputRange( - blockId, - liveRange, - ); - } - - shouldIgnoreDomTextSelection( - anchor: { blockId: string; offset: number }, - focus: { blockId: string; offset: number }, - ): boolean { - return this._selectionCoordinator.shouldIgnoreDomTextSelection( - anchor, - focus, - ); - } - - setTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - if (anchorOffset !== focusOffset) { - this._pendingMarkController.clear(true); - } - this._editor.selectText(blockId, anchorOffset, focusOffset); - this._selectionCoordinator.notifyTextSelectionSet( - blockId, - anchorOffset, - focusOffset, - ); - this._emitStateChange(); - } - - activateTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - options?: PenFieldEditorFocusOptions, - ): void { - this._selectionCoordinator.activateTextSelection( - blockId, - anchorOffset, - focusOffset, - options, - ); - } - - async focusTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - options: PenFieldEditorFocusOptions = {}, - ): Promise { - this.commitProgrammaticTextSelection( - blockId, - anchorOffset, - focusOffset, - options, - ); - const attached = await this.waitForAttachment(blockId); - if (!attached) { - return false; - } - if (options.domFocus === false || options.passive) { - return true; - } - const focused = this.focus(options); - this.commitProgrammaticTextSelection( - blockId, - anchorOffset, - focusOffset, - ); - return focused; - } - - commitProgrammaticTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - options?: PenFieldEditorFocusOptions, - ): void { - this._selectionCoordinator.commitProgrammaticTextSelection( - blockId, - anchorOffset, - focusOffset, - options, - ); - } - - collapseSelectionToFocus(): void { - const selection = this._editor.selection; - if (selection?.type !== "text") return; - - this._collapseAndProject(selection.focus); - } - - collapseSelectionToAnchor(): void { - const selection = this._editor.selection; - if (selection?.type !== "text") return; - - this._collapseAndProject(selection.anchor); - } - - collapseSelectionToPoint(point: { blockId: string; offset: number }): void { - this._collapseAndProject(point); - } - - private _collapseAndProject(point: { - blockId: string; - offset: number; - }): void { - this.setTextSelection(point.blockId, point.offset, point.offset); - - if (!this._isEditing || this._focusBlockId !== point.blockId) { - this.activate(point.blockId); - } - - this._selectionCoordinator.syncDomSelectionOnce(); - } - - delegate(blockSchema: BlockSchema): boolean { - return hasFieldEditorSurface(blockSchema); - } - - getPendingMarks(): Readonly> { - return this._pendingMarkController.getSnapshot(); - } - - clearPendingMarks(): void { - this._pendingMarkController.clear(); - } - - resetSelectAllCycle(): void { - this._selectAllController.resetCycle(); - } - - private _syncSelectionToDOM(): void { - if (!this._isEditing) return; - this._selectionCoordinator.syncDomSelectionOnce(); - } - - private _resolveSelectAllBlockId( - rootElement?: HTMLElement | null, - ): string | null { - const selection = this._editor.selection; - if (selection?.type === "text" && !selection.isMultiBlock) { - return selection.focus.blockId; - } - if ( - this._selectAllController.getBehavior() === "block-first" && - selection?.type === "block" && - selection.blockIds.length === 1 - ) { - return selection.blockIds[0] ?? null; - } - if (selection?.type === "cell") { - return selection.blockId; - } - - if (this._focusBlockId) { - return this._focusBlockId; - } - - const root = rootElement ?? this._findEditorRoot(); - if (!root) { - return null; - } - - const domSelection = domSelectionToEditor(root); - if ( - domSelection && - domSelection.anchor.blockId === domSelection.focus.blockId - ) { - return domSelection.focus.blockId; - } - - const activeElement = root.ownerDocument?.activeElement; - if (activeElement instanceof HTMLElement) { - return ( - activeElement - .closest("[data-block-id]") - ?.getAttribute("data-block-id") ?? null - ); - } - - return null; - } - - private _selectionMatchesSelectAllCycle( - cycle: { blockId: string; scope: "cell" | "block" | "document" }, - selection: SelectionState | null, - ): boolean { - if (cycle.scope === "cell") { - return ( - selection?.type === "cell" && - selection.blockId === cycle.blockId - ); - } - - if (cycle.scope === "block") { - const blockLength = getEditorBlockSelectionLength( - this._editor, - cycle.blockId, - ); - const blockRole = getEditorBlockSelectionRole( - this._editor, - cycle.blockId, - ); - if (blockRole && blockRole !== "editable-inline") { - return ( - selection?.type === "block" && - selection.blockIds.length === 1 && - selection.blockIds[0] === cycle.blockId - ); - } - - if (selection?.type !== "text") { - return false; - } - return ( - !selection.isMultiBlock && - selection.anchor.blockId === cycle.blockId && - selection.focus.blockId === cycle.blockId && - Math.min(selection.anchor.offset, selection.focus.offset) === - 0 && - Math.max(selection.anchor.offset, selection.focus.offset) === - blockLength - ); - } - - const range = getFullDocumentTextRange(this._editor); - if (!range) { - return false; - } - - if (selection?.type !== "text") { - return false; - } - - return ( - selection.isMultiBlock && - ((pointsEqual(selection.anchor, range.start) && - pointsEqual(selection.focus, range.end)) || - (pointsEqual(selection.anchor, range.end) && - pointsEqual(selection.focus, range.start))) - ); - } - - togglePendingMark(markType: string): boolean { - return this._pendingMarkController.toggle( - markType, - this._isEditing, - this._inputMode, - ); - } - - resolveInsertMarks( - ytext: FieldEditorTextLike, - offset: number, - ): Record | undefined { - return this._pendingMarkController.resolveInsertMarks(ytext, offset); - } - - // ── Cross-block expansion ──────────────────────────────── - - expandTo(blockId: string): void { - if (!this._isEditing || !this._focusBlockId) return; - - const selection = this._editor.selection; - const anchor = - selection?.type === "text" && - selection.blockRange.includes(this._focusBlockId) - ? selection.anchor - : { blockId: this._focusBlockId, offset: 0 }; - const doc = this._editor.documentState; - const activeIdx = doc.indexOf(this._focusBlockId); - const targetIdx = doc.indexOf(blockId); - if (activeIdx < 0 || targetIdx < 0) return; - - const targetOffset = - targetIdx >= activeIdx - ? (this._editor.getBlock(blockId)?.length() ?? 0) - : 0; - - this._editor.selectTextRange(anchor, { - blockId, - offset: targetOffset, - }); - } - - contractToFocused(): void { - if (!this._isEditing || !this._focusBlockId) return; - - const selection = this._editor.selection; - if (selection?.type !== "text") return; - - this._editor.selectTextRange(selection.focus, selection.focus); - } - - // ── Events ─────────────────────────────────────────────── - - onActivate(cb: (blockIds: string[]) => void): Unsubscribe { - this._activateListeners.add(cb); - return () => this._activateListeners.delete(cb); - } - - onDeactivate(cb: (blockIds: string[]) => void): Unsubscribe { - this._deactivateListeners.add(cb); - return () => this._deactivateListeners.delete(cb); - } - - onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { - return this._focusController.onFocusLifecycle(listener); - } - - onSelectionChange(cb: (sel: SelectionState) => void): Unsubscribe { - return this._editor.onSelectionChange(cb); - } - - getSnapshot(): FieldEditorStoreSnapshot { - return { - focusBlockId: this._focusBlockId, - activeBlockIds: this._activeBlockIds, - isEditing: this._isEditing, - isFocused: this._isFocused, - isComposing: this._isComposing, - domSyncVersion: this._domSyncVersion, - inputMode: this._inputMode, - mode: this._mode, - activeCellCoord: this._cellEditingController.activeCellCoord, - }; - } - - notifyDomReconciled(_blockId?: string): void { - this._domSyncVersion += 1; - this._emitStateChange(); - } - - subscribe(callback: () => void): Unsubscribe { - this._storeListeners.add(callback); - return () => this._storeListeners.delete(callback); - } - - waitForAttachment(blockId = this._focusBlockId): Promise { - return this._focusController.waitForAttachment(blockId); - } - - destroy(): void { - this._unsubscribeSelection?.(); - this._unsubscribeSelection = null; - this._unsubscribeHistoryApplied?.(); - this._unsubscribeHistoryApplied = null; - this._sessionReconciler.destroy(); - this._deactivate({ restoreFocus: false }); - this._activateListeners.clear(); - this._deactivateListeners.clear(); - this._storeListeners.clear(); - this._focusController.destroy(); - } - - // ── Internal ───────────────────────────────────────────── - - private _resolveBackendClass(): InputBackendConstructor { - if (this._mode === "expanded") { - return ExpandedContentEditableBackend as unknown as InputBackendConstructor; - } - if (this._cellEditingController.activeCellCoord) { - return ContentEditableBackend; - } - if ( - "EditContext" in globalThis && - typeof (globalThis as typeof globalThis & { EditContext?: unknown }) - .EditContext === "function" - ) { - return EditContextBackend; - } - return ContentEditableBackend; - } - - private _syncActiveElement(focus: boolean): void { - if (!this._focusBlockId) return; - const inlineEl = this._resolveInlineElement(this._focusBlockId); - if (!inlineEl) return; - - this.attachElement(inlineEl); - if (focus) { - this.focus(); - } - } - - private _restoreFocusAfterDeactivate(blockId: string | null): void { - this._focusController.restoreFocusAfterDeactivate(blockId); - } - - private _emitStateChange(): void { - for (const callback of this._storeListeners) { - callback(); - } - } - - private _emitFocusLifecycle(event: PenFocusLifecycleEvent): void { - this._focusController.emitLifecycle(event); - } - - private _recomputeSurfaceFromSelection(options?: { - syncSelectionToBackend?: boolean; - }): void { - const surface = classifySelectionSurface( - this._editor, - this._editor.selection, - this._focusBlockId, - this._isEditing, - ); - this._updateSurfaceState(surface.mode, surface.blockIds); - if (options?.syncSelectionToBackend ?? true) { - this._backendLifecycle.updateSelection(null); - } - } - - private _updateSurfaceState( - mode: "inactive" | "single" | "expanded" | "block", - blockIds: string[], - ): void { - const modeChanged = this._mode !== mode; - const blockIdsChanged = !areBlockIdsEqual( - this._activeBlockIds, - blockIds, - ); - if (!modeChanged && !blockIdsChanged) return; - this._mode = mode; - this._activeBlockIds = blockIds; - this._syncBackendForSurfaceMode(); - - if (this._isEditing && blockIdsChanged) { - for (const cb of this._activateListeners) cb([...blockIds]); - this._emitFocusLifecycle({ - type: "activation-changed", - editor: this._editor, - activeBlockIds: [...blockIds], - isEditing: true, - }); - } - - this._emitStateChange(); - } - - private _syncBackendForSurfaceMode(): void { - if (!this._isEditing || !this._focusBlockId) return; - const NextBackendClass = this._resolveBackendClass(); - if (this._backendLifecycle.hasBackend(NextBackendClass)) { - return; - } - - this._backendLifecycle.replace(NextBackendClass); - - if (this._mode === "expanded") { - const expandedHost = this._findExpandedHost(); - this._attachedElement = null; - if (expandedHost) { - this.attachElement(expandedHost); - } - return; - } - - if (this._mode === "single") { - const inlineEl = this._resolveInlineElement(this._focusBlockId); - if (inlineEl) { - this._attachedElement = null; - this.attachElement(inlineEl); - return; - } - } - - if (!this._attachedElement) return; - - const ytext = this._getYText(this._focusBlockId); - if (!ytext) return; - if (!this.requestActivation(this._attachedElement, "backend-attach")) { - return; - } - - this._backendLifecycle.activate(this._attachedElement, ytext); - } - - private _startSession( - blockId: string, - options: { - stopCapturing: boolean; - syncSelectionToBackend: boolean; - attachImmediately: boolean; - }, - ): boolean { - if (this._isEditing) this._deactivate({ restoreFocus: false }); - - const block = this._editor.getBlock(blockId); - if (!block) return false; - - const schema = this._editor.schema.resolve(block.type); - if (schema?.fieldEditor === "none") return false; - - this._focusBlockId = blockId; - this._activeBlockIds = [blockId]; - this._isEditing = true; - this._isComposing = false; - this._mode = "single"; - this._pendingMarkController.reset(); - - if (options.stopCapturing) { - this._editor.undoManager.stopCapturing(); - } - - this._inputMode = resolveInputMode(schema); - this._backendLifecycle.replace(this._resolveBackendClass()); - this._attachedElement = null; - if (options.attachImmediately) { - this._syncActiveElement(false); - } - this._recomputeSurfaceFromSelection({ - syncSelectionToBackend: options.syncSelectionToBackend, - }); - - for (const cb of this._activateListeners) cb([...this._activeBlockIds]); - this._emitFocusLifecycle({ - type: "activation-changed", - editor: this._editor, - activeBlockIds: [...this._activeBlockIds], - isEditing: true, - }); - this._emitStateChange(); - return true; - } - - private _handleHistoryApplied(event: HistoryAppliedEvent): void { - const selection = event.selection; - const nextFocusBlockId = - event.focusBlockId ?? - (selection?.type === "text" ? selection.focus.blockId : null); - if (selection?.type !== "text") { - if (this._isEditing) { - this._deactivate({ restoreFocus: false }); - } - return; - } - - if (!this._isEditing) { - return; - } - - if (nextFocusBlockId) { - this._focusBlockId = nextFocusBlockId; - } - - this._historySelectionCoordinator.beginDeferredProjection( - event.requestId, - ); - - this._recomputeSurfaceFromSelection({ - syncSelectionToBackend: false, - }); - } - - private _attachedElementOwnsFocus(): boolean { - return this._focusController.attachedElementOwnsFocus(); - } - - private _resolveInlineElement(blockId: string): HTMLElement | null { - const root = this._findEditorRoot(); - if (!root) return null; - const cellElement = - this._cellEditingController.resolveInlineElement(blockId); - if (cellElement) return cellElement; - return queryInlineElement(root, blockId); - } - - private _getYText(blockId: string): FieldEditorTextLike | null { - return getResolvedYText( - this._editor, - blockId, - this._cellEditingController.activeCellCoord, - ); - } - - private _getYTextForCell( - blockId: string, - row: number, - col: number, - ): FieldEditorTextLike | null { - return getCellYText(this._editor, blockId, row, col); - } - - private _selectElementContents(element: HTMLElement): void { - if ( - !this.requestDomFocus(element, "select-all", { - preventScroll: true, - }) - ) { - return; - } - const selection = element.ownerDocument?.getSelection(); - if (!selection) return; - - const range = element.ownerDocument.createRange(); - range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); - } - - private _resolveActiveCellElement( - rootElement?: HTMLElement | null, - ): HTMLElement | null { - return this._cellEditingController.resolveActiveCellElement( - rootElement, - ); - } -} - -function resolveInputMode( - schema?: BlockSchema | null, -): "richtext" | "code" | "table" | "none" { - return resolveFieldEditorInputMode(schema); -} - -function isDomSelectionCoveringElementContents(element: HTMLElement): boolean { - const selection = element.ownerDocument?.getSelection(); - if (!selection || selection.rangeCount === 0) { - return false; - } - - const range = selection.getRangeAt(0); - if ( - !element.contains(range.startContainer) || - !element.contains(range.endContainer) - ) { - return false; - } - - const fullRange = element.ownerDocument.createRange(); - fullRange.selectNodeContents(element); - return ( - range.compareBoundaryPoints(Range.START_TO_START, fullRange) === 0 && - range.compareBoundaryPoints(Range.END_TO_END, fullRange) === 0 - ); -} - -function areBlockIdsEqual( - left: readonly string[], - right: readonly string[], -): boolean { - if (left.length !== right.length) return false; - for (let i = 0; i < left.length; i++) { - if (left[i] !== right[i]) return false; - } - return true; -} - -function getFullDocumentTextRange(editor: Editor): { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - focusBlockId: string; -} | null { - const blockOrder = editor.documentState.blockOrder; - const firstBlockId = blockOrder[0]; - const lastBlockId = blockOrder[blockOrder.length - 1]; - if (!firstBlockId || !lastBlockId) { - return null; - } - - const focusBlockId = - blockOrder.find((blockId) => { - const block = editor.getBlock(blockId); - if (!block) return false; - const schema = editor.schema.resolve(block.type); - return usesInlineTextSelection(schema); - }) ?? firstBlockId; - - return { - start: { blockId: firstBlockId, offset: 0 }, - end: { - blockId: lastBlockId, - offset: getEditorBlockSelectionLength(editor, lastBlockId), - }, - focusBlockId, - }; -} - -function pointsEqual( - left: { blockId: string; offset: number }, - right: { blockId: string; offset: number }, -): boolean { - return left.blockId === right.blockId && left.offset === right.offset; -} +export class FieldEditorImpl extends FieldEditorImplRuntime implements FieldEditorSession {} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts new file mode 100644 index 0000000..833a404 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts @@ -0,0 +1,313 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; + +export type FieldEditorOptions = { + selectAllBehavior?: EditorSelectAllBehavior; + focusPolicy?: PenFocusPolicy; +}; + +export abstract class FieldEditorImplCore { + protected _focusBlockId: string | null = null; + protected _activeBlockIds: string[] = []; + protected _attachedElement: HTMLElement | null = null; + protected _isEditing = false; + protected _isFocused = false; + protected _isComposing = false; + protected _suppressNextBackendActivationFocus = false; + protected _inputMode: "richtext" | "code" | "table" | "none" = "none"; + protected _mode: "inactive" | "single" | "expanded" | "block" = "inactive"; + protected _editor: Editor; + protected _rootElement: HTMLElement | null = null; + protected _activateListeners = new Set<(blockIds: string[]) => void>(); + protected _deactivateListeners = new Set<(blockIds: string[]) => void>(); + protected _storeListeners = new Set<() => void>(); + protected _unsubscribeSelection: Unsubscribe | null = null; + protected _unsubscribeHistoryApplied: Unsubscribe | null = null; + protected _domSyncVersion = 0; + protected readonly _sessionReconciler: SessionReconciler; + protected readonly _backendLifecycle: BackendLifecycleController; + protected readonly _focusController: FocusController; + protected readonly _cellEditingController: CellEditingController; + protected readonly _historySelectionCoordinator: HistorySelectionCoordinator; + protected readonly _pendingMarkController: PendingMarkController; + protected readonly _selectAllController: SelectAllController; + protected readonly _selectionCoordinator: FieldEditorSelectionCoordinator; + + constructor(editor: Editor, options?: FieldEditorOptions) { + this._editor = editor; + this._backendLifecycle = new BackendLifecycleController( + this._editor, + this as unknown as FieldEditorInputController, + ); + this._selectAllController = new SelectAllController( + options?.selectAllBehavior, + ); + this._focusController = new FocusController({ + editor: this._editor, + getRootElement: () => this._findEditorRoot(), + getFocusBlockId: () => this._focusBlockId, + getAttachedElement: () => this._attachedElement, + }); + this._focusController.setFocusPolicy(options?.focusPolicy); + this._cellEditingController = new CellEditingController({ + getRootElement: () => this._findEditorRoot(), + getYTextForCell: (blockId, row, col) => + this._getYTextForCell(blockId, row, col), + attachElement: (element) => this.attachElement(element), + requestDomFocus: (target, reason, focusOptions, policyOptions) => + this.requestDomFocus( + target, + reason, + focusOptions, + policyOptions, + ), + }); + this._pendingMarkController = new PendingMarkController({ + editor: this._editor, + getFocusBlockId: () => this._focusBlockId, + getYText: (blockId) => this._getYText(blockId), + emitStateChange: () => this._emitStateChange(), + }); + this._historySelectionCoordinator = new HistorySelectionCoordinator( + this._editor, + ); + this._selectionCoordinator = new FieldEditorSelectionCoordinator({ + historySelectionCoordinator: this._historySelectionCoordinator, + isEditing: () => this._isEditing, + getMode: () => this._mode, + getFocusBlockId: () => this._focusBlockId, + getAttachedElement: () => this._attachedElement, + getRootElement: () => this._findEditorRoot(), + findExpandedHost: () => this._findExpandedHost(), + resolveInlineElement: (blockId) => + this._resolveInlineElement(blockId), + attachElement: (element, focusOptions) => + this.attachElement(element, focusOptions), + requestDomFocus: (target, reason, focusOptions, policyOptions) => + this.requestDomFocus( + target, + reason, + focusOptions, + policyOptions, + ), + updateBackendSelection: () => { + this._backendLifecycle.updateSelection(null); + }, + setTextSelection: (blockId, anchorOffset, focusOffset) => + this.setTextSelection(blockId, anchorOffset, focusOffset), + activate: (blockId) => this.activate(blockId), + emitSelectionProjected: () => { + this._emitFocusLifecycle({ + type: "selection-projected", + editor: this._editor, + blockId: this._focusBlockId, + }); + }, + }); + this._unsubscribeSelection = this._editor.onSelectionChange( + (selection) => { + this._selectAllController.consumeShouldPreserveCycle( + selection, + (cycle, nextSelection) => + this._selectionMatchesSelectAllCycle( + cycle, + nextSelection, + ), + ); + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { + this._pendingMarkController.clear(true); + } + const suppressSelectionSync = + this._selectionCoordinator.consumeDomSelectionProjectionSuppression() || + this._selectionCoordinator.shouldSuppressSelectionSync(); + this._recomputeSurfaceFromSelection({ + syncSelectionToBackend: !suppressSelectionSync, + }); + }, + ); + this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( + (event) => { + this._handleHistoryApplied(event); + }, + ); + this._sessionReconciler = new SessionReconciler(this._editor, { + getSnapshot: () => this.getSnapshot(), + getAttachedElement: () => this._attachedElement, + getInlineElement: (blockId) => this._resolveInlineElement(blockId), + getYText: (blockId) => this._getYText(blockId), + shouldPreserveSelection: () => + this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), + shouldProjectSelection: () => + this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), + projectSelection: () => + this._selectionCoordinator.syncDomSelectionOnce(), + notifyDomReconciled: (blockId) => this.notifyDomReconciled(blockId), + }); + } + + get focusBlockId(): string | null { + return this._focusBlockId; + } + get activeBlockIds(): readonly string[] { + return this._activeBlockIds; + } + get isEditing(): boolean { + return this._isEditing; + } + get isFocused(): boolean { + return this._isFocused; + } + get isComposing(): boolean { + return this._isComposing; + } + get inputMode(): "richtext" | "code" | "table" | "none" { + return this._inputMode; + } + get selection(): SelectionState | null { + return this._isEditing ? this._editor.selection : null; + } + set selection(sel: SelectionState | null) { + this._editor.setSelection(sel); + this._emitStateChange(); + } + get activeCellCoord(): ActiveCellCoord | null { + return this._cellEditingController.activeCellCoord; + } + + setSelectAllBehavior(behavior: EditorSelectAllBehavior): void { + this._selectAllController.setBehavior(behavior); + } + + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { + this._focusController.setFocusPolicy(focusPolicy); + } + + + abstract activate(blockId: string): void; + abstract attachElement( + element: HTMLElement, + options?: PenFieldEditorFocusOptions, + ): boolean; + abstract requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ): boolean; + abstract setTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; + abstract getSnapshot(): FieldEditorStoreSnapshot; + + abstract commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void; + abstract waitForAttachment(blockId?: string | null): Promise; + protected abstract _startSession( + blockId: string, + options: { + stopCapturing: boolean; + syncSelectionToBackend: boolean; + attachImmediately: boolean; + }, + ): boolean; + protected abstract _resolveActiveCellElement( + rootElement?: HTMLElement | null, + ): HTMLElement | null; + protected abstract _resolveSelectAllBlockId( + rootElement?: HTMLElement | null, + ): string | null; + protected abstract _selectElementContents(element: HTMLElement): void; + protected abstract _syncSelectionToDOM(): void; + protected abstract _restoreFocusAfterDeactivate( + blockId: string | null, + ): void; + protected abstract _syncActiveElement(focus: boolean): void; + protected abstract _resolveBackendClass(): InputBackendConstructor; + abstract notifyDomReconciled(blockId?: string): void; + protected abstract _findEditorRoot(): HTMLElement | null; + protected abstract _findExpandedHost(): HTMLElement | null; + protected abstract _getYTextForCell( + blockId: string, + row: number, + col: number, + ): FieldEditorTextLike | null; + protected abstract _getYText(blockId: string): FieldEditorTextLike | null; + protected abstract _resolveInlineElement(blockId: string): HTMLElement | null; + protected abstract _emitStateChange(): void; + protected abstract _emitFocusLifecycle(event: PenFocusLifecycleEvent): void; + protected abstract _selectionMatchesSelectAllCycle( + cycle: { blockId: string; scope: "cell" | "block" | "document" }, + selection: SelectionState | null, + ): boolean; + protected abstract _recomputeSurfaceFromSelection(options?: { + syncSelectionToBackend?: boolean; + }): void; + protected abstract _handleHistoryApplied(event: HistoryAppliedEvent): void; +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts new file mode 100644 index 0000000..de5c9f2 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts @@ -0,0 +1,79 @@ +import type { BlockSchema, Editor } from "@pen/types"; +import { usesInlineTextSelection, resolveFieldEditorInputMode } from "@pen/types"; +import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; + +export function resolveInputMode( + schema?: BlockSchema | null, +): "richtext" | "code" | "table" | "none" { + return resolveFieldEditorInputMode(schema); +} + +export function isDomSelectionCoveringElementContents(element: HTMLElement): boolean { + const selection = element.ownerDocument?.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + if ( + !element.contains(range.startContainer) || + !element.contains(range.endContainer) + ) { + return false; + } + + const fullRange = element.ownerDocument.createRange(); + fullRange.selectNodeContents(element); + return ( + range.compareBoundaryPoints(Range.START_TO_START, fullRange) === 0 && + range.compareBoundaryPoints(Range.END_TO_END, fullRange) === 0 + ); +} + +export function areBlockIdsEqual( + left: readonly string[], + right: readonly string[], +): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false; + } + return true; +} + +export function getFullDocumentTextRange(editor: Editor): { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + focusBlockId: string; +} | null { + const blockOrder = editor.documentState.blockOrder; + const firstBlockId = blockOrder[0]; + const lastBlockId = blockOrder[blockOrder.length - 1]; + if (!firstBlockId || !lastBlockId) { + return null; + } + + const focusBlockId = + blockOrder.find((blockId) => { + const block = editor.getBlock(blockId); + if (!block) return false; + const schema = editor.schema.resolve(block.type); + return usesInlineTextSelection(schema); + }) ?? firstBlockId; + + return { + start: { blockId: firstBlockId, offset: 0 }, + end: { + blockId: lastBlockId, + offset: getEditorBlockSelectionLength(editor, lastBlockId), + }, + focusBlockId, + }; +} + +export function pointsEqual( + left: { blockId: string; offset: number }, + right: { blockId: string; offset: number }, +): boolean { + return left.blockId === right.blockId && left.offset === right.offset; +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts new file mode 100644 index 0000000..3e5fad6 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts @@ -0,0 +1,417 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { DocumentRangeImpl } from "@pen/core"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import { FieldEditorImplCore } from "./fieldEditorImplCore"; +import { + getFullDocumentTextRange, + isDomSelectionCoveringElementContents, +} from "./fieldEditorImplHelpers"; + +export abstract class FieldEditorImplLifecycle extends FieldEditorImplCore { + activate(blockId: string): void { + if (this._focusBlockId === blockId) return; + this._startSession(blockId, { + stopCapturing: true, + syncSelectionToBackend: true, + attachImmediately: true, + }); + } + + activateCell(blockId: string, row: number, col: number): void { + this._activateCell(blockId, row, col); + this._attachedElement = null; + this._cellEditingController.trySyncBackend(); + } + + activateCellFromElement( + blockId: string, + row: number, + col: number, + element: HTMLElement, + ): void { + this._activateCell(blockId, row, col); + this.attachElement(element); + this._cellEditingController.placeCaretInCell(element); + } + + protected _activateCell(blockId: string, row: number, col: number): void { + this._cellEditingController.setActiveCell(blockId, row, col); + if (!this._isEditing || this._focusBlockId !== blockId) { + this._startSession(blockId, { + stopCapturing: true, + syncSelectionToBackend: false, + attachImmediately: false, + }); + } + this._inputMode = "table"; + this._emitStateChange(); + } + + deactivate(): void { + this._deactivate({ restoreFocus: true }); + } + + selectAll(rootElement?: HTMLElement | null): boolean { + const activeCellElement = this._resolveActiveCellElement(rootElement); + if (activeCellElement) { + const activeCellBlockId = + this._cellEditingController.activeCellCoord?.blockId ?? + this._resolveSelectAllBlockId(rootElement); + const shouldSelectCellContents = + !isDomSelectionCoveringElementContents(activeCellElement) || + !this._selectAllController.hasScope(activeCellBlockId, "cell"); + if (shouldSelectCellContents) { + if ( + this._attachedElement !== activeCellElement || + !this._attachedElement?.isConnected + ) { + this.attachElement(activeCellElement); + } + this._selectElementContents(activeCellElement); + if (activeCellBlockId) { + this._selectAllController.recordScope( + activeCellBlockId, + "cell", + ); + } + return true; + } + } + + if (this._selectAllController.getBehavior() === "document-first") { + const activeBlockId = this._resolveSelectAllBlockId(rootElement); + const activeCapability = activeBlockId + ? getEditorFlowCapability(this._editor, activeBlockId) + : null; + if ( + !shouldForceBlockScopedSelectAll( + this._editor.documentProfile, + activeCapability, + ) + ) { + return this._selectEntireDocument(); + } + } + + const blockId = this._resolveSelectAllBlockId(rootElement); + if (blockId) { + const blockLength = getEditorBlockSelectionLength( + this._editor, + blockId, + ); + const blockRole = getEditorBlockSelectionRole( + this._editor, + blockId, + ); + const shouldSelectDocument = + blockLength === 0 || + this._selectAllController.hasScope(blockId, "block"); + const nextScope = shouldSelectDocument ? "document" : "block"; + if (nextScope === "block") { + if (blockRole && blockRole !== "editable-inline") { + this.deactivate(); + this._editor.selectBlock(blockId); + this._selectAllController.recordScope(blockId, "block"); + return true; + } + this.commitProgrammaticTextSelection(blockId, 0, blockLength); + this._selectAllController.recordScope(blockId, "block"); + return true; + } + } + + return this._selectEntireDocument(blockId ?? null); + } + + protected _selectEntireDocument(blockId?: string | null): boolean { + const range = getFullDocumentTextRange(this._editor); + if (!range) { + return true; + } + + if (range.start.blockId === range.end.blockId) { + this.commitProgrammaticTextSelection( + range.start.blockId, + range.start.offset, + range.end.offset, + ); + } else { + if (!this._isEditing) { + this.activate(range.focusBlockId); + } + this._editor.selectTextRange(range.start, range.end); + } + this._recomputeSurfaceFromSelection(); + if (this._selectAllController.getBehavior() === "block-first") { + this._selectAllController.recordScope( + blockId ?? range.focusBlockId, + "document", + ); + } + this._syncSelectionToDOM(); + return true; + } + + suspendForPointerSelection(): void { + if (this._isComposing) return; + this._deactivate({ restoreFocus: false }); + } + + beginPointerSelection(): void { + this._selectionCoordinator.beginPointerSelection(); + } + + endPointerSelection(): void { + this._selectionCoordinator.endPointerSelection(); + } + + setComposing(composing: boolean): void { + if (this._isComposing === composing) return; + this._isComposing = composing; + this._emitStateChange(); + } + + protected _deactivate(options: { restoreFocus: boolean }): void { + if (!this._isEditing) return; + + const blockIds = [...this._activeBlockIds]; + const focusTargetId = this._focusBlockId ?? blockIds[0] ?? null; + this._backendLifecycle.deactivate(); + this._attachedElement = null; + this._cellEditingController.clear(); + + this._focusBlockId = null; + this._activeBlockIds = []; + this._isEditing = false; + this._isComposing = false; + this._historySelectionCoordinator.reset(); + this._selectionCoordinator.reset(); + this._inputMode = "none"; + this._mode = "inactive"; + this._pendingMarkController.reset(); + + for (const cb of this._deactivateListeners) cb(blockIds); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [], + isEditing: false, + }); + if (options.restoreFocus) { + this._restoreFocusAfterDeactivate(focusTargetId); + } + this._emitStateChange(); + } + + focus(options: PenFieldEditorFocusOptions = {}): boolean { + if (!this._isEditing || !this._focusBlockId) return false; + const root = this._findEditorRoot(); + + if (!root) return false; + + const blockEl = queryBlockElement(root, this._focusBlockId); + const inlineEl = blockEl?.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + if (!inlineEl) return false; + + const selection = this._editor.selection; + if ( + !this.requestDomFocus( + inlineEl, + "activate", + { + preventScroll: false, + }, + options, + ) + ) { + return false; + } + + if ( + selection?.type === "text" && + selection.anchor.blockId === this._focusBlockId && + selection.focus.blockId === this._focusBlockId + ) { + this._backendLifecycle.updateSelection(null); + return true; + } + + const nativeSelection = root.ownerDocument?.getSelection(); + if (!nativeSelection) return true; + + const range = root.ownerDocument.createRange(); + range.selectNodeContents(inlineEl); + range.collapse(false); + + nativeSelection.removeAllRanges(); + nativeSelection.addRange(range); + return true; + } + + blur(): void { + this._focusController.blur(); + } + + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions: PenFieldEditorFocusOptions = {}, + ): boolean { + if ( + reason === "backend-activate" && + this._suppressNextBackendActivationFocus + ) { + return true; + } + return this._focusController.requestDomFocus( + target, + reason, + options, + policyOptions, + ); + } + + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + return this._focusController.requestActivation(target, reason, options); + } + + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean { + return this._focusController.requestRootFocus(target, reason, options); + } + + setRootElement(element: HTMLElement | null): void { + this._rootElement = element; + if (element) { + this._focusController.notifyRootAttached(element); + } + if (element && this._isEditing) { + this._syncActiveElement(false); + } + } + + setFocused(focused: boolean): void { + if (this._isFocused === focused) return; + this._isFocused = focused; + this._emitStateChange(); + } + + protected _findEditorRoot(): HTMLElement | null { + if (!this._rootElement?.isConnected) return null; + return this._rootElement; + } + + protected _findExpandedHost(): HTMLElement | null { + const root = this._findEditorRoot(); + if (!root) return null; + return root.querySelector( + "[data-pen-editor-blocks-host]", + ) as HTMLElement | null; + } + + attachElement( + element: HTMLElement, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + if (!this._focusBlockId) return false; + if (this._attachedElement === element && this._backendLifecycle.current) + return true; + if (!this.requestActivation(element, "backend-attach", options)) + return false; + this._emitFocusLifecycle({ + type: "backend-attach-started", + editor: this._editor, + target: element, + blockId: this._focusBlockId, + }); + this._backendLifecycle.replace(this._resolveBackendClass()); + + const ytext = this._getYText(this._focusBlockId); + if (!ytext) return false; + + this._suppressNextBackendActivationFocus = + options.domFocus === false || options.passive === true; + try { + this._backendLifecycle.activate(element, ytext); + } finally { + this._suppressNextBackendActivationFocus = false; + } + this._attachedElement = element; + this._emitFocusLifecycle({ + type: "backend-attach-completed", + editor: this._editor, + target: element, + blockId: this._focusBlockId, + }); + this._focusController.resolveAttachmentWaiters(); + return true; + } +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts new file mode 100644 index 0000000..64d1a3a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts @@ -0,0 +1,434 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { DocumentRangeImpl } from "@pen/core"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import { FieldEditorImplSelection } from "./fieldEditorImplSelection"; +import { + areBlockIdsEqual, + resolveInputMode, +} from "./fieldEditorImplHelpers"; + +export abstract class FieldEditorImplRuntime extends FieldEditorImplSelection { + togglePendingMark(markType: string): boolean { + return this._pendingMarkController.toggle( + markType, + this._isEditing, + this._inputMode, + ); + } + + resolveInsertMarks( + ytext: FieldEditorTextLike, + offset: number, + ): Record | undefined { + return this._pendingMarkController.resolveInsertMarks(ytext, offset); + } + + // ── Cross-block expansion ──────────────────────────────── + + expandTo(blockId: string): void { + if (!this._isEditing || !this._focusBlockId) return; + + const selection = this._editor.selection; + const anchor = + selection?.type === "text" && + selection.blockRange.includes(this._focusBlockId) + ? selection.anchor + : { blockId: this._focusBlockId, offset: 0 }; + const doc = this._editor.documentState; + const activeIdx = doc.indexOf(this._focusBlockId); + const targetIdx = doc.indexOf(blockId); + if (activeIdx < 0 || targetIdx < 0) return; + + const targetOffset = + targetIdx >= activeIdx + ? (this._editor.getBlock(blockId)?.length() ?? 0) + : 0; + + this._editor.selectTextRange(anchor, { + blockId, + offset: targetOffset, + }); + } + + contractToFocused(): void { + if (!this._isEditing || !this._focusBlockId) return; + + const selection = this._editor.selection; + if (selection?.type !== "text") return; + + this._editor.selectTextRange(selection.focus, selection.focus); + } + + // ── Events ─────────────────────────────────────────────── + + onActivate(cb: (blockIds: string[]) => void): Unsubscribe { + this._activateListeners.add(cb); + return () => this._activateListeners.delete(cb); + } + + onDeactivate(cb: (blockIds: string[]) => void): Unsubscribe { + this._deactivateListeners.add(cb); + return () => this._deactivateListeners.delete(cb); + } + + onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { + return this._focusController.onFocusLifecycle(listener); + } + + onSelectionChange(cb: (sel: SelectionState) => void): Unsubscribe { + return this._editor.onSelectionChange(cb); + } + + getSnapshot(): FieldEditorStoreSnapshot { + return { + focusBlockId: this._focusBlockId, + activeBlockIds: this._activeBlockIds, + isEditing: this._isEditing, + isFocused: this._isFocused, + isComposing: this._isComposing, + domSyncVersion: this._domSyncVersion, + inputMode: this._inputMode, + mode: this._mode, + activeCellCoord: this._cellEditingController.activeCellCoord, + }; + } + + notifyDomReconciled(_blockId?: string): void { + this._domSyncVersion += 1; + this._emitStateChange(); + } + + subscribe(callback: () => void): Unsubscribe { + this._storeListeners.add(callback); + return () => this._storeListeners.delete(callback); + } + + waitForAttachment(blockId = this._focusBlockId): Promise { + return this._focusController.waitForAttachment(blockId); + } + + destroy(): void { + this._unsubscribeSelection?.(); + this._unsubscribeSelection = null; + this._unsubscribeHistoryApplied?.(); + this._unsubscribeHistoryApplied = null; + this._sessionReconciler.destroy(); + this._deactivate({ restoreFocus: false }); + this._activateListeners.clear(); + this._deactivateListeners.clear(); + this._storeListeners.clear(); + this._focusController.destroy(); + } + + // ── Internal ───────────────────────────────────────────── + + protected _resolveBackendClass(): InputBackendConstructor { + if (this._mode === "expanded") { + return ExpandedContentEditableBackend; + } + if (this._cellEditingController.activeCellCoord) { + return ContentEditableBackend; + } + if ( + "EditContext" in globalThis && + typeof (globalThis as typeof globalThis & { EditContext?: unknown }) + .EditContext === "function" + ) { + return EditContextBackend; + } + return ContentEditableBackend; + } + + protected _syncActiveElement(focus: boolean): void { + if (!this._focusBlockId) return; + const inlineEl = this._resolveInlineElement(this._focusBlockId); + if (!inlineEl) return; + + this.attachElement(inlineEl); + if (focus) { + this.focus(); + } + } + + protected _restoreFocusAfterDeactivate(blockId: string | null): void { + this._focusController.restoreFocusAfterDeactivate(blockId); + } + + protected _emitStateChange(): void { + for (const callback of this._storeListeners) { + callback(); + } + } + + protected _emitFocusLifecycle(event: PenFocusLifecycleEvent): void { + this._focusController.emitLifecycle(event); + } + + protected _recomputeSurfaceFromSelection(options?: { + syncSelectionToBackend?: boolean; + }): void { + const surface = classifySelectionSurface( + this._editor, + this._editor.selection, + this._focusBlockId, + this._isEditing, + ); + this._updateSurfaceState(surface.mode, surface.blockIds); + if (options?.syncSelectionToBackend ?? true) { + this._backendLifecycle.updateSelection(null); + } + } + + protected _updateSurfaceState( + mode: "inactive" | "single" | "expanded" | "block", + blockIds: string[], + ): void { + const modeChanged = this._mode !== mode; + const blockIdsChanged = !areBlockIdsEqual( + this._activeBlockIds, + blockIds, + ); + if (!modeChanged && !blockIdsChanged) return; + this._mode = mode; + this._activeBlockIds = blockIds; + this._syncBackendForSurfaceMode(); + + if (this._isEditing && blockIdsChanged) { + for (const cb of this._activateListeners) cb([...blockIds]); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [...blockIds], + isEditing: true, + }); + } + + this._emitStateChange(); + } + + protected _syncBackendForSurfaceMode(): void { + if (!this._isEditing || !this._focusBlockId) return; + const NextBackendClass = this._resolveBackendClass(); + if (this._backendLifecycle.hasBackend(NextBackendClass)) { + return; + } + + this._backendLifecycle.replace(NextBackendClass); + + if (this._mode === "expanded") { + const expandedHost = this._findExpandedHost(); + this._attachedElement = null; + if (expandedHost) { + this.attachElement(expandedHost); + } + return; + } + + if (this._mode === "single") { + const inlineEl = this._resolveInlineElement(this._focusBlockId); + if (inlineEl) { + this._attachedElement = null; + this.attachElement(inlineEl); + return; + } + } + + if (!this._attachedElement) return; + + const ytext = this._getYText(this._focusBlockId); + if (!ytext) return; + if (!this.requestActivation(this._attachedElement, "backend-attach")) { + return; + } + + this._backendLifecycle.activate(this._attachedElement, ytext); + } + + protected _startSession( + blockId: string, + options: { + stopCapturing: boolean; + syncSelectionToBackend: boolean; + attachImmediately: boolean; + }, + ): boolean { + if (this._isEditing) this._deactivate({ restoreFocus: false }); + + const block = this._editor.getBlock(blockId); + if (!block) return false; + + const schema = this._editor.schema.resolve(block.type); + if (schema?.fieldEditor === "none") return false; + + this._focusBlockId = blockId; + this._activeBlockIds = [blockId]; + this._isEditing = true; + this._isComposing = false; + this._mode = "single"; + this._pendingMarkController.reset(); + + if (options.stopCapturing) { + this._editor.undoManager.stopCapturing(); + } + + this._inputMode = resolveInputMode(schema); + this._backendLifecycle.replace(this._resolveBackendClass()); + this._attachedElement = null; + if (options.attachImmediately) { + this._syncActiveElement(false); + } + this._recomputeSurfaceFromSelection({ + syncSelectionToBackend: options.syncSelectionToBackend, + }); + + for (const cb of this._activateListeners) cb([...this._activeBlockIds]); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [...this._activeBlockIds], + isEditing: true, + }); + this._emitStateChange(); + return true; + } + + protected _handleHistoryApplied(event: HistoryAppliedEvent): void { + const selection = event.selection; + const nextFocusBlockId = + event.focusBlockId ?? + (selection?.type === "text" ? selection.focus.blockId : null); + if (selection?.type !== "text") { + if (this._isEditing) { + this._deactivate({ restoreFocus: false }); + } + return; + } + + if (!this._isEditing) { + return; + } + + if (nextFocusBlockId) { + this._focusBlockId = nextFocusBlockId; + } + + this._historySelectionCoordinator.beginDeferredProjection( + event.requestId, + ); + + this._recomputeSurfaceFromSelection({ + syncSelectionToBackend: false, + }); + } + + protected _attachedElementOwnsFocus(): boolean { + return this._focusController.attachedElementOwnsFocus(); + } + + protected _resolveInlineElement(blockId: string): HTMLElement | null { + const root = this._findEditorRoot(); + if (!root) return null; + const cellElement = + this._cellEditingController.resolveInlineElement(blockId); + if (cellElement) return cellElement; + return queryInlineElement(root, blockId); + } + + protected _getYText(blockId: string): FieldEditorTextLike | null { + return getResolvedYText( + this._editor, + blockId, + this._cellEditingController.activeCellCoord, + ); + } + + protected _getYTextForCell( + blockId: string, + row: number, + col: number, + ): FieldEditorTextLike | null { + return getCellYText(this._editor, blockId, row, col); + } + + protected _selectElementContents(element: HTMLElement): void { + if ( + !this.requestDomFocus(element, "select-all", { + preventScroll: true, + }) + ) { + return; + } + const selection = element.ownerDocument?.getSelection(); + if (!selection) return; + + const range = element.ownerDocument.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + } + + protected _resolveActiveCellElement( + rootElement?: HTMLElement | null, + ): HTMLElement | null { + return this._cellEditingController.resolveActiveCellElement( + rootElement, + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts new file mode 100644 index 0000000..d9c846d --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts @@ -0,0 +1,469 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { DocumentRangeImpl } from "@pen/core"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import { FieldEditorImplLifecycle } from "./fieldEditorImplLifecycle"; +import { + getFullDocumentTextRange, + pointsEqual, +} from "./fieldEditorImplHelpers"; + +export abstract class FieldEditorImplSelection extends FieldEditorImplLifecycle { + syncTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + if (!this._isEditing) return; + if (this._focusBlockId !== blockId) return; + + if ( + this._selectionCoordinator.prepareSyncedTextSelection( + this._editor.selection, + blockId, + anchorOffset, + focusOffset, + ) === "skip" + ) { + return; + } + this.setTextSelection(blockId, anchorOffset, focusOffset); + } + + applyDocumentTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): void { + this._selectionCoordinator.recordUserSelectionIntent(); + this._selectionCoordinator.suppressNextDomSelectionProjection(); + + if (!this._isEditing || !this._focusBlockId) { + this._startSession(anchor.blockId, { + stopCapturing: false, + syncSelectionToBackend: false, + attachImmediately: false, + }); + } else { + const blockRange = new DocumentRangeImpl( + anchor, + focus, + this._editor.internals.doc, + ).blockRange; + if (!blockRange.includes(this._focusBlockId)) { + this._focusBlockId = anchor.blockId; + } + } + + this._editor.selectTextRange(anchor, focus); + this._emitStateChange(); + } + + applyDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + options?: { + focusBlockId?: string; + }, + ): void { + if (anchor.blockId !== focus.blockId) { + this.applyDocumentTextSelection(anchor, focus); + return; + } + + const isProgrammaticDomSelection = + this._selectionCoordinator.isProgrammaticDomTextSelection( + anchor, + focus, + ); + if (!isProgrammaticDomSelection) { + this._selectionCoordinator.recordUserSelectionIntent(); + } + this._selectionCoordinator.suppressNextDomSelectionProjection(); + + if ( + anchor.blockId === focus.blockId && + (!this._isEditing || this._focusBlockId !== anchor.blockId) + ) { + this._startSession(anchor.blockId, { + stopCapturing: false, + syncSelectionToBackend: false, + attachImmediately: false, + }); + } + + if (anchor.blockId === focus.blockId) { + this.setTextSelection(anchor.blockId, anchor.offset, focus.offset); + return; + } + + if (options?.focusBlockId) { + this._focusBlockId = options.focusBlockId; + } + this._editor.selectTextRange(anchor, focus); + this._emitStateChange(); + } + + shouldHandleDomSelectionChange(isApplyingSelection: number): boolean { + return this._selectionCoordinator.shouldHandleDomSelectionChange( + this._focusBlockId, + isApplyingSelection, + ); + } + + resetBackendSelectionAuthority(): void { + this._selectionCoordinator.resetAuthority(); + } + + setBackendSelectionAuthority( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._selectionCoordinator.setAuthoritySelection(source, selection); + } + + getBackendSelectionAuthority( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._selectionCoordinator.getAuthoritySelection( + source, + blockId, + ); + } + + hasBackendSelectionAuthority(source: FieldEditorSelectionSource): boolean { + return this._selectionCoordinator.hasAuthoritySelection(source); + } + + clearBackendSelectionAuthority(source: FieldEditorSelectionSource): void { + this._selectionCoordinator.clearAuthoritySelection(source); + } + + applyBackendSelectionUntilNextFrame(): void { + this._selectionCoordinator.applySelectionUntilNextFrame(); + } + + getBackendSelectionApplicationDepth(): number { + return this._selectionCoordinator.isApplyingSelection; + } + + setEditContextSelectionSnapshot( + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._selectionCoordinator.setEditContextSelection(selection); + } + + getEditContextSelectionSnapshot( + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._selectionCoordinator.getEditContextSelection(blockId); + } + + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + return this._selectionCoordinator.resolveProgrammaticInputRange( + blockId, + liveRange, + ); + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + return this._selectionCoordinator.shouldIgnoreDomTextSelection( + anchor, + focus, + ); + } + + setTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + if (anchorOffset !== focusOffset) { + this._pendingMarkController.clear(true); + } + this._editor.selectText(blockId, anchorOffset, focusOffset); + this._selectionCoordinator.notifyTextSelectionSet( + blockId, + anchorOffset, + focusOffset, + ); + this._emitStateChange(); + } + + activateTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._selectionCoordinator.activateTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + async focusTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options: PenFieldEditorFocusOptions = {}, + ): Promise { + this.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + const attached = await this.waitForAttachment(blockId); + if (!attached) { + return false; + } + if (options.domFocus === false || options.passive) { + return true; + } + const focused = this.focus(options); + this.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + ); + return focused; + } + + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._selectionCoordinator.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + collapseSelectionToFocus(): void { + const selection = this._editor.selection; + if (selection?.type !== "text") return; + + this._collapseAndProject(selection.focus); + } + + collapseSelectionToAnchor(): void { + const selection = this._editor.selection; + if (selection?.type !== "text") return; + + this._collapseAndProject(selection.anchor); + } + + collapseSelectionToPoint(point: { blockId: string; offset: number }): void { + this._collapseAndProject(point); + } + + protected _collapseAndProject(point: { + blockId: string; + offset: number; + }): void { + this.setTextSelection(point.blockId, point.offset, point.offset); + + if (!this._isEditing || this._focusBlockId !== point.blockId) { + this.activate(point.blockId); + } + + this._selectionCoordinator.syncDomSelectionOnce(); + } + + delegate(blockSchema: BlockSchema): boolean { + return hasFieldEditorSurface(blockSchema); + } + + getPendingMarks(): Readonly> { + return this._pendingMarkController.getSnapshot(); + } + + clearPendingMarks(): void { + this._pendingMarkController.clear(); + } + + resetSelectAllCycle(): void { + this._selectAllController.resetCycle(); + } + + protected _syncSelectionToDOM(): void { + if (!this._isEditing) return; + this._selectionCoordinator.syncDomSelectionOnce(); + } + + protected _resolveSelectAllBlockId( + rootElement?: HTMLElement | null, + ): string | null { + const selection = this._editor.selection; + if (selection?.type === "text" && !selection.isMultiBlock) { + return selection.focus.blockId; + } + if ( + this._selectAllController.getBehavior() === "block-first" && + selection?.type === "block" && + selection.blockIds.length === 1 + ) { + return selection.blockIds[0] ?? null; + } + if (selection?.type === "cell") { + return selection.blockId; + } + + if (this._focusBlockId) { + return this._focusBlockId; + } + + const root = rootElement ?? this._findEditorRoot(); + if (!root) { + return null; + } + + const domSelection = domSelectionToEditor(root); + if ( + domSelection && + domSelection.anchor.blockId === domSelection.focus.blockId + ) { + return domSelection.focus.blockId; + } + + const activeElement = root.ownerDocument?.activeElement; + if (activeElement instanceof HTMLElement) { + return ( + activeElement + .closest("[data-block-id]") + ?.getAttribute("data-block-id") ?? null + ); + } + + return null; + } + + protected _selectionMatchesSelectAllCycle( + cycle: { blockId: string; scope: "cell" | "block" | "document" }, + selection: SelectionState | null, + ): boolean { + if (cycle.scope === "cell") { + return ( + selection?.type === "cell" && + selection.blockId === cycle.blockId + ); + } + + if (cycle.scope === "block") { + const blockLength = getEditorBlockSelectionLength( + this._editor, + cycle.blockId, + ); + const blockRole = getEditorBlockSelectionRole( + this._editor, + cycle.blockId, + ); + if (blockRole && blockRole !== "editable-inline") { + return ( + selection?.type === "block" && + selection.blockIds.length === 1 && + selection.blockIds[0] === cycle.blockId + ); + } + + if (selection?.type !== "text") { + return false; + } + return ( + !selection.isMultiBlock && + selection.anchor.blockId === cycle.blockId && + selection.focus.blockId === cycle.blockId && + Math.min(selection.anchor.offset, selection.focus.offset) === + 0 && + Math.max(selection.anchor.offset, selection.focus.offset) === + blockLength + ); + } + + const range = getFullDocumentTextRange(this._editor); + if (!range) { + return false; + } + + if (selection?.type !== "text") { + return false; + } + + return ( + selection.isMultiBlock && + ((pointsEqual(selection.anchor, range.start) && + pointsEqual(selection.focus, range.end)) || + (pointsEqual(selection.anchor, range.end) && + pointsEqual(selection.focus, range.start))) + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts index 7afcbeb..1a0765f 100644 --- a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts +++ b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts @@ -220,416 +220,11 @@ function getInlineAtomChipElement(element: Element): HTMLElement | null { return null; } -function getInlineAtomHostElement(node: Node): HTMLElement | null { - if (node instanceof HTMLElement && isInlineAtomHostNode(node)) { - return node; - } - - if (node instanceof HTMLElement && isInlineAtomChipNode(node)) { - const parent = node.parentElement; - return parent && isInlineAtomHostNode(parent) ? parent : null; - } - - if (isInlineAtomCaretBoundaryNode(node)) { - const parent = node.parentElement; - return parent && isInlineAtomHostNode(parent) ? parent : null; - } - - return null; -} - -function getInlineAtomCaretBoundaryElement( - host: HTMLElement, - side: InlineAtomCaretBoundarySide, -): HTMLElement | null { - for (const child of Array.from(host.childNodes)) { - if ( - isInlineAtomCaretBoundaryNode(child) && - child.getAttribute(DATA_ATTRS.inlineAtomCaretSide) === side - ) { - return child; - } - } - return null; -} - -function getInlineAtomCaretBoundaryTextPoint( - host: HTMLElement, - side: InlineAtomCaretBoundarySide, -): { node: Node; offset: number } | null { - const boundary = getInlineAtomCaretBoundaryElement(host, side); - if (!boundary) { - return null; - } - - const textNode = boundary.firstChild; - if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { - return null; - } - - return { - node: textNode, - offset: side === "before" ? 0 : (textNode.textContent?.length ?? 0), - }; -} - -function resolveLogicalInlineAtomUnit(node: HTMLElement): HTMLElement { - const host = getInlineAtomHostElement(node); - if (host) { - return host; - } - return node; -} - -export function getLogicalNodeLength(node: Node): number { - if ( - isInlineAtomCaretBoundaryNode(node) || - hasInlineAtomCaretBoundaryAncestor(node) - ) { - return 0; - } - - if (isInlineAtomHostNode(node)) { - return 1; - } - - if (isInlineAtomChipNode(node)) { - return getInlineAtomHostElement(node) ? 0 : 1; - } - - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent?.length ?? 0; - } - - let length = 0; - for (const child of Array.from(node.childNodes)) { - length += getLogicalNodeLength(child); - } - return length; -} - -export function getLogicalTextContent(root: HTMLElement): string { - let text = ""; - for (const child of Array.from(root.childNodes)) { - text += getLogicalNodeText(child); - } - return text; -} - -export function getInlineAtomPointerOffset( - container: HTMLElement, - clientX: number, - clientY: number, -): number | null { - const atomElements = Array.from( - container.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), - ).filter( - (element): element is HTMLElement => element instanceof HTMLElement, - ); - if (atomElements.length === 0) { - return null; - } - - let bestOffset: number | null = null; - let bestScore = Number.POSITIVE_INFINITY; - - for (const atomElement of atomElements) { - const rect = atomElement.getBoundingClientRect(); - const dx = - clientX < rect.left - ? rect.left - clientX - : clientX > rect.right - ? clientX - rect.right - : 0; - const dy = - clientY < rect.top - ? rect.top - clientY - : clientY > rect.bottom - ? clientY - rect.bottom - : 0; - const score = dy * 1000 + dx; - if (score >= bestScore) { - continue; - } - - const logicalAtom = resolveLogicalInlineAtomUnit(atomElement); - const atomOffset = getOffsetBeforeNode(container, logicalAtom); - bestOffset = - clientX <= rect.left + rect.width / 2 ? atomOffset : atomOffset + 1; - bestScore = score; - } - - return bestOffset; -} - -export function domPointToLogicalOffset( - container: HTMLElement, - targetNode: Node, - targetOffset: number, -): number { - const boundaryAncestor = findInlineAtomCaretBoundaryAncestor( - targetNode, - container, - ); - if (boundaryAncestor) { - const side = boundaryAncestor.getAttribute( - DATA_ATTRS.inlineAtomCaretSide, - ) as InlineAtomCaretBoundarySide | null; - const host = getInlineAtomHostElement(boundaryAncestor); - if (host && (side === "before" || side === "after")) { - const hostOffset = getOffsetBeforeNode(container, host); - return side === "before" ? hostOffset : hostOffset + 1; - } - } - - const atomAncestor = findInlineAtomAncestor(targetNode, container); - if (atomAncestor) { - const logicalAtom = resolveLogicalInlineAtomUnit(atomAncestor); - const atomOffset = getOffsetBeforeNode(container, logicalAtom); - if (logicalAtom === targetNode || isInlineAtomChipNode(atomAncestor)) { - return targetOffset <= 0 ? atomOffset : atomOffset + 1; - } - return atomOffset + 1; - } - - const resolved = resolveLogicalOffset(container, targetNode, targetOffset); - return resolved ?? getLogicalNodeLength(container); -} - -export function findLogicalDOMPoint( - container: HTMLElement, - offset: number, -): { node: Node; offset: number } { - return findLogicalDOMPointInElement(container, Math.max(0, offset)); -} - -function getLogicalNodeText(node: Node): string { - if ( - isInlineAtomCaretBoundaryNode(node) || - hasInlineAtomCaretBoundaryAncestor(node) - ) { - return ""; - } - - if (isInlineAtomHostNode(node)) { - return INLINE_ATOM_REPLACEMENT_TEXT; - } - - if (isInlineAtomChipNode(node)) { - return getInlineAtomHostElement(node) - ? "" - : INLINE_ATOM_REPLACEMENT_TEXT; - } - - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent ?? ""; - } - let text = ""; - for (const child of Array.from(node.childNodes)) { - text += getLogicalNodeText(child); - } - return text; -} - -function findInlineAtomCaretBoundaryAncestor( - node: Node, - container: HTMLElement, -): HTMLElement | null { - let current: Node | null = node; - while (current && current !== container) { - if (isInlineAtomCaretBoundaryNode(current)) { - return current; - } - current = current.parentNode; - } - return null; -} - -function hasInlineAtomCaretBoundaryAncestor(node: Node): boolean { - let current: Node | null = node.parentNode; - while (current) { - if (isInlineAtomCaretBoundaryNode(current)) { - return true; - } - if (isInlineAtomHostNode(current)) { - return false; - } - current = current.parentNode; - } - return false; -} - -function findInlineAtomAncestor( - node: Node, - container: HTMLElement, -): HTMLElement | null { - let current: Node | null = node; - while (current && current !== container) { - if (isInlineAtomNode(current)) { - return current; - } - current = current.parentNode; - } - return null; -} - -function getOffsetBeforeNode(container: HTMLElement, target: Node): number { - let offset = 0; - let found = false; - - const visit = (node: Node) => { - if (found) { - return; - } - if (node === target) { - found = true; - return; - } - if (node !== container) { - offset += getLogicalNodeLength(node); - return; - } - for (const child of Array.from(node.childNodes)) { - visit(child); - if (found) { - return; - } - } - }; - - visit(container); - return offset; -} - -function resolveLogicalOffset( - current: Node, - targetNode: Node, - targetOffset: number, -): number | null { - if (current === targetNode) { - if (isInlineAtomHostNode(current)) { - return targetOffset <= 0 ? 0 : 1; - } - - if (isInlineAtomChipNode(current)) { - return getInlineAtomHostElement(current) - ? null - : targetOffset <= 0 - ? 0 - : 1; - } - - if (isInlineAtomCaretBoundaryNode(current)) { - return 0; - } - - if (current.nodeType === Node.TEXT_NODE) { - return Math.min(targetOffset, current.textContent?.length ?? 0); - } - - let offset = 0; - const children = Array.from(current.childNodes); - for ( - let index = 0; - index < targetOffset && index < children.length; - index += 1 - ) { - offset += getLogicalNodeLength(children[index]); - } - return offset; - } - - if ( - current.nodeType === Node.TEXT_NODE || - isInlineAtomHostNode(current) || - isInlineAtomChipNode(current) || - isInlineAtomCaretBoundaryNode(current) - ) { - return null; - } - - let offset = 0; - for (const child of Array.from(current.childNodes)) { - const childOffset = resolveLogicalOffset( - child, - targetNode, - targetOffset, - ); - if (childOffset !== null) { - return offset + childOffset; - } - offset += getLogicalNodeLength(child); - } - - return null; -} - -function findLogicalDOMPointInElement( - element: HTMLElement, - offset: number, -): { node: Node; offset: number } { - let remaining = offset; - const children = Array.from(element.childNodes); - - for (let index = 0; index < children.length; index += 1) { - const child = children[index]; - const length = getLogicalNodeLength(child); - - if (remaining === 0) { - if (isInlineAtomHostNode(child)) { - const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( - child, - "before", - ); - if (boundaryPoint) { - return boundaryPoint; - } - } - return { node: element, offset: index }; - } - - if (child.nodeType === Node.TEXT_NODE) { - if (remaining <= length) { - return { node: child, offset: remaining }; - } - remaining -= length; - continue; - } - - if (isInlineAtomHostNode(child)) { - if (remaining <= 1) { - const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( - child, - remaining === 0 ? "before" : "after", - ); - if (boundaryPoint) { - return boundaryPoint; - } - return { node: element, offset: index + 1 }; - } - remaining -= 1; - continue; - } - - if (isInlineAtomChipNode(child)) { - if (remaining <= 1) { - return { node: element, offset: index + 1 }; - } - remaining -= 1; - continue; - } - - if (isInlineAtomCaretBoundaryNode(child)) { - continue; - } - - if (remaining <= length && child instanceof HTMLElement) { - return findLogicalDOMPointInElement(child, remaining); - } - - remaining -= length; - } - - return { node: element, offset: children.length }; -} +export { + domPointToLogicalOffset, + findLogicalDOMPoint, + getInlineAtomPointerOffset, + getLogicalNodeLength, + getLogicalTextContent, +} from "./inlineAtomLogicalDom"; diff --git a/packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts new file mode 100644 index 0000000..02a1966 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts @@ -0,0 +1,423 @@ +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { INLINE_ATOM_REPLACEMENT_TEXT } from "./inlineAtomModel"; +import { + isInlineAtomCaretBoundaryNode, + isInlineAtomChipNode, + isInlineAtomHostNode, + isInlineAtomNode, + type InlineAtomCaretBoundarySide, +} from "./inlineAtomDom"; + +function getInlineAtomHostElement(node: Node): HTMLElement | null { + if (node instanceof HTMLElement && isInlineAtomHostNode(node)) { + return node; + } + + if (node instanceof HTMLElement && isInlineAtomChipNode(node)) { + const parent = node.parentElement; + return parent && isInlineAtomHostNode(parent) ? parent : null; + } + + if (isInlineAtomCaretBoundaryNode(node)) { + const parent = node.parentElement; + return parent && isInlineAtomHostNode(parent) ? parent : null; + } + + return null; +} + +function getInlineAtomCaretBoundaryElement( + host: HTMLElement, + side: InlineAtomCaretBoundarySide, +): HTMLElement | null { + for (const child of Array.from(host.childNodes)) { + if ( + isInlineAtomCaretBoundaryNode(child) && + child.getAttribute(DATA_ATTRS.inlineAtomCaretSide) === side + ) { + return child; + } + } + return null; +} + +function getInlineAtomCaretBoundaryTextPoint( + host: HTMLElement, + side: InlineAtomCaretBoundarySide, +): { node: Node; offset: number } | null { + const boundary = getInlineAtomCaretBoundaryElement(host, side); + if (!boundary) { + return null; + } + + const textNode = boundary.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + return { + node: textNode, + offset: side === "before" ? 0 : (textNode.textContent?.length ?? 0), + }; +} + +function resolveLogicalInlineAtomUnit(node: HTMLElement): HTMLElement { + const host = getInlineAtomHostElement(node); + if (host) { + return host; + } + return node; +} + +export function getLogicalNodeLength(node: Node): number { + if ( + isInlineAtomCaretBoundaryNode(node) || + hasInlineAtomCaretBoundaryAncestor(node) + ) { + return 0; + } + + if (isInlineAtomHostNode(node)) { + return 1; + } + + if (isInlineAtomChipNode(node)) { + return getInlineAtomHostElement(node) ? 0 : 1; + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length ?? 0; + } + + let length = 0; + for (const child of Array.from(node.childNodes)) { + length += getLogicalNodeLength(child); + } + return length; +} + +export function getLogicalTextContent(root: HTMLElement): string { + let text = ""; + for (const child of Array.from(root.childNodes)) { + text += getLogicalNodeText(child); + } + return text; +} + +export function getInlineAtomPointerOffset( + container: HTMLElement, + clientX: number, + clientY: number, +): number | null { + const atomElements = Array.from( + container.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), + ).filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ); + if (atomElements.length === 0) { + return null; + } + + let bestOffset: number | null = null; + let bestScore = Number.POSITIVE_INFINITY; + + for (const atomElement of atomElements) { + const rect = atomElement.getBoundingClientRect(); + const dx = + clientX < rect.left + ? rect.left - clientX + : clientX > rect.right + ? clientX - rect.right + : 0; + const dy = + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0; + const score = dy * 1000 + dx; + if (score >= bestScore) { + continue; + } + + const logicalAtom = resolveLogicalInlineAtomUnit(atomElement); + const atomOffset = getOffsetBeforeNode(container, logicalAtom); + bestOffset = + clientX <= rect.left + rect.width / 2 ? atomOffset : atomOffset + 1; + bestScore = score; + } + + return bestOffset; +} + +export function domPointToLogicalOffset( + container: HTMLElement, + targetNode: Node, + targetOffset: number, +): number { + const boundaryAncestor = findInlineAtomCaretBoundaryAncestor( + targetNode, + container, + ); + if (boundaryAncestor) { + const side = boundaryAncestor.getAttribute( + DATA_ATTRS.inlineAtomCaretSide, + ) as InlineAtomCaretBoundarySide | null; + const host = getInlineAtomHostElement(boundaryAncestor); + if (host && (side === "before" || side === "after")) { + const hostOffset = getOffsetBeforeNode(container, host); + return side === "before" ? hostOffset : hostOffset + 1; + } + } + + const atomAncestor = findInlineAtomAncestor(targetNode, container); + if (atomAncestor) { + const logicalAtom = resolveLogicalInlineAtomUnit(atomAncestor); + const atomOffset = getOffsetBeforeNode(container, logicalAtom); + if (logicalAtom === targetNode || isInlineAtomChipNode(atomAncestor)) { + return targetOffset <= 0 ? atomOffset : atomOffset + 1; + } + return atomOffset + 1; + } + + const resolved = resolveLogicalOffset(container, targetNode, targetOffset); + return resolved ?? getLogicalNodeLength(container); +} + +export function findLogicalDOMPoint( + container: HTMLElement, + offset: number, +): { node: Node; offset: number } { + return findLogicalDOMPointInElement(container, Math.max(0, offset)); +} + +function getLogicalNodeText(node: Node): string { + if ( + isInlineAtomCaretBoundaryNode(node) || + hasInlineAtomCaretBoundaryAncestor(node) + ) { + return ""; + } + + if (isInlineAtomHostNode(node)) { + return INLINE_ATOM_REPLACEMENT_TEXT; + } + + if (isInlineAtomChipNode(node)) { + return getInlineAtomHostElement(node) + ? "" + : INLINE_ATOM_REPLACEMENT_TEXT; + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ""; + } + + let text = ""; + for (const child of Array.from(node.childNodes)) { + text += getLogicalNodeText(child); + } + return text; +} + +function findInlineAtomCaretBoundaryAncestor( + node: Node, + container: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== container) { + if (isInlineAtomCaretBoundaryNode(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function hasInlineAtomCaretBoundaryAncestor(node: Node): boolean { + let current: Node | null = node.parentNode; + while (current) { + if (isInlineAtomCaretBoundaryNode(current)) { + return true; + } + if (isInlineAtomHostNode(current)) { + return false; + } + current = current.parentNode; + } + return false; +} + +function findInlineAtomAncestor( + node: Node, + container: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== container) { + if (isInlineAtomNode(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function getOffsetBeforeNode(container: HTMLElement, target: Node): number { + let offset = 0; + let found = false; + + const visit = (node: Node) => { + if (found) { + return; + } + if (node === target) { + found = true; + return; + } + if (node !== container) { + offset += getLogicalNodeLength(node); + return; + } + for (const child of Array.from(node.childNodes)) { + visit(child); + if (found) { + return; + } + } + }; + + visit(container); + return offset; +} + +function resolveLogicalOffset( + current: Node, + targetNode: Node, + targetOffset: number, +): number | null { + if (current === targetNode) { + if (isInlineAtomHostNode(current)) { + return targetOffset <= 0 ? 0 : 1; + } + + if (isInlineAtomChipNode(current)) { + return getInlineAtomHostElement(current) + ? null + : targetOffset <= 0 + ? 0 + : 1; + } + + if (isInlineAtomCaretBoundaryNode(current)) { + return 0; + } + + if (current.nodeType === Node.TEXT_NODE) { + return Math.min(targetOffset, current.textContent?.length ?? 0); + } + + let offset = 0; + const children = Array.from(current.childNodes); + for ( + let index = 0; + index < targetOffset && index < children.length; + index += 1 + ) { + offset += getLogicalNodeLength(children[index]); + } + return offset; + } + + if ( + current.nodeType === Node.TEXT_NODE || + isInlineAtomHostNode(current) || + isInlineAtomChipNode(current) || + isInlineAtomCaretBoundaryNode(current) + ) { + return null; + } + + let offset = 0; + for (const child of Array.from(current.childNodes)) { + const childOffset = resolveLogicalOffset( + child, + targetNode, + targetOffset, + ); + if (childOffset !== null) { + return offset + childOffset; + } + offset += getLogicalNodeLength(child); + } + + return null; +} + +function findLogicalDOMPointInElement( + element: HTMLElement, + offset: number, +): { node: Node; offset: number } { + let remaining = offset; + const children = Array.from(element.childNodes); + + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + const length = getLogicalNodeLength(child); + + if (remaining === 0) { + if (isInlineAtomHostNode(child)) { + const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( + child, + "before", + ); + if (boundaryPoint) { + return boundaryPoint; + } + } + return { node: element, offset: index }; + } + + if (child.nodeType === Node.TEXT_NODE) { + if (remaining <= length) { + return { node: child, offset: remaining }; + } + remaining -= length; + continue; + } + + if (isInlineAtomHostNode(child)) { + if (remaining <= 1) { + const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( + child, + remaining === 0 ? "before" : "after", + ); + if (boundaryPoint) { + return boundaryPoint; + } + return { node: element, offset: index + 1 }; + } + remaining -= 1; + continue; + } + + if (isInlineAtomChipNode(child)) { + if (remaining <= 1) { + return { node: element, offset: index + 1 }; + } + remaining -= 1; + continue; + } + + if (isInlineAtomCaretBoundaryNode(child)) { + continue; + } + + if (remaining <= length && child instanceof HTMLElement) { + return findLogicalDOMPointInElement(child, remaining); + } + + remaining -= length; + } + + return { node: element, offset: children.length }; +} diff --git a/packages/rendering/dom/src/field-editor/inlineInputRules.ts b/packages/rendering/dom/src/field-editor/inlineInputRules.ts new file mode 100644 index 0000000..58c7ae4 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineInputRules.ts @@ -0,0 +1,109 @@ +import { INPUT_RULES_ENGINE_SLOT_KEY, supportsInlineInputRules } from "@pen/types"; +import type { DocumentOp, Editor } from "@pen/types"; +import { matchInlineInputRule } from "../utils/inlineInputRule"; +import type { InlineInputRuleEngine } from "./crdt"; + +export type InlineInputRuleSelectionTarget = { + blockId: string; + anchorOffset: number; + focusOffset: number; +}; + +export function applyInlineInputRule( + editor: Editor, + options: { + blockId: string; + offset: number; + text: string; + }, +): InlineInputRuleSelectionTarget | null { + const { blockId, offset, text } = options; + if (text.length !== 1) { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + + const blockSchema = editor.schema.resolve(block.type); + if (!supportsInlineInputRules(blockSchema)) { + return null; + } + + const inputRuleEngine = + editor.internals.getSlot( + INPUT_RULES_ENGINE_SLOT_KEY, + ) ?? null; + const ops = + inputRuleEngine?.tryMatchInline(editor, blockId, text, { offset }) ?? + resolveFallbackInlineInputRule(editor, blockId, block.textContent(), offset, text); + if (!ops) { + return null; + } + + const selectionTarget = resolveInlineSelectionTarget(blockId, ops); + if (!selectionTarget) { + return null; + } + + editor.apply(ops, { origin: "input-rule" }); + return selectionTarget; +} + +function resolveFallbackInlineInputRule( + editor: Editor, + blockId: string, + blockText: string, + offset: number, + text: string, +): DocumentOp[] | null { + const match = matchInlineInputRule(blockText, offset, text); + if (!match) { + return null; + } + + const markType = Object.keys(match.marks)[0]; + if (!markType || !editor.schema.resolveInline(markType)) { + return null; + } + + return [ + { + type: "delete-text", + blockId, + offset: match.deleteRange.start, + length: match.deleteRange.end - match.deleteRange.start, + }, + { + type: "insert-text", + blockId, + offset: match.deleteRange.start, + text: match.text, + marks: match.marks, + }, + ]; +} + +function resolveInlineSelectionTarget( + blockId: string, + ops: DocumentOp[], +): InlineInputRuleSelectionTarget | null { + let nextOffset: number | null = null; + for (const op of ops) { + if (op.type === "insert-text" && op.blockId === blockId) { + nextOffset = op.offset + op.text.length; + } + } + + if (nextOffset == null) { + return null; + } + + return { + blockId, + anchorOffset: nextOffset, + focusOffset: nextOffset, + }; +} diff --git a/packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts b/packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts new file mode 100644 index 0000000..659e503 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts @@ -0,0 +1,213 @@ +import type { Editor, KeyBindingContext } from "@pen/types"; +import { + COLLECT_KEY_BINDINGS_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; + +export function tryHandleHistoryOverrideBinding( + editor: Editor, + event: KeyboardEvent, +): boolean { + if (!isUndoShortcut(event) && !isRedoShortcut(event)) { + return false; + } + + const bindings = collectKeyBindings(editor); + for (const binding of bindings) { + if ( + matchesBindingContext(editor, binding.context) && + matchesKey(binding.key, event) && + binding.handler(editor, event) + ) { + return true; + } + } + + return false; +} + +export function getDocumentTextRange(editor: Editor): { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + focusBlockId: string; +} | null { + const blockOrder = editor.documentState.blockOrder; + const firstBlockId = blockOrder[0]; + const lastBlockId = blockOrder[blockOrder.length - 1]; + if (!firstBlockId || !lastBlockId) { + return null; + } + + const focusBlockId = + blockOrder.find((blockId) => { + const block = editor.getBlock(blockId); + if (!block) return false; + const schema = editor.schema.resolve(block.type); + return usesInlineTextSelection(schema); + }) ?? firstBlockId; + + return { + start: { blockId: firstBlockId, offset: 0 }, + end: { + blockId: lastBlockId, + offset: getEditorBlockSelectionLength(editor, lastBlockId), + }, + focusBlockId, + }; +} + +export function collectKeyBindings(editor: Editor): ReadonlyArray<{ + key: string; + context?: KeyBindingContext; + handler: (editor: Editor, event: KeyboardEvent) => boolean; +}> { + const collect = + editor.internals.getSlot< + (registry: Editor["schema"]) => ReadonlyArray<{ + key: string; + context?: KeyBindingContext; + handler: (editor: Editor, event: KeyboardEvent) => boolean; + }> + >(COLLECT_KEY_BINDINGS_SLOT_KEY) ?? null; + return collect?.(editor.schema) ?? []; +} + +export function matchesBindingContext( + editor: Editor, + context: KeyBindingContext | undefined, +): boolean { + if (!context) return true; + + const selection = editor.selection; + const activeBlock = getActiveBlock(editor); + + if ( + context.blockType && + (!activeBlock || !context.blockType.includes(activeBlock.type)) + ) { + return false; + } + + if (context.hasSelection !== undefined) { + const hasSelection = + selection?.type === "text" + ? !selection.isCollapsed + : selection !== null; + if (hasSelection !== context.hasSelection) { + return false; + } + } + + if (context.collapsed !== undefined) { + const isCollapsed = selection?.type === "text" && selection.isCollapsed; + if (isCollapsed !== context.collapsed) { + return false; + } + } + + if ( + context.withinLayout && + (!activeBlock || !isWithinLayout(activeBlock, context.withinLayout)) + ) { + return false; + } + + return true; +} + +function getActiveBlock(editor: Editor) { + const selection = editor.selection; + if (!selection) return null; + + if (selection.type === "text") { + return editor.getBlock(selection.anchor.blockId); + } + + if (selection.type === "block") { + const blockId = selection.blockIds[0]; + return blockId ? editor.getBlock(blockId) : null; + } + + if (selection.type === "cell") { + return editor.getBlock(selection.blockId); + } + + return null; +} + +function isWithinLayout( + block: NonNullable>, + allowedLayoutTypes: readonly string[], +): boolean { + let parent = block.layoutParent(); + while (parent) { + if (allowedLayoutTypes.includes(parent.type)) { + return true; + } + parent = parent.layoutParent(); + } + + return false; +} + +export function matchesKey(pattern: string, event: KeyboardEvent): boolean { + const parts = pattern.split("-").map((part) => part.toLowerCase()); + const key = parts.pop()?.toLowerCase() ?? ""; + + const needsCtrl = parts.includes("ctrl"); + const needsMeta = parts.includes("meta"); + const needsMod = parts.includes("mod"); + const needsShift = parts.includes("shift"); + const needsAlt = parts.includes("alt"); + + const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.platform ?? ""); + + const allowCtrl = needsCtrl || (needsMod && !isMac); + const allowMeta = needsMeta || (needsMod && isMac); + + const modMatch = needsMod ? (isMac ? event.metaKey : event.ctrlKey) : true; + const ctrlMatch = allowCtrl ? event.ctrlKey : !event.ctrlKey; + const metaMatch = allowMeta ? event.metaKey : !event.metaKey; + const shiftMatch = needsShift ? event.shiftKey : !event.shiftKey; + const altMatch = needsAlt ? event.altKey : !event.altKey; + + return ( + modMatch && + ctrlMatch && + metaMatch && + shiftMatch && + altMatch && + event.key.toLowerCase() === key + ); +} + +export function isSelectAllShortcut(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "a" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +export function isUndoShortcut(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "z" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +export function isRedoShortcut(event: KeyboardEvent): boolean { + const key = event.key.toLowerCase(); + const usesMod = event.metaKey || event.ctrlKey; + return ( + usesMod && + !event.altKey && + ((key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey)) + ); +} diff --git a/packages/rendering/dom/src/field-editor/keyHandling.ts b/packages/rendering/dom/src/field-editor/keyHandling.ts index 98ae69f..1ccae1e 100644 --- a/packages/rendering/dom/src/field-editor/keyHandling.ts +++ b/packages/rendering/dom/src/field-editor/keyHandling.ts @@ -1,24 +1,25 @@ import { getInlineCompletionController } from "@pen/core"; -import type { Editor, KeyBindingContext } from "@pen/types"; -import { - COLLECT_KEY_BINDINGS_SLOT_KEY, - usesInlineTextSelection, -} from "@pen/types"; +import type { Editor } from "@pen/types"; import type { FieldEditorKeyboardController } from "./controller"; import { applyDeleteBehavior, applyEnterBehavior, applyListTabBehavior, moveCaretAcrossBlocks, - normalizeInlineRange, type SelectionRange, } from "./commands"; -import { - getInlineAtomRangeAtOffset, - isInlineAtomRange, -} from "./inlineAtomModel"; -import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; import { getAutocompleteController } from "../utils/autocompleteController"; +import { selectInlineAtomWithArrowKey } from "./keyHandlingInlineAtoms"; +import { + collectKeyBindings, + getDocumentTextRange, + isRedoShortcut, + isSelectAllShortcut, + isUndoShortcut, + matchesBindingContext, + matchesKey, + tryHandleHistoryOverrideBinding, +} from "./keyBindingShortcuts"; export function handleFieldEditorKeyDown(options: { event: KeyboardEvent; @@ -306,123 +307,6 @@ export function handleFieldEditorKeyDown(options: { return handleEditorKeyBindings(editor, event, { includeSelectAll: false }); } -function selectInlineAtomWithArrowKey(options: { - blockId: string; - editor: Editor; - event: KeyboardEvent; - fieldEditor: FieldEditorKeyboardController; - range: SelectionRange | null; - ytext: { - length: number; - toString(): string; - toDelta(): Array<{ insert?: string | Record }>; - }; -}): boolean { - const { blockId, editor, event, fieldEditor, ytext } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!range) { - return false; - } - - const direction = event.key === "ArrowLeft" ? "previous" : "next"; - if (event.shiftKey) { - return extendInlineAtomSelectionWithArrowKey({ - blockId, - direction, - editor, - fieldEditor, - range, - ytext, - }); - } - - if (range.start !== range.end) { - if (!isInlineAtomRange(ytext, range.start, range.end)) { - return false; - } - const offset = direction === "previous" ? range.start : range.end; - fieldEditor.activateTextSelection(blockId, offset, offset); - return true; - } - - const atomOffset = direction === "previous" ? range.start - 1 : range.start; - const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); - if (!atomRange) { - return false; - } - - fieldEditor.activateTextSelection(blockId, atomRange.start, atomRange.end); - return true; -} - -function extendInlineAtomSelectionWithArrowKey(options: { - blockId: string; - direction: "previous" | "next"; - editor: Editor; - fieldEditor: FieldEditorKeyboardController; - range: SelectionRange; - ytext: { - toDelta(): Array<{ insert?: string | Record }>; - }; -}): boolean { - const { blockId, direction, editor, fieldEditor, range, ytext } = options; - const selection = editor.selection; - if ( - selection?.type === "text" && - !selection.isCollapsed && - !selection.isMultiBlock && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ) { - const focusAtomOffset = - direction === "previous" - ? selection.focus.offset - 1 - : selection.focus.offset; - const focusAtomRange = getInlineAtomRangeAtOffset( - ytext, - focusAtomOffset, - ); - if (focusAtomRange) { - const nextFocusOffset = - direction === "previous" - ? focusAtomRange.start - : focusAtomRange.end; - fieldEditor.activateTextSelection( - blockId, - selection.anchor.offset, - nextFocusOffset, - ); - return true; - } - } - - if (range.start === range.end) { - const atomOffset = - direction === "previous" ? range.start - 1 : range.end; - const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); - if (!atomRange) { - return false; - } - const anchorOffset = - direction === "previous" ? atomRange.end : atomRange.start; - const focusOffset = - direction === "previous" ? atomRange.start : atomRange.end; - fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); - return true; - } - - const atomOffset = direction === "previous" ? range.start - 1 : range.end; - const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); - if (!atomRange) { - return false; - } - - const anchorOffset = - direction === "previous" ? atomRange.start : range.start; - const focusOffset = direction === "previous" ? range.end : atomRange.end; - fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); - return true; -} function syncAcceptedInlineCompletionSelection( editor: Editor, @@ -539,210 +423,3 @@ export function handleHistoryShortcut( return false; } - -function tryHandleHistoryOverrideBinding( - editor: Editor, - event: KeyboardEvent, -): boolean { - if (!isUndoShortcut(event) && !isRedoShortcut(event)) { - return false; - } - - const bindings = collectKeyBindings(editor); - for (const binding of bindings) { - if ( - matchesBindingContext(editor, binding.context) && - matchesKey(binding.key, event) && - binding.handler(editor, event) - ) { - return true; - } - } - - return false; -} - -function getDocumentTextRange(editor: Editor): { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - focusBlockId: string; -} | null { - const blockOrder = editor.documentState.blockOrder; - const firstBlockId = blockOrder[0]; - const lastBlockId = blockOrder[blockOrder.length - 1]; - if (!firstBlockId || !lastBlockId) { - return null; - } - - const focusBlockId = - blockOrder.find((blockId) => { - const block = editor.getBlock(blockId); - if (!block) return false; - const schema = editor.schema.resolve(block.type); - return usesInlineTextSelection(schema); - }) ?? firstBlockId; - - return { - start: { blockId: firstBlockId, offset: 0 }, - end: { - blockId: lastBlockId, - offset: getEditorBlockSelectionLength(editor, lastBlockId), - }, - focusBlockId, - }; -} - -function collectKeyBindings(editor: Editor): ReadonlyArray<{ - key: string; - context?: KeyBindingContext; - handler: (editor: Editor, event: KeyboardEvent) => boolean; -}> { - const collect = - editor.internals.getSlot< - (registry: Editor["schema"]) => ReadonlyArray<{ - key: string; - context?: KeyBindingContext; - handler: (editor: Editor, event: KeyboardEvent) => boolean; - }> - >(COLLECT_KEY_BINDINGS_SLOT_KEY) ?? null; - return collect?.(editor.schema) ?? []; -} - -function matchesBindingContext( - editor: Editor, - context: KeyBindingContext | undefined, -): boolean { - if (!context) return true; - - const selection = editor.selection; - const activeBlock = getActiveBlock(editor); - - if ( - context.blockType && - (!activeBlock || !context.blockType.includes(activeBlock.type)) - ) { - return false; - } - - if (context.hasSelection !== undefined) { - const hasSelection = - selection?.type === "text" - ? !selection.isCollapsed - : selection !== null; - if (hasSelection !== context.hasSelection) { - return false; - } - } - - if (context.collapsed !== undefined) { - const isCollapsed = selection?.type === "text" && selection.isCollapsed; - if (isCollapsed !== context.collapsed) { - return false; - } - } - - if ( - context.withinLayout && - (!activeBlock || !isWithinLayout(activeBlock, context.withinLayout)) - ) { - return false; - } - - return true; -} - -function getActiveBlock(editor: Editor) { - const selection = editor.selection; - if (!selection) return null; - - if (selection.type === "text") { - return editor.getBlock(selection.anchor.blockId); - } - - if (selection.type === "block") { - const blockId = selection.blockIds[0]; - return blockId ? editor.getBlock(blockId) : null; - } - - if (selection.type === "cell") { - return editor.getBlock(selection.blockId); - } - - return null; -} - -function isWithinLayout( - block: NonNullable>, - allowedLayoutTypes: readonly string[], -): boolean { - let parent = block.layoutParent(); - while (parent) { - if (allowedLayoutTypes.includes(parent.type)) { - return true; - } - parent = parent.layoutParent(); - } - - return false; -} - -function matchesKey(pattern: string, event: KeyboardEvent): boolean { - const parts = pattern.split("-").map((part) => part.toLowerCase()); - const key = parts.pop()?.toLowerCase() ?? ""; - - const needsCtrl = parts.includes("ctrl"); - const needsMeta = parts.includes("meta"); - const needsMod = parts.includes("mod"); - const needsShift = parts.includes("shift"); - const needsAlt = parts.includes("alt"); - - const isMac = - typeof navigator !== "undefined" && - /Mac|iPhone|iPad/.test(navigator.platform ?? ""); - - const allowCtrl = needsCtrl || (needsMod && !isMac); - const allowMeta = needsMeta || (needsMod && isMac); - - const modMatch = needsMod ? (isMac ? event.metaKey : event.ctrlKey) : true; - const ctrlMatch = allowCtrl ? event.ctrlKey : !event.ctrlKey; - const metaMatch = allowMeta ? event.metaKey : !event.metaKey; - const shiftMatch = needsShift ? event.shiftKey : !event.shiftKey; - const altMatch = needsAlt ? event.altKey : !event.altKey; - - return ( - modMatch && - ctrlMatch && - metaMatch && - shiftMatch && - altMatch && - event.key.toLowerCase() === key - ); -} - -function isSelectAllShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "a" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -function isUndoShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "z" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -function isRedoShortcut(event: KeyboardEvent): boolean { - const key = event.key.toLowerCase(); - const usesMod = event.metaKey || event.ctrlKey; - return ( - usesMod && - !event.altKey && - ((key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey)) - ); -} diff --git a/packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts b/packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts new file mode 100644 index 0000000..149963c --- /dev/null +++ b/packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts @@ -0,0 +1,128 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorKeyboardController } from "./controller"; +import { + normalizeInlineRange, + type SelectionRange, +} from "./commands"; +import { + getInlineAtomRangeAtOffset, + isInlineAtomRange, +} from "./inlineAtomModel"; + +export function selectInlineAtomWithArrowKey(options: { + blockId: string; + editor: Editor; + event: KeyboardEvent; + fieldEditor: FieldEditorKeyboardController; + range: SelectionRange | null; + ytext: { + length: number; + toString(): string; + toDelta(): Array<{ insert?: string | Record }>; + }; +}): boolean { + const { blockId, editor, event, fieldEditor, ytext } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!range) { + return false; + } + + const direction = event.key === "ArrowLeft" ? "previous" : "next"; + if (event.shiftKey) { + return extendInlineAtomSelectionWithArrowKey({ + blockId, + direction, + editor, + fieldEditor, + range, + ytext, + }); + } + + if (range.start !== range.end) { + if (!isInlineAtomRange(ytext, range.start, range.end)) { + return false; + } + const offset = direction === "previous" ? range.start : range.end; + fieldEditor.activateTextSelection(blockId, offset, offset); + return true; + } + + const atomOffset = direction === "previous" ? range.start - 1 : range.start; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + + fieldEditor.activateTextSelection(blockId, atomRange.start, atomRange.end); + return true; +} + +function extendInlineAtomSelectionWithArrowKey(options: { + blockId: string; + direction: "previous" | "next"; + editor: Editor; + fieldEditor: FieldEditorKeyboardController; + range: SelectionRange; + ytext: { + toDelta(): Array<{ insert?: string | Record }>; + }; +}): boolean { + const { blockId, direction, editor, fieldEditor, range, ytext } = options; + const selection = editor.selection; + if ( + selection?.type === "text" && + !selection.isCollapsed && + !selection.isMultiBlock && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ) { + const focusAtomOffset = + direction === "previous" + ? selection.focus.offset - 1 + : selection.focus.offset; + const focusAtomRange = getInlineAtomRangeAtOffset( + ytext, + focusAtomOffset, + ); + if (focusAtomRange) { + const nextFocusOffset = + direction === "previous" + ? focusAtomRange.start + : focusAtomRange.end; + fieldEditor.activateTextSelection( + blockId, + selection.anchor.offset, + nextFocusOffset, + ); + return true; + } + } + + if (range.start === range.end) { + const atomOffset = + direction === "previous" ? range.start - 1 : range.end; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + const anchorOffset = + direction === "previous" ? atomRange.end : atomRange.start; + const focusOffset = + direction === "previous" ? atomRange.start : atomRange.end; + fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); + return true; + } + + const atomOffset = direction === "previous" ? range.start - 1 : range.end; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + + const anchorOffset = + direction === "previous" ? atomRange.start : range.start; + const focusOffset = direction === "previous" ? range.end : atomRange.end; + fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); + return true; +} diff --git a/packages/rendering/dom/src/field-editor/reconciler.ts b/packages/rendering/dom/src/field-editor/reconciler.ts index c5ff866..8276ead 100644 --- a/packages/rendering/dom/src/field-editor/reconciler.ts +++ b/packages/rendering/dom/src/field-editor/reconciler.ts @@ -1,6 +1,7 @@ import type { InlineDecoration, SchemaRegistry } from "@pen/types"; import { sortDeltaAttributes } from "@pen/core"; import type { FieldEditorDelta, FieldEditorTextLike } from "./crdt"; +import { saveSelection, restoreSelection } from "./reconcilerSelection"; import { applyInlineDecorationsToDeltas, INLINE_DECORATION_ATTRIBUTE_KEY, @@ -490,65 +491,9 @@ function updateInlineAtomHostTextContent(target: Node, source: Node): void { // ── Selection save/restore ───────────────────────────────── -export interface SavedSelection { - anchorOffset: number; - focusOffset: number; -} - -export function saveSelection(element: HTMLElement): SavedSelection | null { - const sel = typeof window !== "undefined" ? window.getSelection() : null; - if (!sel || sel.rangeCount === 0) return null; - - const anchorOffset = computeCharacterOffset( - element, - sel.anchorNode, - sel.anchorOffset, - ); - const focusOffset = computeCharacterOffset( - element, - sel.focusNode, - sel.focusOffset, - ); - - return { anchorOffset, focusOffset }; -} - -export function restoreSelection( - element: HTMLElement, - saved: SavedSelection | null, -): void { - if (!saved) return; - try { - const sel = window.getSelection(); - if (!sel) return; - - const anchor = findPositionInDOM(element, saved.anchorOffset); - const focus = findPositionInDOM(element, saved.focusOffset); - if (!anchor || !focus) return; - - sel.setBaseAndExtent( - anchor.node, - anchor.offset, - focus.node, - focus.offset, - ); - } catch { - // Selection restoration can fail if DOM structure changed - } -} - -function computeCharacterOffset( - root: HTMLElement, - node: Node | null, - offset: number, -): number { - if (!node) return 0; - return domPointToLogicalOffset(root, node, offset); -} -function findPositionInDOM( - root: HTMLElement, - charOffset: number, -): { node: Node; offset: number } | null { - return findLogicalDOMPoint(root, charOffset); -} +export { + restoreSelection, + saveSelection, + type SavedSelection, +} from "./reconcilerSelection"; diff --git a/packages/rendering/dom/src/field-editor/reconcilerSelection.ts b/packages/rendering/dom/src/field-editor/reconcilerSelection.ts new file mode 100644 index 0000000..e85269a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/reconcilerSelection.ts @@ -0,0 +1,67 @@ +import { + domPointToLogicalOffset, + findLogicalDOMPoint, +} from "./inlineAtomDom"; + +export interface SavedSelection { + anchorOffset: number; + focusOffset: number; +} + +export function saveSelection(element: HTMLElement): SavedSelection | null { + const sel = typeof window !== "undefined" ? window.getSelection() : null; + if (!sel || sel.rangeCount === 0) return null; + + const anchorOffset = computeCharacterOffset( + element, + sel.anchorNode, + sel.anchorOffset, + ); + const focusOffset = computeCharacterOffset( + element, + sel.focusNode, + sel.focusOffset, + ); + + return { anchorOffset, focusOffset }; +} + +export function restoreSelection( + element: HTMLElement, + saved: SavedSelection | null, +): void { + if (!saved) return; + try { + const sel = window.getSelection(); + if (!sel) return; + + const anchor = findPositionInDOM(element, saved.anchorOffset); + const focus = findPositionInDOM(element, saved.focusOffset); + if (!anchor || !focus) return; + + sel.setBaseAndExtent( + anchor.node, + anchor.offset, + focus.node, + focus.offset, + ); + } catch { + // Selection restoration can fail if DOM structure changed + } +} + +function computeCharacterOffset( + root: HTMLElement, + node: Node | null, + offset: number, +): number { + if (!node) return 0; + return domPointToLogicalOffset(root, node, offset); +} + +function findPositionInDOM( + root: HTMLElement, + charOffset: number, +): { node: Node; offset: number } | null { + return findLogicalDOMPoint(root, charOffset); +} diff --git a/packages/rendering/dom/src/field-editor/selectionBridge.ts b/packages/rendering/dom/src/field-editor/selectionBridge.ts index 779dd41..581d3ab 100644 --- a/packages/rendering/dom/src/field-editor/selectionBridge.ts +++ b/packages/rendering/dom/src/field-editor/selectionBridge.ts @@ -458,260 +458,11 @@ export function domSelectionToEditor( return { anchor, focus }; } -/** - * Set DOM selection from editor (blockId, offset) pairs. - */ -export function editorSelectionToDOM( - root: HTMLElement, - anchor: SelectionPoint, - focus: SelectionPoint, -): void { - const anchorResult = findDOMPoint(root, anchor.blockId, anchor.offset); - const focusResult = findDOMPoint(root, focus.blockId, focus.offset); - if (!anchorResult || !focusResult) return; - - const sel = window.getSelection(); - if (!sel) return; - - setDOMSelection(sel, anchorResult, focusResult); -} - -export function getSelectionPointRect( - root: HTMLElement, - point: SelectionPoint, -): DOMRect | null { - const domPoint = findDOMPoint(root, point.blockId, point.offset); - if (!domPoint) return null; - - const blockEl = queryBlockElement(root, point.blockId); - const inlineEl = blockEl?.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; - if (!inlineEl) return null; - - const doc = root.ownerDocument; - if (!doc) return null; - - const range = doc.createRange(); - range.setStart(domPoint.node, domPoint.offset); - range.collapse(true); - - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter === "function") { - const rect = rangeRectGetter.call(range); - if (rect.height > 0 || rect.width > 0) { - return rect; - } - } - - return getInlineCaretRectFromOffset(inlineEl, point.offset); -} - -export function getTextSelectionClientRects( - root: HTMLElement, - selection: { - anchor: SelectionPoint; - focus: SelectionPoint; - }, -): DOMRect[] { - const doc = root.ownerDocument; - if (!doc) { - return []; - } - - const anchorPoint = findDOMPoint( - root, - selection.anchor.blockId, - selection.anchor.offset, - ); - const focusPoint = findDOMPoint( - root, - selection.focus.blockId, - selection.focus.offset, - ); - if (!anchorPoint || !focusPoint) { - return []; - } - - const range = doc.createRange(); - try { - range.setStart(anchorPoint.node, anchorPoint.offset); - range.setEnd(focusPoint.node, focusPoint.offset); - } catch { - range.setStart(focusPoint.node, focusPoint.offset); - range.setEnd(anchorPoint.node, anchorPoint.offset); - } - - const rangeClientRectGetter = ( - range as Range & { getClientRects?: () => DOMRectList | DOMRect[] } - ).getClientRects; - const clientRects = - typeof rangeClientRectGetter === "function" - ? Array.from(rangeClientRectGetter.call(range)) - : []; - if (clientRects.length > 0) { - return clientRects.filter((rect) => rect.width > 0 || rect.height > 0); - } - - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter !== "function") { - return []; - } - - const boundingRect = rangeRectGetter.call(range); - return boundingRect.width > 0 || boundingRect.height > 0 - ? [boundingRect] - : []; -} - -/** - * Find the DOM text node and offset for a given (blockId, characterOffset). - */ -function findDOMPoint( - root: HTMLElement, - blockId: string, - charOffset: number, -): { node: Node; offset: number } | null { - const blockEl = queryBlockElement(root, blockId); - if (!blockEl) return null; - - const inlineEl = blockEl.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; - if (!inlineEl) return null; - - return findLogicalDOMPoint(inlineEl, charOffset); -} - -/** - * Get the current selection as character offsets within the active inline content. - * Used by DIRECT_HANDLERS to know the selection range for editing operations. - */ -export function getDirectionalSelectionOffsets( - inlineElement: HTMLElement, -): DirectionalSelectionOffsets | null { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0) return null; - if (!sel.anchorNode || !sel.focusNode) return null; - if ( - !isNodeWithinOrEqual(inlineElement, sel.anchorNode) || - !isNodeWithinOrEqual(inlineElement, sel.focusNode) - ) { - return null; - } - - const anchor = domPointToOffset( - inlineElement, - sel.anchorNode, - sel.anchorOffset, - ); - const focus = domPointToOffset( - inlineElement, - sel.focusNode, - sel.focusOffset, - ); - - return { - anchor, - focus, - start: Math.min(anchor, focus), - end: Math.max(anchor, focus), - }; -} - -export function getSelectionOffsets( - inlineElement: HTMLElement, -): { start: number; end: number } | null { - const offsets = getDirectionalSelectionOffsets(inlineElement); - if (!offsets) return null; - - return { start: offsets.start, end: offsets.end }; -} - -/** - * Get the caret offset (collapsed cursor position) within an inline element. - */ -export function getCaretOffset(inlineElement: HTMLElement): number { - const offsets = getSelectionOffsets(inlineElement); - return offsets?.start ?? 0; -} - -function setDOMSelection( - selection: Selection, - anchor: { node: Node; offset: number }, - focus: { node: Node; offset: number }, -): void { - selection.removeAllRanges(); - - const setBaseAndExtent = ( - selection as Selection & { - setBaseAndExtent?: ( - anchorNode: Node, - anchorOffset: number, - focusNode: Node, - focusOffset: number, - ) => void; - } - ).setBaseAndExtent; - if (typeof setBaseAndExtent === "function") { - try { - setBaseAndExtent.call( - selection, - anchor.node, - anchor.offset, - focus.node, - focus.offset, - ); - return; - } catch { - // Fall back to the range-based path in test environments like jsdom. - } - } - - const collapseRange = document.createRange(); - collapseRange.setStart(anchor.node, anchor.offset); - collapseRange.collapse(true); - selection.addRange(collapseRange); - - if ( - (anchor.node !== focus.node || anchor.offset !== focus.offset) && - typeof selection.extend === "function" - ) { - selection.extend(focus.node, focus.offset); - return; - } - - selection.removeAllRanges(); - const orderedRange = document.createRange(); - if (compareDOMPoints(anchor, focus) <= 0) { - orderedRange.setStart(anchor.node, anchor.offset); - orderedRange.setEnd(focus.node, focus.offset); - } else { - orderedRange.setStart(focus.node, focus.offset); - orderedRange.setEnd(anchor.node, anchor.offset); - } - selection.addRange(orderedRange); -} - -function compareDOMPoints( - left: { node: Node; offset: number }, - right: { node: Node; offset: number }, -): number { - if (left.node === right.node) { - return left.offset - right.offset; - } - - const leftRange = document.createRange(); - leftRange.setStart(left.node, left.offset); - leftRange.collapse(true); - - const rightRange = document.createRange(); - rightRange.setStart(right.node, right.offset); - rightRange.collapse(true); - - return leftRange.compareBoundaryPoints(Range.START_TO_START, rightRange); -} +export { + editorSelectionToDOM, + getCaretOffset, + getDirectionalSelectionOffsets, + getSelectionOffsets, + getSelectionPointRect, + getTextSelectionClientRects, +} from "./selectionBridgeOffsets"; diff --git a/packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts b/packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts new file mode 100644 index 0000000..8d969c6 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts @@ -0,0 +1,270 @@ +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { + findLogicalDOMPoint, + getInlineAtomPointerOffset, + getLogicalNodeLength, +} from "./inlineAtomDom"; +import { getInlineCaretRectFromOffset } from "./selectionGeometry"; +import { queryBlockElement } from "./selectionDomQueries"; +import { domPointToOffset, type DirectionalSelectionOffsets, type SelectionPoint } from "./selectionBridge"; +function isNodeWithinOrEqual(container: HTMLElement, node: Node): boolean { + return node === container || container.contains(node); +} + +/** + * Set DOM selection from editor (blockId, offset) pairs. + */ +export function editorSelectionToDOM( + root: HTMLElement, + anchor: SelectionPoint, + focus: SelectionPoint, +): void { + const anchorResult = findDOMPoint(root, anchor.blockId, anchor.offset); + const focusResult = findDOMPoint(root, focus.blockId, focus.offset); + if (!anchorResult || !focusResult) return; + + const sel = window.getSelection(); + if (!sel) return; + + setDOMSelection(sel, anchorResult, focusResult); +} + +export function getSelectionPointRect( + root: HTMLElement, + point: SelectionPoint, +): DOMRect | null { + const domPoint = findDOMPoint(root, point.blockId, point.offset); + if (!domPoint) return null; + + const blockEl = queryBlockElement(root, point.blockId); + const inlineEl = blockEl?.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + if (!inlineEl) return null; + + const doc = root.ownerDocument; + if (!doc) return null; + + const range = doc.createRange(); + range.setStart(domPoint.node, domPoint.offset); + range.collapse(true); + + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter === "function") { + const rect = rangeRectGetter.call(range); + if (rect.height > 0 || rect.width > 0) { + return rect; + } + } + + return getInlineCaretRectFromOffset(inlineEl, point.offset); +} + +export function getTextSelectionClientRects( + root: HTMLElement, + selection: { + anchor: SelectionPoint; + focus: SelectionPoint; + }, +): DOMRect[] { + const doc = root.ownerDocument; + if (!doc) { + return []; + } + + const anchorPoint = findDOMPoint( + root, + selection.anchor.blockId, + selection.anchor.offset, + ); + const focusPoint = findDOMPoint( + root, + selection.focus.blockId, + selection.focus.offset, + ); + if (!anchorPoint || !focusPoint) { + return []; + } + + const range = doc.createRange(); + try { + range.setStart(anchorPoint.node, anchorPoint.offset); + range.setEnd(focusPoint.node, focusPoint.offset); + } catch { + range.setStart(focusPoint.node, focusPoint.offset); + range.setEnd(anchorPoint.node, anchorPoint.offset); + } + + const rangeClientRectGetter = ( + range as Range & { getClientRects?: () => DOMRectList | DOMRect[] } + ).getClientRects; + const clientRects = + typeof rangeClientRectGetter === "function" + ? Array.from(rangeClientRectGetter.call(range)) + : []; + if (clientRects.length > 0) { + return clientRects.filter((rect) => rect.width > 0 || rect.height > 0); + } + + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter !== "function") { + return []; + } + + const boundingRect = rangeRectGetter.call(range); + return boundingRect.width > 0 || boundingRect.height > 0 + ? [boundingRect] + : []; +} + +/** + * Find the DOM text node and offset for a given (blockId, characterOffset). + */ +function findDOMPoint( + root: HTMLElement, + blockId: string, + charOffset: number, +): { node: Node; offset: number } | null { + const blockEl = queryBlockElement(root, blockId); + if (!blockEl) return null; + + const inlineEl = blockEl.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + if (!inlineEl) return null; + + return findLogicalDOMPoint(inlineEl, charOffset); +} + +/** + * Get the current selection as character offsets within the active inline content. + * Used by DIRECT_HANDLERS to know the selection range for editing operations. + */ +export function getDirectionalSelectionOffsets( + inlineElement: HTMLElement, +): DirectionalSelectionOffsets | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return null; + if (!sel.anchorNode || !sel.focusNode) return null; + if ( + !isNodeWithinOrEqual(inlineElement, sel.anchorNode) || + !isNodeWithinOrEqual(inlineElement, sel.focusNode) + ) { + return null; + } + + const anchor = domPointToOffset( + inlineElement, + sel.anchorNode, + sel.anchorOffset, + ); + const focus = domPointToOffset( + inlineElement, + sel.focusNode, + sel.focusOffset, + ); + + return { + anchor, + focus, + start: Math.min(anchor, focus), + end: Math.max(anchor, focus), + }; +} + +export function getSelectionOffsets( + inlineElement: HTMLElement, +): { start: number; end: number } | null { + const offsets = getDirectionalSelectionOffsets(inlineElement); + if (!offsets) return null; + + return { start: offsets.start, end: offsets.end }; +} + +/** + * Get the caret offset (collapsed cursor position) within an inline element. + */ +export function getCaretOffset(inlineElement: HTMLElement): number { + const offsets = getSelectionOffsets(inlineElement); + return offsets?.start ?? 0; +} + +function setDOMSelection( + selection: Selection, + anchor: { node: Node; offset: number }, + focus: { node: Node; offset: number }, +): void { + selection.removeAllRanges(); + + const setBaseAndExtent = ( + selection as Selection & { + setBaseAndExtent?: ( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, + ) => void; + } + ).setBaseAndExtent; + if (typeof setBaseAndExtent === "function") { + try { + setBaseAndExtent.call( + selection, + anchor.node, + anchor.offset, + focus.node, + focus.offset, + ); + return; + } catch { + // Fall back to the range-based path in test environments like jsdom. + } + } + + const collapseRange = document.createRange(); + collapseRange.setStart(anchor.node, anchor.offset); + collapseRange.collapse(true); + selection.addRange(collapseRange); + + if ( + (anchor.node !== focus.node || anchor.offset !== focus.offset) && + typeof selection.extend === "function" + ) { + selection.extend(focus.node, focus.offset); + return; + } + + selection.removeAllRanges(); + const orderedRange = document.createRange(); + if (compareDOMPoints(anchor, focus) <= 0) { + orderedRange.setStart(anchor.node, anchor.offset); + orderedRange.setEnd(focus.node, focus.offset); + } else { + orderedRange.setStart(focus.node, focus.offset); + orderedRange.setEnd(anchor.node, anchor.offset); + } + selection.addRange(orderedRange); +} + +function compareDOMPoints( + left: { node: Node; offset: number }, + right: { node: Node; offset: number }, +): number { + if (left.node === right.node) { + return left.offset - right.offset; + } + + const leftRange = document.createRange(); + leftRange.setStart(left.node, left.offset); + leftRange.collapse(true); + + const rightRange = document.createRange(); + rightRange.setStart(right.node, right.offset); + rightRange.collapse(true); + + return leftRange.compareBoundaryPoints(Range.START_TO_START, rightRange); +} diff --git a/packages/rendering/dom/src/field-editor/textInputPipeline.ts b/packages/rendering/dom/src/field-editor/textInputPipeline.ts new file mode 100644 index 0000000..abfbe6f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/textInputPipeline.ts @@ -0,0 +1,124 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import type { FieldEditorInputController, ActiveCellCoord } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import { + buildInlineTextDiffOps, + buildInlineTextEditTransaction, + type InlineTextDiffOp, + type InlineTextRange, + type InlineTextSelectionTarget, +} from "./inlineTextTransaction"; + +type TextInputPipelineController = Pick< + FieldEditorInputController, + | "setBackendSelectionAuthority" + | "syncTextSelection" + | "resolveInsertMarks" +>; + +export interface ApplyInlineTextInputOptions { + editor: Editor; + fieldEditor: TextInputPipelineController; + blockId: string; + range: InlineTextRange; + text: string; + marks?: Record; + cellCoord?: ActiveCellCoord | null; + selection?: InlineTextSelectionTarget | null; + syncSelection?: boolean; +} + +export interface ApplyInlineTextDiffInputOptions { + editor: Editor; + fieldEditor: TextInputPipelineController; + blockId: string; + diff: readonly InlineTextDiffOp[]; + ytext: FieldEditorTextLike; + selection?: InlineTextSelectionTarget | null; + cellCoord?: ActiveCellCoord | null; +} + +export interface ApplyInlineTextDiffInputResult { + applied: boolean; + selection: InlineTextSelectionTarget | null; +} + +export function applyInlineTextInput( + options: ApplyInlineTextInputOptions, +): InlineTextSelectionTarget { + const transaction = buildInlineTextEditTransaction({ + blockId: options.blockId, + range: options.range, + text: options.text, + marks: options.marks, + cellCoord: options.cellCoord, + }); + const selection = options.selection ?? transaction.selection; + if (options.syncSelection === false) { + if (transaction.ops.length > 0) { + options.editor.apply(transaction.ops, { origin: "user" }); + } + return selection; + } + applyInlineTextOperations(options, transaction.ops, selection); + return selection; +} + +export function applyInlineTextDiffInput( + options: ApplyInlineTextDiffInputOptions, +): ApplyInlineTextDiffInputResult { + if (options.diff.length === 0) { + return { applied: false, selection: null }; + } + + const ops = buildInlineTextDiffOps({ + blockId: options.blockId, + diff: options.diff, + ytext: options.ytext, + resolveInsertMarks: (sourceText, offset) => + options.fieldEditor.resolveInsertMarks(sourceText, offset), + cellCoord: options.cellCoord, + }); + if (ops.length === 0) { + return { applied: false, selection: null }; + } + + if (!options.selection) { + options.editor.apply(ops, { origin: "user" }); + return { applied: true, selection: null }; + } + + applyInlineTextOperations(options, ops, options.selection); + return { applied: true, selection: options.selection }; +} + +function applyInlineTextOperations( + options: { + editor: Editor; + fieldEditor: TextInputPipelineController; + blockId: string; + cellCoord?: ActiveCellCoord | null; + }, + ops: readonly DocumentOp[], + selection: InlineTextSelectionTarget, +): void { + options.fieldEditor.setBackendSelectionAuthority( + "programmatic", + selection, + ); + + if (ops.length > 0) { + options.editor.apply([...ops], { origin: "user" }); + } + + if (options.cellCoord) { + options.fieldEditor.setBackendSelectionAuthority("cell", selection); + return; + } + + options.fieldEditor.syncTextSelection( + options.blockId, + selection.anchorOffset, + selection.focusOffset, + ); +} diff --git a/packages/rendering/dom/src/utils/tableCellClipboard.ts b/packages/rendering/dom/src/utils/tableCellClipboard.ts new file mode 100644 index 0000000..0a25928 --- /dev/null +++ b/packages/rendering/dom/src/utils/tableCellClipboard.ts @@ -0,0 +1,194 @@ +import type { CellSelection, DocumentOp, Editor } from "@pen/types"; +import { + resolveCellSelectionCoord, + resolveCellSelectionMatrix, +} from "@pen/core"; + +export function isPasteShortcut(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "v" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +export async function cutCellSelection( + editor: Editor, + selection: CellSelection, +): Promise { + const copied = await copyCellSelection(editor, selection); + if (copied) { + editor.deleteSelection(); + } +} + +export async function copyCellSelection( + editor: Editor, + selection: CellSelection, +): Promise { + const block = editor.getBlock(selection.blockId); + if (!block) return false; + + const cellData: string[][] = []; + for (const rowCells of resolveCellSelectionMatrix(block, selection)) { + const row: string[] = []; + for (const cellCoord of rowCells) { + const cell = block.tableCell(cellCoord.row, cellCoord.col); + row.push(cell?.textContent() ?? ""); + } + cellData.push(row); + } + + const tabSeparated = cellData.map((row) => row.join("\t")).join("\n"); + const penCells = JSON.stringify({ + cells: cellData, + rows: cellData.length, + cols: Math.max(...cellData.map((row) => row.length), 0), + }); + const encodedPenCells = encodeURIComponent(penCells); + + const htmlRows = cellData.map((row) => + `${row.map((c) => `${escapeHtml(c)}`).join("")}`, + ).join(""); + const html = `${htmlRows}
          `; + const clipboard = globalThis.navigator?.clipboard; + if (!clipboard) { + return false; + } + + if ( + typeof ClipboardItem !== "undefined" && + typeof clipboard.write === "function" + ) { + try { + await clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([tabSeparated], { type: "text/plain" }), + "text/html": new Blob( + [`${html}`], + { type: "text/html" }, + ), + }), + ]); + return true; + } catch { + // Fall through to plain-text clipboard writes below. + } + } + + if (typeof clipboard.writeText === "function") { + try { + await clipboard.writeText(tabSeparated); + return true; + } catch { + return false; + } + } + + return false; +} + +export function pasteCellSelection(editor: Editor, selection: CellSelection): void { + navigator.clipboard.read().then((items) => { + for (const item of items) { + if (item.types.includes("text/html")) { + item.getType("text/html").then((blob) => { + blob.text().then((html) => { + const penCellsMatch = html.match(/data-pen-cells=(['"])(.*?)\1/); + if (penCellsMatch) { + try { + const parsed = parseEncodedCellPayload(penCellsMatch[2]); + applyPastedCells(editor, selection, parsed.cells); + return; + } catch { + // Fall through to plain-text clipboard reads below. + } + } + pasteFromPlainText(editor, selection); + }); + }); + return; + } + } + pasteFromPlainText(editor, selection); + }).catch(() => { + pasteFromPlainText(editor, selection); + }); +} + +function pasteFromPlainText(editor: Editor, selection: CellSelection): void { + navigator.clipboard.readText().then((text) => { + const cells = text.split("\n").map((row) => row.split("\t")); + applyPastedCells(editor, selection, cells); + }).catch(() => { }); +} + +function applyPastedCells(editor: Editor, selection: CellSelection, cellData: string[][]): void { + const block = editor.getBlock(selection.blockId); + if (!block) return; + + const rowCount = selection.rowIds?.length ?? block.tableRowCount(); + const colCount = selection.columnIds?.length ?? block.tableColumnCount(); + const startRow = Math.min(selection.anchor.row, selection.head.row); + const startCol = Math.min(selection.anchor.col, selection.head.col); + + const ops: DocumentOp[] = []; + for (let r = 0; r < cellData.length; r++) { + const targetRow = startRow + r; + if (targetRow >= rowCount) break; + for (let c = 0; c < cellData[r].length; c++) { + const targetCol = startCol + c; + if (targetCol >= colCount) break; + const resolvedCoord = resolveCellSelectionCoord(block, selection, { + row: targetRow, + col: targetCol, + }); + if (!resolvedCoord) continue; + const cell = block.tableCell(resolvedCoord.row, resolvedCoord.col); + if (!cell) continue; + const existingLen = cell.textContent().length; + if (existingLen > 0) { + ops.push({ + type: "delete-table-cell-text", + blockId: selection.blockId, + row: resolvedCoord.row, + col: resolvedCoord.col, + offset: 0, + length: existingLen, + }); + } + const pasteText = cellData[r][c]; + if (pasteText.length > 0) { + ops.push({ + type: "insert-table-cell-text", + blockId: selection.blockId, + row: resolvedCoord.row, + col: resolvedCoord.col, + offset: 0, + text: pasteText, + }); + } + } + } + + if (ops.length > 0) { + editor.apply(ops, { origin: "user" }); + } +} + +function parseEncodedCellPayload(raw: string): { cells: string[][] } { + try { + return JSON.parse(decodeURIComponent(raw)) as { cells: string[][] }; + } catch { + return JSON.parse(raw) as { cells: string[][] }; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/packages/rendering/dom/src/utils/tableCellNavigation.ts b/packages/rendering/dom/src/utils/tableCellNavigation.ts index dde0440..1d94c96 100644 --- a/packages/rendering/dom/src/utils/tableCellNavigation.ts +++ b/packages/rendering/dom/src/utils/tableCellNavigation.ts @@ -1,3 +1,9 @@ +import { + copyCellSelection, + cutCellSelection, + isPasteShortcut, + pasteCellSelection, +} from "./tableCellClipboard"; import type { CellSelection, DocumentOp, Editor } from "@pen/types"; import { hasIndexedCellSelectionMetadata, @@ -433,192 +439,3 @@ function isCutShortcut(event: KeyboardEvent): boolean { (event.metaKey || event.ctrlKey) ); } - -function isPasteShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "v" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -async function cutCellSelection( - editor: Editor, - selection: CellSelection, -): Promise { - const copied = await copyCellSelection(editor, selection); - if (copied) { - editor.deleteSelection(); - } -} - -async function copyCellSelection( - editor: Editor, - selection: CellSelection, -): Promise { - const block = editor.getBlock(selection.blockId); - if (!block) return false; - - const cellData: string[][] = []; - for (const rowCells of resolveCellSelectionMatrix(block, selection)) { - const row: string[] = []; - for (const cellCoord of rowCells) { - const cell = block.tableCell(cellCoord.row, cellCoord.col); - row.push(cell?.textContent() ?? ""); - } - cellData.push(row); - } - - const tabSeparated = cellData.map((row) => row.join("\t")).join("\n"); - const penCells = JSON.stringify({ - cells: cellData, - rows: cellData.length, - cols: Math.max(...cellData.map((row) => row.length), 0), - }); - const encodedPenCells = encodeURIComponent(penCells); - - const htmlRows = cellData.map((row) => - `${row.map((c) => `${escapeHtml(c)}`).join("")}`, - ).join(""); - const html = `${htmlRows}
          `; - const clipboard = globalThis.navigator?.clipboard; - if (!clipboard) { - return false; - } - - if ( - typeof ClipboardItem !== "undefined" && - typeof clipboard.write === "function" - ) { - try { - await clipboard.write([ - new ClipboardItem({ - "text/plain": new Blob([tabSeparated], { type: "text/plain" }), - "text/html": new Blob( - [`${html}`], - { type: "text/html" }, - ), - }), - ]); - return true; - } catch { - // Fall through to plain-text clipboard writes below. - } - } - - if (typeof clipboard.writeText === "function") { - try { - await clipboard.writeText(tabSeparated); - return true; - } catch { - return false; - } - } - - return false; -} - -function pasteCellSelection(editor: Editor, selection: CellSelection): void { - navigator.clipboard.read().then((items) => { - for (const item of items) { - if (item.types.includes("text/html")) { - item.getType("text/html").then((blob) => { - blob.text().then((html) => { - const penCellsMatch = html.match(/data-pen-cells=(['"])(.*?)\1/); - if (penCellsMatch) { - try { - const parsed = parseEncodedCellPayload(penCellsMatch[2]); - applyPastedCells(editor, selection, parsed.cells); - return; - } catch { - // Fall through to plain-text clipboard reads below. - } - } - pasteFromPlainText(editor, selection); - }); - }); - return; - } - } - pasteFromPlainText(editor, selection); - }).catch(() => { - pasteFromPlainText(editor, selection); - }); -} - -function pasteFromPlainText(editor: Editor, selection: CellSelection): void { - navigator.clipboard.readText().then((text) => { - const cells = text.split("\n").map((row) => row.split("\t")); - applyPastedCells(editor, selection, cells); - }).catch(() => { }); -} - -function applyPastedCells(editor: Editor, selection: CellSelection, cellData: string[][]): void { - const block = editor.getBlock(selection.blockId); - if (!block) return; - - const rowCount = selection.rowIds?.length ?? block.tableRowCount(); - const colCount = selection.columnIds?.length ?? block.tableColumnCount(); - const startRow = Math.min(selection.anchor.row, selection.head.row); - const startCol = Math.min(selection.anchor.col, selection.head.col); - - const ops: DocumentOp[] = []; - for (let r = 0; r < cellData.length; r++) { - const targetRow = startRow + r; - if (targetRow >= rowCount) break; - for (let c = 0; c < cellData[r].length; c++) { - const targetCol = startCol + c; - if (targetCol >= colCount) break; - const resolvedCoord = resolveCellSelectionCoord(block, selection, { - row: targetRow, - col: targetCol, - }); - if (!resolvedCoord) continue; - const cell = block.tableCell(resolvedCoord.row, resolvedCoord.col); - if (!cell) continue; - const existingLen = cell.textContent().length; - if (existingLen > 0) { - ops.push({ - type: "delete-table-cell-text", - blockId: selection.blockId, - row: resolvedCoord.row, - col: resolvedCoord.col, - offset: 0, - length: existingLen, - }); - } - const pasteText = cellData[r][c]; - if (pasteText.length > 0) { - ops.push({ - type: "insert-table-cell-text", - blockId: selection.blockId, - row: resolvedCoord.row, - col: resolvedCoord.col, - offset: 0, - text: pasteText, - }); - } - } - } - - if (ops.length > 0) { - editor.apply(ops, { origin: "user" }); - } -} - -function parseEncodedCellPayload(raw: string): { cells: string[][] } { - try { - return JSON.parse(decodeURIComponent(raw)) as { cells: string[][] }; - } catch { - return JSON.parse(raw) as { cells: string[][] }; - } -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx new file mode 100644 index 0000000..63a7fb6 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx @@ -0,0 +1,400 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("renders bottom-chat markdown as schema blocks while streaming", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "# Story\n\nOnce upon " }; + await releaseFinalDelta.promise; + yield { type: "text-delta" as const, delta: "a time" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + let session: + | ReturnType["startSession"]> + | null = null; + let generationPromise: Promise | null = null; + + await act(async () => { + session = controller!.startSession({ + surface: "bottom-chat", + target: "document", + }); + generationPromise = controller!.runSessionPrompt( + session!.id, + "Write a short story", + { target: "document" }, + ); + await waitForCondition(() => { + const heading = container.querySelector("h1[data-block-type='heading']"); + const text = (container.textContent ?? "").replace(/\u200B/g, ""); + return heading?.textContent?.includes("Story") === true && text.includes("Once upon"); + }); + }); + + const heading = container.querySelector("h1[data-block-type='heading']"); + expect(heading?.textContent).toContain("Story"); + expect((container.textContent ?? "").replace(/\u200B/g, "")).toContain("Once upon"); + + await act(async () => { + releaseFinalDelta.resolve(); + await generationPromise; + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("exposes AI sessions through React hooks", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + function SessionProbe() { + const sessions = useAISessions(editor); + const activeSession = useActiveAISession(editor); + const actions = useAIActions(editor); + + return ( +
          + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + let sessionId = ""; + await act(async () => { + const session = controller?.startSession({ + surface: "inline-edit", + target: "selection", + }); + if (session) { + sessionId = session.id; + await controller?.runSessionPrompt(session.id, "Rewrite the selection"); + } + }); + + await act(async () => { + const controllerAny = controller as any; + controllerAny?._recordSessionFastApplyMetrics(sessionId, { + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + }); + await Promise.resolve(); + }); + + const probe = container.querySelector("[data-session-count]"); + expect(probe?.getAttribute("data-session-count")).toBe("1"); + expect(probe?.getAttribute("data-active-session-id")).toBeTruthy(); + expect(probe?.getAttribute("data-session-action-ready")).toBe(""); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx new file mode 100644 index 0000000..d38d353 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx @@ -0,0 +1,437 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("exposes AI debug logs through a React hook", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + function DebugProbe() { + const debugLog = useAIDebugLog(editor); + + return ( +
          + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + const session = controller?.startSession({ + surface: "inline-edit", + target: "selection", + }); + if (session) { + await controller?.runSessionPrompt(session.id, "Rewrite the selection"); + } + }); + + const probe = container.querySelector("[data-entry-count]"); + expect(Number(probe?.getAttribute("data-entry-count"))).toBeGreaterThan(0); + expect(probe?.getAttribute("data-active-generation-id")).toBeTruthy(); + expect(probe?.getAttribute("data-aggregate-fast-apply-attempt-count")).toBe("1"); + expect(probe?.getAttribute("data-aggregate-fast-apply-native-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-attempt-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-native-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-scoped-count")).toBe("0"); + expect(probe?.getAttribute("data-fast-apply-plain-count")).toBe("0"); + expect(probe?.getAttribute("data-fast-apply-failed-count")).toBe("0"); + expect(probe?.getAttribute("data-last-entry-label")).toBe("Generation finished"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("reads fast-apply metrics for a requested session in the debug hook", async () => { + const editor = createEditor({ + extensions: [aiExtension({})], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + + function DebugProbe(props: { sessionId: string }) { + const debugLog = useAIDebugLog(editor, { sessionId: props.sessionId }); + + return ( +
          + ); + } + + const bottomChatSession = controller!.startSession({ + surface: "bottom-chat", + target: "document", + }); + const inlineSession = controller!.startSession({ + surface: "inline-edit", + target: "selection", + }); + expect(controller!.getState().activeSessionId).toBe(inlineSession.id); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + const controllerAny = controller as any; + controllerAny?._recordSessionFastApplyMetrics(bottomChatSession.id, { + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + }); + controllerAny?._recordSessionFastApplyMetrics(bottomChatSession.id, { + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + }); + root.render( + + + , + ); + await Promise.resolve(); + }); + + const probe = container.querySelector( + "[data-fast-apply-session-id]", + ) as HTMLElement | null; + expect(probe?.getAttribute("data-fast-apply-session-id")).toBe( + bottomChatSession.id, + ); + expect(probe?.getAttribute("data-aggregate-fast-apply-attempt-count")).toBe("2"); + expect(probe?.getAttribute("data-aggregate-fast-apply-native-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-attempt-count")).toBe("2"); + expect(probe?.getAttribute("data-fast-apply-native-count")).toBe("1"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx new file mode 100644 index 0000000..98070c7 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx @@ -0,0 +1,374 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("renders an inline AI session from the selection toolbar", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).toBeTruthy(); + expect(trigger?.disabled).toBe(false); + expect( + container.querySelector("[data-pen-selection-toolbar-content]"), + ).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInput = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + expect(inlineSessionInput).toBeTruthy(); + expect(document.activeElement).toBe(inlineSessionInput); + expect( + container.querySelector("[data-pen-selection-toolbar-content]"), + ).toBeNull(); + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).not.toBeNull(); + expect( + container.querySelector("[data-pen-ai-inline-session-turn-actions]"), + ).toBeNull(); + + await act(async () => { + const activeSessionId = controller?.getState().activeSessionId ?? null; + if (activeSessionId) { + await controller?.runSessionPrompt(activeSessionId, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const sessionId = controller?.getState().activeSessionId ?? null; + const sessionTurns = controller?.getState().sessions[0]?.turns ?? []; + expect(sessionTurns).toHaveLength(1); + expect( + container.querySelector("[data-pen-ai-inline-session-turn-actions]"), + ).not.toBeNull(); + + await act(async () => { + if (sessionId && sessionTurns[0]) { + controller?.acceptSessionTurn(sessionId, sessionTurns[0].id); + } + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx new file mode 100644 index 0000000..5fcde04 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx @@ -0,0 +1,459 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("keeps the inline prompt focused after submitting a prompt", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 180, + width: 80, + height: 20, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInput = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + const inlineSessionForm = container.querySelector( + "[data-pen-ai-inline-session-form]", + ) as HTMLFormElement | null; + expect(inlineSessionInput).not.toBeNull(); + expect(inlineSessionForm).not.toBeNull(); + + await act(async () => { + inlineSessionInput?.focus(); + controller.updateContextualPromptDraft(session!.id, "Rewrite this"); + for (let tick = 0; tick < 2; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + inlineSessionForm?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInputAfterSubmit = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + expect(inlineSessionInputAfterSubmit).not.toBeNull(); + expect(document.activeElement).toBe(inlineSessionInputAfterSubmit); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + it("keeps the inline session open after a second submitted edit", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 180, + width: 80, + height: 20, + }); + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInput = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + const inlineSessionForm = container.querySelector( + "[data-pen-ai-inline-session-form]", + ) as HTMLFormElement | null; + expect(inlineSessionInput).not.toBeNull(); + expect(inlineSessionForm).not.toBeNull(); + + await act(async () => { + inlineSessionInput?.focus(); + controller.updateContextualPromptDraft(session!.id, "Rewrite this"); + for (let tick = 0; tick < 2; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + inlineSessionForm?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInputAfterFirstSubmit = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + expect(inlineSessionInputAfterFirstSubmit).not.toBeNull(); + + await act(async () => { + controller.updateContextualPromptDraft( + session!.id, + "Make it more whimsical", + ); + for (let tick = 0; tick < 2; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + inlineSessionForm?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector("[data-pen-ai-inline-session-input]"), + ).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx new file mode 100644 index 0000000..663a50d --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx @@ -0,0 +1,356 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("keeps the inline AI selection overlay visible from the captured session target", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).not.toBeNull(); + const initialAffectedRange = container.querySelector( + "[data-ai-affected-range]", + ) as HTMLElement | null; + expect(initialAffectedRange).not.toBeNull(); + expect(initialAffectedRange?.textContent).toBe("world"); + expect( + container.querySelector("[data-pen-ai-inline-session-target-hint]")?.textContent, + ).toBe("AI target is active"); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).not.toBeNull(); + const preservedAffectedRange = container.querySelector( + "[data-ai-affected-range]", + ) as HTMLElement | null; + expect(preservedAffectedRange).not.toBeNull(); + expect(preservedAffectedRange?.textContent).toBe("world"); + expect( + container.querySelector("[data-pen-ai-inline-session-target-hint]")?.textContent, + ).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx new file mode 100644 index 0000000..a4d2019 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx @@ -0,0 +1,363 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("keeps the inline AI session bound to its captured selection", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector("[data-pen-ai-inline-session-target-hint]")?.textContent, + ).toBe("AI target is active"); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const activeSessionId = controller?.getState().activeSessionId ?? null; + if (activeSessionId) { + await controller?.runSessionPrompt(activeSessionId, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const sessionId = controller?.getState().activeSessionId ?? null; + const sessionTurns = controller?.getState().sessions[0]?.turns ?? []; + expect(sessionTurns).toHaveLength(1); + + await act(async () => { + if (sessionId && sessionTurns[0]) { + controller?.acceptSessionTurn(sessionId, sessionTurns[0].id); + } + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx new file mode 100644 index 0000000..6f20ff7 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx @@ -0,0 +1,344 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("opens a fresh inline session for a new selection instead of reusing old review state", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world again" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const firstSession = controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + if (firstSession) { + await controller?.runSessionPrompt(firstSession.id, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const firstSessionId = controller?.getState().activeSessionId ?? null; + expect( + container.querySelector("[data-pen-ai-inline-session-turn-actions]"), + ).not.toBeNull(); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const activeSession = controller?.getActiveSession() ?? null; + expect(activeSession?.id).not.toBe(firstSessionId); + expect(activeSession?.turns).toHaveLength(0); + expect( + container.querySelector("[data-pen-ai-inline-session-turn-actions]"), + ).toBeNull(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx new file mode 100644 index 0000000..f927445 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx @@ -0,0 +1,445 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("submits a new inline selection edit after keeping bottom-chat changes", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream() { + pass += 1; + yield { + type: "text-delta" as const, + delta: pass === 1 ? "Hello world" : "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const bottomChatSession = controller?.startSession({ + surface: "bottom-chat", + target: "document", + }); + if (bottomChatSession) { + await controller?.runSessionPrompt( + bottomChatSession.id, + "Write something in the document", + { target: "document" }, + ); + const keptTurnId = controller + ?.getSessions() + .find((session) => session.id === bottomChatSession.id) + ?.turns[0]?.id; + if (keptTurnId) { + controller?.acceptSessionTurn(bottomChatSession.id, keptTurnId); + } + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const blockId = editor.firstBlock()!.id; + await act(async () => { + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const activeSessionId = controller?.getState().activeSessionId ?? null; + if (activeSessionId) { + await controller?.runSessionPrompt(activeSessionId, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const activeSession = controller?.getActiveSession() ?? null; + expect(activeSession?.surface).toBe("inline-edit"); + expect(activeSession?.turns).toHaveLength(1); + expect(activeSession?.turns[0]?.status).toBe("review"); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + it("renders a durable affected-range decoration while the inline session is visible", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + , + ); + await Promise.resolve(); + }); + + await act(async () => { + controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const decorations = ( + controller as unknown as { + buildDecorations: () => Array<{ attributes?: Record }>; + } + ).buildDecorations(); + expect( + decorations.some( + (decoration) => decoration.attributes?.["data-ai-affected-range"] === "", + ), + ).toBe(true); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx new file mode 100644 index 0000000..8c2a352 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx @@ -0,0 +1,409 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("does not reopen raw inline UI history through keyboard shortcuts without a turn boundary", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + , + ); + await Promise.resolve(); + }); + + await act(async () => { + const session = controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + if (session) { + controller?.suspendInlineSession(session.id); + } + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(container.querySelector("[data-pen-ai-inline-session-input]")).toBeNull(); + + await act(async () => { + document.dispatchEvent(createKeyDownEvent("z", { ctrlKey: true })); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(container.querySelector("[data-pen-ai-inline-session-input]")).toBeNull(); + + await act(async () => { + document.dispatchEvent( + createKeyDownEvent("z", { ctrlKey: true, shiftKey: true }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(container.querySelector("[data-pen-ai-inline-session-input]")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + it("ignores inline history shortcuts from external textareas", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 180, + width: 80, + height: 20, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + +