From 4ea7542e5034dec5c71e3429312f16f237d88f7d Mon Sep 17 00:00:00 2001 From: janglad Date: Wed, 16 Sep 2026 13:12:37 +0200 Subject: [PATCH] Implement document placeholder eligibility based on content blocks This update introduces a new mechanism for determining document placeholder eligibility by counting content blocks instead of root blocks. A block schema can now specify `authoring.contentRole: "chrome"` for elements like email signatures, which will not suppress the empty-document placeholder. The `getBlockContentRole` function has been added to handle this logic, defaulting to `"content"` for existing hosts. Additionally, the `getDocumentPlaceholderTargetBlockId` function identifies the appropriate block for the placeholder, ensuring that the hint is displayed correctly in documents that start with chrome elements. Changes include: - New `getBlockContentRole` function in `@input/pen-core`. - Updated placeholder visibility logic in React and Vue bindings. - Tests added to verify the new behavior regarding document placeholders. This change enhances the user experience by providing clearer visual cues in the editor. --- .changeset/wild-pots-smile.md | 11 ++ packages/core/api-report.md | 1 + .../src/__tests__/blockCapabilities.test.ts | 19 +++ packages/core/src/editor/profilePolicy.ts | 32 +++- packages/core/src/index.ts | 2 + packages/rendering/dom/api-report.md | 2 +- .../__tests__/placeholderVisibility.test.ts | 11 +- .../__tests__/ri8DocumentPlaceholder.test.ts | 137 ++++++++++++++++++ .../dom/src/field-editor/contentGestures.ts | 3 - .../contentGesturesPointerSelection.ts | 16 +- .../src/field-editor/contentGesturesShared.ts | 1 - .../dom/src/utils/editorEmptyState.ts | 37 ++++- .../dom/src/utils/placeholderVisibility.ts | 6 +- ...olderBehavior.documentPlaceholder.test.tsx | 93 ++++++++++++ .../react/src/context/editorContentContext.ts | 4 +- .../react/src/hooks/useDocumentEmptyState.ts | 10 +- .../react/src/primitives/editor/content.tsx | 8 +- .../src/primitives/editor/inlineContent.tsx | 8 +- .../editor/useEditorContentGestures.ts | 4 - .../react/src/utils/editorEmptyState.ts | 2 +- .../vue/src/components/PenInlineContent.ts | 11 +- .../rendering/vue/src/internal/editorState.ts | 6 +- packages/types/api-report.md | 1 + packages/types/src/types/index.ts | 1 + packages/types/src/types/schema.ts | 3 + spec/packages/core.md | 2 +- spec/rules/dom.md | 4 +- 27 files changed, 365 insertions(+), 70 deletions(-) create mode 100644 .changeset/wild-pots-smile.md create mode 100644 packages/rendering/dom/src/__tests__/ri8DocumentPlaceholder.test.ts diff --git a/.changeset/wild-pots-smile.md b/.changeset/wild-pots-smile.md new file mode 100644 index 00000000..e7e5a3e4 --- /dev/null +++ b/.changeset/wild-pots-smile.md @@ -0,0 +1,11 @@ +--- +"@input/pen-types": patch +"@input/pen-core": patch +"@input/pen-dom": patch +"@input/pen-react": patch +"@input/pen-vue": patch +--- + +Count content blocks, not root blocks, when deciding document placeholder eligibility (RI8). A block schema can now declare `authoring.contentRole: "chrome"` for furniture the host puts in the document — an email signature, a quoted message — and such a block no longer suppresses the empty-document placeholder or pulls a click below the blocks into itself. `getBlockContentRole` (`@input/pen-core`) is the canonical reader; `contentRole` defaults to `"content"`, so existing hosts are unaffected. + +Eligibility names its block. `getDocumentPlaceholderTargetBlockId` (`@input/pen-dom`) returns the one block the hint paints on and the click-below caret lands in, or null when there is no target. The React and Vue bindings paint on the target instead of on the first root block, so a document that opens with chrome now shows the hint on its body. `InlinePlaceholderVisibilityOptions` replaces its `isFirstBlock` and `isDocumentEmpty` fields with a single `isDocumentPlaceholderTarget`. diff --git a/packages/core/api-report.md b/packages/core/api-report.md index bf993dc5..845ab09b 100644 --- a/packages/core/api-report.md +++ b/packages/core/api-report.md @@ -59,6 +59,7 @@ - filterPendingBlocksForDocumentProfile - foldAndNormalize - getApplyOptionsGroupId +- getBlockContentRole - getBlockSelectionRoleFromSchema - getBlockSelectionRoleFromType - getCellCaretFocus diff --git a/packages/core/src/__tests__/blockCapabilities.test.ts b/packages/core/src/__tests__/blockCapabilities.test.ts index 3e0aefa1..adefd182 100644 --- a/packages/core/src/__tests__/blockCapabilities.test.ts +++ b/packages/core/src/__tests__/blockCapabilities.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { BlockSchema, ContentType, PropSchema } from "@input/pen-types"; import { + getBlockContentRole, getBlockSelectionRoleFromSchema, getBlockSelectionRoleFromType, getFlowCapabilityFromSchema, @@ -46,6 +47,24 @@ describe("block capability helpers", () => { expect(getFlowCapabilityFromSchema(schema)).toBe("flow-delegated"); expect(getBlockSelectionRoleFromSchema(schema)).toBe("delegated"); + expect(getBlockContentRole(schema)).toBe("content"); + }); + + it("defaults contentRole to content and honors explicit chrome", () => { + expect(getBlockContentRole(block("paragraph"))).toBe("content"); + expect( + getBlockContentRole( + block("signature", { + content: "none", + fieldEditor: "none", + authoring: { + contentRole: "chrome", + }, + }), + ), + ).toBe("chrome"); + expect(getBlockContentRole(null)).toBe(null); + expect(getBlockContentRole(undefined)).toBe(null); }); it("keeps code editors inline-editable by default", () => { diff --git a/packages/core/src/editor/profilePolicy.ts b/packages/core/src/editor/profilePolicy.ts index 03de7e44..2b6aef27 100644 --- a/packages/core/src/editor/profilePolicy.ts +++ b/packages/core/src/editor/profilePolicy.ts @@ -1,5 +1,6 @@ import type { BlockAuthoring, + BlockContentRole, BlockSelectionRole, DocumentOp, DocumentProfile, @@ -75,12 +76,33 @@ export function getBlockSelectionRoleFromSchema( return "delegated"; } +/** + * Reads `authoring.contentRole` from a resolved block schema. + * + * Unset schemas default to `"content"` so only an explicit `"chrome"` + * declaration is furniture. Missing schemas return `null`. + * + * @param schema - Resolved block schema, or `null`/`undefined` when the type + * is unregistered. + * @returns The content role, or `null` when `schema` is missing. + * @throws Never. + */ +export function getBlockContentRole( + schema: BlockSchemaCapabilityLike, +): BlockContentRole | null { + if (!schema) { + return null; + } + + if (schema.authoring?.contentRole) { + return schema.authoring.contentRole; + } + + return "content"; +} + type LegacyBlockType = - | "codeBlock" - | "divider" - | "image" - | "subdocument" - | "table"; + "codeBlock" | "divider" | "image" | "subdocument" | "table"; function isLegacyBlockType(value: string): value is LegacyBlockType { return ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c32ef28c..ec55bec3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ import { filterOpsForDocumentProfile, filterPendingBlocksForDocumentProfile, createImportResult, + getBlockContentRole, getBlockSelectionRoleFromSchema, getBlockSelectionRoleFromType, getFlowCapabilityFromSchema, @@ -94,6 +95,7 @@ export { createImportResult, filterOpsForDocumentProfile, filterPendingBlocksForDocumentProfile, + getBlockContentRole, getBlockSelectionRoleFromSchema, getBlockSelectionRoleFromType, getFlowCapabilityFromSchema, diff --git a/packages/rendering/dom/api-report.md b/packages/rendering/dom/api-report.md index 8d69cf7c..e3ccd24e 100644 --- a/packages/rendering/dom/api-report.md +++ b/packages/rendering/dom/api-report.md @@ -654,7 +654,7 @@ _no exports_ ### function - computeDocumentEmpty -- computeDocumentPlaceholderVisible +- getDocumentPlaceholderTargetBlockId - isInlineContentEmpty ## ./utils/environment diff --git a/packages/rendering/dom/src/__tests__/placeholderVisibility.test.ts b/packages/rendering/dom/src/__tests__/placeholderVisibility.test.ts index 0a083d69..ed7a76aa 100644 --- a/packages/rendering/dom/src/__tests__/placeholderVisibility.test.ts +++ b/packages/rendering/dom/src/__tests__/placeholderVisibility.test.ts @@ -6,8 +6,7 @@ import { const baseOptions = { blockTextEmpty: true, - isDocumentEmpty: false, - isFirstBlock: false, + isDocumentPlaceholderTarget: false, isFocusedBlock: true, hasEmptyPlaceholder: true, hasExplicitPlaceholder: false, @@ -20,8 +19,7 @@ describe("resolveInlinePlaceholderVisibility", () => { expect( resolveInlinePlaceholderVisibility({ ...baseOptions, - isDocumentEmpty: true, - isFirstBlock: true, + isDocumentPlaceholderTarget: true, hasExplicitPlaceholder: true, suppressPlaceholders: true, }), @@ -32,12 +30,11 @@ describe("resolveInlinePlaceholderVisibility", () => { }); }); - it("prefers the document placeholder for the first empty document block", () => { + it("prefers the document placeholder on the document placeholder target", () => { expect( resolveInlinePlaceholderVisibility({ ...baseOptions, - isDocumentEmpty: true, - isFirstBlock: true, + isDocumentPlaceholderTarget: true, hasExplicitPlaceholder: true, }), ).toEqual({ diff --git a/packages/rendering/dom/src/__tests__/ri8DocumentPlaceholder.test.ts b/packages/rendering/dom/src/__tests__/ri8DocumentPlaceholder.test.ts new file mode 100644 index 00000000..0f74b6c7 --- /dev/null +++ b/packages/rendering/dom/src/__tests__/ri8DocumentPlaceholder.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + createHeadlessEditor, + defineBlock, + mergeSchemas, + SchemaRegistryImpl, +} from "@input/pen-core"; +import { defaultSchema } from "@input/pen-schema"; +import type { Editor } from "@input/pen-types"; +import { getDocumentPlaceholderTargetBlockId } from "../utils/editorEmptyState"; + +/** + * An email signature: chrome the host puts in the document, which the user + * did not write and is not asked to write. + */ +const signature = defineBlock("signature", { + content: "none", + fieldEditor: "none", + authoring: { + contentRole: "chrome", + flowCapability: "flow-structural", + selectionRole: "structural", + }, +}); + +const schema = mergeSchemas( + defaultSchema, + new SchemaRegistryImpl({ blocks: [signature], inlines: [] }), +); + +function createComposerEditor(): Editor { + return createHeadlessEditor({ schema }); +} + +function insertSignature(editor: Editor, position: "first" | "last"): void { + editor.apply( + [ + { + type: "insert-block", + blockId: "signature-1", + blockType: "signature", + props: {}, + position, + }, + ], + { origin: "user" }, + ); +} + +function appendSignature(editor: Editor): void { + insertSignature(editor, "last"); +} + +describe("RI8: document placeholder eligibility", () => { + it("targets the body block when the only other root block is chrome", () => { + const editor = createComposerEditor(); + const bodyId = editor.firstBlock()!.id; + appendSignature(editor); + + expect(getDocumentPlaceholderTargetBlockId(editor)).toBe(bodyId); + + editor.destroy(); + }); + + // Eligibility names the block it is about, so a document that opens with + // chrome targets the body rather than the first root block. The paint site + // and the click-below caret both read this id. + it("targets the body block when the document opens with chrome", () => { + const editor = createComposerEditor(); + const bodyId = editor.firstBlock()!.id; + insertSignature(editor, "first"); + + expect(editor.documentState.blockOrder[0]).toBe("signature-1"); + expect(getDocumentPlaceholderTargetBlockId(editor)).toBe(bodyId); + + editor.destroy(); + }); + + it("has no target when every root block is chrome", () => { + const editor = createComposerEditor(); + const bodyId = editor.firstBlock()!.id; + appendSignature(editor); + + editor.apply([{ type: "delete-block", blockId: bodyId }], { + origin: "user", + }); + + expect(getDocumentPlaceholderTargetBlockId(editor)).toBeNull(); + + editor.destroy(); + }); + + it("drops eligibility once the body beside the chrome has text", () => { + const editor = createComposerEditor(); + appendSignature(editor); + const bodyId = editor.firstBlock()!.id; + + editor.apply( + [ + { + type: "splice-text", + blockId: bodyId, + from: 0, + to: 0, + insert: "hi", + }, + ], + { origin: "user" }, + ); + + expect(getDocumentPlaceholderTargetBlockId(editor)).toBeNull(); + + editor.destroy(); + }); + + it("drops eligibility for a second content block, chrome or not", () => { + const editor = createComposerEditor(); + appendSignature(editor); + + editor.apply( + [ + { + type: "insert-block", + blockId: "divider-1", + blockType: "divider", + props: {}, + position: "last", + }, + ], + { origin: "user" }, + ); + + expect(getDocumentPlaceholderTargetBlockId(editor)).toBeNull(); + + editor.destroy(); + }); +}); diff --git a/packages/rendering/dom/src/field-editor/contentGestures.ts b/packages/rendering/dom/src/field-editor/contentGestures.ts index 4442bc31..c2c737e6 100644 --- a/packages/rendering/dom/src/field-editor/contentGestures.ts +++ b/packages/rendering/dom/src/field-editor/contentGestures.ts @@ -46,7 +46,6 @@ export interface AttachContentGesturesOptions< regionSelectionStore: RegionSelectionStore; state: ContentGestureState; blockSelectionEnabled: boolean; - isDocumentPlaceholderVisible: boolean; runSync?: ((run: () => void) => void) | undefined; } @@ -61,7 +60,6 @@ export function attachContentGestures< regionSelectionStore, state, blockSelectionEnabled, - isDocumentPlaceholderVisible, } = options; const runSync = options.runSync ?? ((run: () => void) => run()); const { @@ -91,7 +89,6 @@ export function attachContentGestures< interactionModelRef, clearPointerSelectionState, blockSelectionEnabled, - isDocumentPlaceholderVisible, runSync, }; diff --git a/packages/rendering/dom/src/field-editor/contentGesturesPointerSelection.ts b/packages/rendering/dom/src/field-editor/contentGesturesPointerSelection.ts index 27e36f0a..a1ee218f 100644 --- a/packages/rendering/dom/src/field-editor/contentGesturesPointerSelection.ts +++ b/packages/rendering/dom/src/field-editor/contentGesturesPointerSelection.ts @@ -8,6 +8,7 @@ import { getRootGeometry, measureWithRoot } from "../geometry/rootGeometry"; import { getEditorBlockSelectionRole } from "../utils/blockSelectionSemantics"; import { DATA_ATTRS } from "../utils/dataAttributes"; import { getPreorderBlockIds } from "../utils/documentPreorder"; +import { getDocumentPlaceholderTargetBlockId } from "../utils/editorEmptyState"; import { isRepeatedCellSelection, resolveBlockPointerIntent, @@ -46,7 +47,6 @@ export function createPointerSelectionGestures< interactionModelRef, clearPointerSelectionState, blockSelectionEnabled, - isDocumentPlaceholderVisible, } = ctx; const handleClickOutsideBlocks = (event: MouseEvent): boolean => { @@ -81,15 +81,11 @@ export function createPointerSelectionGestures< return true; } - if (isDocumentPlaceholderVisible) { - const firstBlock = editor.firstBlock(); - if (firstBlock) { - const schema = editor.schema.resolve(firstBlock.type); - if (usesInlineTextSelection(schema)) { - fieldEditor.activateTextSelection?.(firstBlock.id, 0, 0); - return true; - } - } + const placeholderTargetBlockId = + getDocumentPlaceholderTargetBlockId(editor); + if (placeholderTargetBlockId) { + fieldEditor.activateTextSelection?.(placeholderTargetBlockId, 0, 0); + return true; } const firstBlockId = firstBlockEl.getAttribute("data-block-id"); diff --git a/packages/rendering/dom/src/field-editor/contentGesturesShared.ts b/packages/rendering/dom/src/field-editor/contentGesturesShared.ts index 9fb3146d..f5e2aca6 100644 --- a/packages/rendering/dom/src/field-editor/contentGesturesShared.ts +++ b/packages/rendering/dom/src/field-editor/contentGesturesShared.ts @@ -41,7 +41,6 @@ export interface ContentGesturesContext< interactionModelRef: GestureSlot; clearPointerSelectionState(): void; blockSelectionEnabled: boolean; - isDocumentPlaceholderVisible: boolean; runSync: (run: () => void) => void; } diff --git a/packages/rendering/dom/src/utils/editorEmptyState.ts b/packages/rendering/dom/src/utils/editorEmptyState.ts index 66567d4f..a9636c59 100644 --- a/packages/rendering/dom/src/utils/editorEmptyState.ts +++ b/packages/rendering/dom/src/utils/editorEmptyState.ts @@ -1,3 +1,4 @@ +import { getBlockContentRole } from "@input/pen-core"; import type { Editor } from "@input/pen-types"; interface InlineDeltaLike { @@ -8,21 +9,41 @@ export function computeDocumentEmpty(editor: Editor): boolean { return editor.documentState.isEmpty; } -export function computeDocumentPlaceholderVisible(editor: Editor): boolean { - const { blockOrder } = editor.documentState; - if (blockOrder.length === 0) return true; - if (blockOrder.length > 1) return false; - const block = editor.getBlock(blockOrder[0]); - if (!block) return true; +/** + * The empty-document placeholder's block, or `null` when the document is not + * eligible. Chrome (`getBlockContentRole` === `"chrome"`) is not content. + * + * @param editor - Live editor whose root `blockOrder` is read. + * @returns The sole empty inline content block, or `null`. + * @throws Never. + */ +export function getDocumentPlaceholderTargetBlockId( + editor: Editor, +): string | null { + const contentBlockIds = editor.documentState.blockOrder.filter( + (blockId) => !isChromeBlock(editor, blockId), + ); + if (contentBlockIds.length !== 1) return null; + + const blockId = contentBlockIds[0]; + const block = editor.getBlock(blockId); + if (!block) return null; const schema = editor.schema.resolve(block.type); if ( !schema || schema.content !== "inline" || schema.fieldEditor === "none" ) { - return false; + return null; } - return isInlineContentEmpty(block.inlineDeltas()); + return isInlineContentEmpty(block.inlineDeltas()) ? blockId : null; +} + +function isChromeBlock(editor: Editor, blockId: string): boolean { + const block = editor.getBlock(blockId); + if (!block) return false; + const schema = editor.schema.resolve(block.type); + return getBlockContentRole(schema) === "chrome"; } export function isInlineContentEmpty( diff --git a/packages/rendering/dom/src/utils/placeholderVisibility.ts b/packages/rendering/dom/src/utils/placeholderVisibility.ts index 60362b60..8657418e 100644 --- a/packages/rendering/dom/src/utils/placeholderVisibility.ts +++ b/packages/rendering/dom/src/utils/placeholderVisibility.ts @@ -1,7 +1,6 @@ export interface InlinePlaceholderVisibilityOptions { blockTextEmpty: boolean; - isDocumentEmpty: boolean; - isFirstBlock: boolean; + isDocumentPlaceholderTarget: boolean; isFocusedBlock: boolean; hasEmptyPlaceholder: boolean; hasExplicitPlaceholder: boolean; @@ -28,8 +27,7 @@ export function resolveInlinePlaceholderVisibility( const showDocumentPlaceholder = options.blockTextEmpty && - options.isFirstBlock && - options.isDocumentEmpty && + options.isDocumentPlaceholderTarget && options.hasEmptyPlaceholder; const showExplicitPlaceholder = options.blockTextEmpty && diff --git a/packages/rendering/react/src/__tests__/placeholderBehavior.documentPlaceholder.test.tsx b/packages/rendering/react/src/__tests__/placeholderBehavior.documentPlaceholder.test.tsx index 2aed6ce8..b62f17bb 100644 --- a/packages/rendering/react/src/__tests__/placeholderBehavior.documentPlaceholder.test.tsx +++ b/packages/rendering/react/src/__tests__/placeholderBehavior.documentPlaceholder.test.tsx @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { createRoot } from "react-dom/client"; import { createEditor, + defineBlock, ensureInlineCompletionController, + mergeSchemas, + SchemaRegistryImpl, } from "@input/pen-core"; import { type BlockHandle, type BlockRenderContext } from "@input/pen-types"; import { defaultPreset } from "@input/pen"; @@ -36,6 +39,39 @@ function PlaceholderParagraphRenderer( ); } +/** + * An email signature: chrome the host puts in the document, which the user did + * not write and is not asked to write (RI8). + */ +const signature = defineBlock("signature", { + content: "none", + fieldEditor: "none", + authoring: { + contentRole: "chrome", + flowCapability: "flow-structural", + selectionRole: "structural", + }, +}); + +const composerSchema = mergeSchemas( + defaultSchema, + new SchemaRegistryImpl({ blocks: [signature], inlines: [] }), +); + +function SignatureRenderer( + block: BlockHandle, + ctx: BlockRenderContext, +): React.ReactElement { + return ( +
} + data-block-type="signature" + > + — Ada +
+ ); +} + afterEach(() => { registerRenderer("paragraph", ParagraphRenderer); }); @@ -85,6 +121,63 @@ describe("@input/pen-react placeholder behavior: the document placeholder", () = editor.destroy(); }); + it("paints the document placeholder on the body of a chrome-first document", async () => { + registerRenderer("paragraph", PlaceholderParagraphRenderer); + + const editor = createEditor({ + schema: composerSchema, + preset: defaultPreset({ + tools: false, + deltaStream: false, + undo: false, + }), + }); + const bodyId = editor.firstBlock()!.id; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + editor.apply([ + { + type: "insert-block", + blockId: "signature-1", + blockType: "signature", + props: {}, + position: "first", + }, + ]); + + await act(async () => { + root.render( + + + , + ); + }); + + const placeholders = container.querySelectorAll( + "[data-placeholder-visible]", + ); + expect(placeholders).toHaveLength(1); + expect(placeholders[0]?.getAttribute("data-placeholder")).toBe( + "Start writing...", + ); + const bodyElement = container.querySelector( + `[data-block-id="${bodyId}"]`, + ); + expect(bodyElement).not.toBeNull(); + expect(placeholders[0]?.closest("[data-block-id]")).toBe(bodyElement); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + it("hides the document empty placeholder for a single atom-only block", async () => { registerRenderer("paragraph", PlaceholderParagraphRenderer); diff --git a/packages/rendering/react/src/context/editorContentContext.ts b/packages/rendering/react/src/context/editorContentContext.ts index 33cb91e0..f58d33e8 100644 --- a/packages/rendering/react/src/context/editorContentContext.ts +++ b/packages/rendering/react/src/context/editorContentContext.ts @@ -2,12 +2,12 @@ import { createContext, useContext } from "react"; export interface EditorContentContextValue { emptyPlaceholder?: string; - isEmpty: boolean; + documentPlaceholderTargetBlockId: string | null; } const EMPTY_EDITOR_CONTENT_CONTEXT: EditorContentContextValue = { emptyPlaceholder: undefined, - isEmpty: false, + documentPlaceholderTargetBlockId: null, }; export const EditorContentContext = diff --git a/packages/rendering/react/src/hooks/useDocumentEmptyState.ts b/packages/rendering/react/src/hooks/useDocumentEmptyState.ts index 9daac853..7e8a60e0 100644 --- a/packages/rendering/react/src/hooks/useDocumentEmptyState.ts +++ b/packages/rendering/react/src/hooks/useDocumentEmptyState.ts @@ -2,7 +2,7 @@ import { useRef, useSyncExternalStore } from "react"; import type { Editor } from "@input/pen-types"; import { computeDocumentEmpty, - computeDocumentPlaceholderVisible, + getDocumentPlaceholderTargetBlockId, } from "../utils/editorEmptyState"; export function useDocumentEmptyState(editor: Editor): boolean { @@ -22,19 +22,19 @@ export function useDocumentEmptyState(editor: Editor): boolean { ); } -export function useDocumentPlaceholderState(editor: Editor): boolean { - const snapshotRef = useRef(computeDocumentPlaceholderVisible(editor)); +export function useDocumentPlaceholderTarget(editor: Editor): string | null { + const snapshotRef = useRef(getDocumentPlaceholderTargetBlockId(editor)); return useSyncExternalStore( (callback) => editor.on("commit", () => callback()), () => { - const nextSnapshot = computeDocumentPlaceholderVisible(editor); + const nextSnapshot = getDocumentPlaceholderTargetBlockId(editor); if (snapshotRef.current === nextSnapshot) { return snapshotRef.current; } snapshotRef.current = nextSnapshot; return nextSnapshot; }, - () => false, + () => null, ); } diff --git a/packages/rendering/react/src/primitives/editor/content.tsx b/packages/rendering/react/src/primitives/editor/content.tsx index d1a3d57c..28db1ee2 100644 --- a/packages/rendering/react/src/primitives/editor/content.tsx +++ b/packages/rendering/react/src/primitives/editor/content.tsx @@ -10,7 +10,7 @@ import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect import { useBlockList } from "../../hooks/useBlockList"; import { useDocumentEmptyState, - useDocumentPlaceholderState, + useDocumentPlaceholderTarget, } from "../../hooks/useDocumentEmptyState"; import { useInlineCompletionState } from "../../hooks/useInlineCompletionState"; import { renderAsChild, type AsChildProps } from "../../utils/asChild"; @@ -76,7 +76,8 @@ export function EditorContent(props: EditorContentProps) { } = useEditorContentPointerState(interactionModel); const isEmpty = useDocumentEmptyState(editor); - const isDocumentPlaceholderVisible = useDocumentPlaceholderState(editor); + const documentPlaceholderTargetBlockId = + useDocumentPlaceholderTarget(editor); const { isDropActive, dropPreview, @@ -124,7 +125,6 @@ export function EditorContent(props: EditorContentProps) { contentRef, blocksHostRef, regionSelectionStore, - isDocumentPlaceholderVisible, regionGestureRef, pointerGestureRef, pointerGestureVersionRef, @@ -288,7 +288,7 @@ export function EditorContent(props: EditorContentProps) { return ( {renderAsChild( diff --git a/packages/rendering/react/src/primitives/editor/inlineContent.tsx b/packages/rendering/react/src/primitives/editor/inlineContent.tsx index 84f1bdad..3a39f420 100644 --- a/packages/rendering/react/src/primitives/editor/inlineContent.tsx +++ b/packages/rendering/react/src/primitives/editor/inlineContent.tsx @@ -52,7 +52,7 @@ export function InlineContent(props: InlineContentProps) { } = props; const { editor, inlineAtomInteractions, inlineAtomRenderers, readonly } = useEditorContext(); - const { emptyPlaceholder, isEmpty: isDocumentEmpty } = + const { emptyPlaceholder, documentPlaceholderTargetBlockId } = useEditorContentContext(); const fieldEditor = useFieldEditorContext(); const fieldEditorState = useFieldEditorState(fieldEditor); @@ -74,7 +74,8 @@ export function InlineContent(props: InlineContentProps) { fieldEditorState.mode === "expanded" && fieldEditorState.activeBlockIds.includes(blockId); - const isFirstBlock = editor.documentState.blockOrder[0] === blockId; + const isDocumentPlaceholderTarget = + documentPlaceholderTargetBlockId === blockId; const schemaPlaceholder = resolveEditorSchemaPlaceholder(editor, blockId); const isFocusedBlock = isActive || @@ -96,8 +97,7 @@ export function InlineContent(props: InlineContentProps) { showBlockPlaceholder, } = resolveInlinePlaceholderVisibility({ blockTextEmpty, - isDocumentEmpty, - isFirstBlock, + isDocumentPlaceholderTarget, isFocusedBlock, hasEmptyPlaceholder: !!emptyPlaceholder, hasExplicitPlaceholder: !!placeholderProp, diff --git a/packages/rendering/react/src/primitives/editor/useEditorContentGestures.ts b/packages/rendering/react/src/primitives/editor/useEditorContentGestures.ts index c917865a..a99d82b4 100644 --- a/packages/rendering/react/src/primitives/editor/useEditorContentGestures.ts +++ b/packages/rendering/react/src/primitives/editor/useEditorContentGestures.ts @@ -20,7 +20,6 @@ export interface UseEditorContentGesturesOptions extends EditorContentPointerSta contentRef: RefObject; blocksHostRef: RefObject; regionSelectionStore: RegionSelectionStore; - isDocumentPlaceholderVisible: boolean; } export function useEditorContentGestures( @@ -34,7 +33,6 @@ export function useEditorContentGestures( contentRef, blocksHostRef, regionSelectionStore, - isDocumentPlaceholderVisible, regionGestureRef, pointerGestureRef, pointerGestureVersionRef, @@ -54,7 +52,6 @@ export function useEditorContentGestures( getBlocksHost: () => blocksHostRef.current, regionSelectionStore, blockSelectionEnabled: blockSelection.enabled, - isDocumentPlaceholderVisible, runSync: flushSync, state: { regionGesture: regionGestureRef, @@ -69,7 +66,6 @@ export function useEditorContentGestures( blockSelection.enabled, editor, fieldEditor, - isDocumentPlaceholderVisible, readonly, regionSelectionStore, ]); diff --git a/packages/rendering/react/src/utils/editorEmptyState.ts b/packages/rendering/react/src/utils/editorEmptyState.ts index c7e462a1..2a3fb170 100644 --- a/packages/rendering/react/src/utils/editorEmptyState.ts +++ b/packages/rendering/react/src/utils/editorEmptyState.ts @@ -1,5 +1,5 @@ export { computeDocumentEmpty, - computeDocumentPlaceholderVisible, + getDocumentPlaceholderTargetBlockId, isInlineContentEmpty, } from "@input/pen-dom/utils/editorEmptyState"; diff --git a/packages/rendering/vue/src/components/PenInlineContent.ts b/packages/rendering/vue/src/components/PenInlineContent.ts index 3d642fd1..2d33e550 100644 --- a/packages/rendering/vue/src/components/PenInlineContent.ts +++ b/packages/rendering/vue/src/components/PenInlineContent.ts @@ -25,7 +25,7 @@ import { useBlockDecorations, useBlockModel, useBlockTextSnapshot, - useDocumentPlaceholderState, + useDocumentPlaceholderTarget, useFieldEditorState, } from "../internal/editorState"; import { resolveEditorSchemaPlaceholder } from "../internal/displayCopy"; @@ -66,7 +66,7 @@ export const PenInlineContent = defineComponent({ const blockModel = useBlockModel(editor, props.blockId); const blockDecorations = useBlockDecorations(editor, props.blockId); const textSnapshot = useBlockTextSnapshot(editor, props.blockId); - const documentPlaceholderVisible = useDocumentPlaceholderState(editor); + const documentPlaceholderTarget = useDocumentPlaceholderTarget(editor); const elementRef = ref(null); const isActive = computed( @@ -80,8 +80,8 @@ export const PenInlineContent = defineComponent({ const schemaPlaceholder = computed(() => resolveEditorSchemaPlaceholder(editor, props.blockId), ); - const isFirstBlock = computed( - () => editor.documentState.blockOrder[0] === props.blockId, + const isDocumentPlaceholderTarget = computed( + () => documentPlaceholderTarget.value === props.blockId, ); const isFocusedBlock = computed(() => { return ( @@ -97,8 +97,7 @@ export const PenInlineContent = defineComponent({ const placeholderVisibility = computed(() => resolveInlinePlaceholderVisibility({ blockTextEmpty: blockTextEmpty.value, - isDocumentEmpty: documentPlaceholderVisible.value, - isFirstBlock: isFirstBlock.value, + isDocumentPlaceholderTarget: isDocumentPlaceholderTarget.value, isFocusedBlock: isFocusedBlock.value, hasEmptyPlaceholder: !!emptyPlaceholder.value, hasExplicitPlaceholder: !!props.placeholder, diff --git a/packages/rendering/vue/src/internal/editorState.ts b/packages/rendering/vue/src/internal/editorState.ts index 00d6189c..40770799 100644 --- a/packages/rendering/vue/src/internal/editorState.ts +++ b/packages/rendering/vue/src/internal/editorState.ts @@ -8,7 +8,7 @@ import type { FieldEditorStore, FieldEditorStoreSnapshot, } from "@input/pen-dom/field-editor/store"; -import { computeDocumentPlaceholderVisible } from "@input/pen-dom/utils/editorEmptyState"; +import { getDocumentPlaceholderTargetBlockId } from "@input/pen-dom/utils/editorEmptyState"; import { getChildBlockIds } from "@input/pen-dom/utils/parentIdTree"; import type { Decoration, @@ -68,10 +68,10 @@ export function useDocumentEmptyState(editor: Editor) { ); } -export function useDocumentPlaceholderState(editor: Editor) { +export function useDocumentPlaceholderTarget(editor: Editor) { return useExternalStore( (callback) => editor.on("commit", () => callback()), - () => computeDocumentPlaceholderVisible(editor), + () => getDocumentPlaceholderTargetBlockId(editor), ); } diff --git a/packages/types/api-report.md b/packages/types/api-report.md index 21a33668..dbd3c426 100644 --- a/packages/types/api-report.md +++ b/packages/types/api-report.md @@ -100,6 +100,7 @@ - BlockAuthoring - BlockCapabilityKey - BlockCapabilityMap +- BlockContentRole - BlockDecoration - BlockDeletePart - BlockDisplay diff --git a/packages/types/src/types/index.ts b/packages/types/src/types/index.ts index b474615a..174109a7 100644 --- a/packages/types/src/types/index.ts +++ b/packages/types/src/types/index.ts @@ -121,6 +121,7 @@ export { type ContentType, type BlockDisplay, type BlockAuthoring, + type BlockContentRole, type BlockSelectionRole, type FlowBlockCapability, type ImportInlineMark, diff --git a/packages/types/src/types/schema.ts b/packages/types/src/types/schema.ts index 8d22d77e..5bc1cd70 100644 --- a/packages/types/src/types/schema.ts +++ b/packages/types/src/types/schema.ts @@ -94,9 +94,12 @@ export type FlowBlockCapability = export type BlockSelectionRole = "editable-inline" | "structural" | "delegated"; +export type BlockContentRole = "content" | "chrome"; + export interface BlockAuthoring { flowCapability?: FlowBlockCapability; selectionRole?: BlockSelectionRole; + contentRole?: BlockContentRole; } export interface BlockSchema< diff --git a/spec/packages/core.md b/spec/packages/core.md index 967e45d7..c86ed88c 100644 --- a/spec/packages/core.md +++ b/spec/packages/core.md @@ -18,7 +18,7 @@ Every higher-level package depends on the contracts and runtime behavior establi - Import and profile-policy helpers such as `blocksToOps()`, `normalizePendingBlocksForImport()`, `filterOpsForDocumentProfile()`, and related policy-reporting APIs. Core owns the implementation and the public export. - Slash-menu display ordering (`orderSlashMenuItemsByGroup()`, `slashMenuGroupOf()`), which partitions `allBlockDisplays()` by `display.group` so a menu's rendered order equals the index order it navigates and confirms. It lives here, beside the `allBlockDisplays()` registry it reorders, because it is DOM-free and every renderer's slash menu needs the same invariant (API6). - Suggestion-menu target matching (`resolveSuggestionMenuTarget()`, `inlineLogicalText()`): caret-offset lookbehind in the logical domain (N6). React's `useSuggestionMenu` re-exports the resolver. -- Block-capability helpers (`getFlowCapabilityFromSchema()`, `shouldExposeBlockInTooling()`, and siblings) and selection-target helpers (`resolveSelectionTargetBlockIds()`, `renderSelectionTargetText()`, `renderSelectionTargetBlockText()`) +- Block-capability helpers (`getFlowCapabilityFromSchema()`, `getBlockContentRole()`, `shouldExposeBlockInTooling()`, and siblings) and selection-target helpers (`resolveSelectionTargetBlockIds()`, `renderSelectionTargetText()`, `renderSelectionTargetBlockText()`) - `mapOffsetThroughSplices()` — the per-block clamp helper for one summary, moved here from `@input/pen-types` by v4 DL12 so types can reach its types-only end state. There is still no compose and no cross-commit mapping form; anchors carry positions across commits. - Catalog helpers (`interpolateMessage()`, `resolveMessage()`), mutation-group helpers (`createMutationGroupMetadata()`, `getApplyOptionsGroupId()`, `getOpOriginGroupId()`, `getOpOriginType()`), field-editor helpers (`usesInlineTextSelection()`, `supportsInlineMarks()`, and siblings), and tool-execution helper `collectToolExecutionOutput()` - Locale-aware case folding (`foldAndNormalize()`) next to `localeFacet`; search, AI alignment, and suggestions call this instead of `toLowerCase()` diff --git a/spec/rules/dom.md b/spec/rules/dom.md index ece90fc8..f373d7de 100644 --- a/spec/rules/dom.md +++ b/spec/rules/dom.md @@ -87,6 +87,8 @@ Exporters emit text as stored: Pen inserts no LRM or RLM direction marks on expo - RI7. Mark wrappers expose every stored prop a host must recover from the live DOM. Built-in semantic marks use HTML elements (`strong`, `em`, `u`, `s`, `code`, `a`, `mark`). `link` writes `href` and `title`. `suggestion` writes `data-mark-type`, `data-suggestion-id`, `data-suggestion-action`, and the review-surface class. The three colour marks share one path: each writes `data-color` — the stored `color` string, whether a CSS colour or an opaque host token — and paints through a `var()` fallback, so `highlight` is `background-color: var(--pen-highlight-color, )` on `` while `textColor` is `color: var(--pen-text-color, )` and `backgroundColor` is `background-color: var(--pen-background-color, )` on a ``. `` carries no `data-mark-type`, because a semantic element is its own type; hosts select `mark[data-color="…"]`. The library never assigns those custom properties, so a host stylesheet that sets one on the mark wins at normal specificity and needs no `!important`. The corollary is that the paint is an inline style: a host rule setting `color` or `background-color` on the mark itself no longer applies, and setting the token is the only supported remap. A colour mark whose `color` prop is missing, non-string, or empty paints nothing and writes no `data-color`. Unknown marks fall through to `` with no props copied. Export serializers (`serialize.toHTML`) are a separate surface and are unchanged by this rule — they still interpolate the stored value into an inline `style` on the exported HTML, which is also why copy carries the stored colour rather than the `var()` string: clipboard HTML comes from `schema.serialize.toHTML`, never from the live DOM. +- RI8. Document placeholder eligibility counts content blocks, not root blocks, and names the block it is about. `getDocumentPlaceholderTargetBlockId` (`@input/pen-dom`) filters `blockOrder` through `getBlockContentRole` (`@input/pen-core`) and asks the old question of what is left, except that it answers with a block rather than a boolean: exactly one content block, empty, whose content is `inline` and whose field editor is not `none`, is the target; two content blocks, or none at all, leave no target. That block id is the only notion the placeholder has of which block is its own — the React and Vue bindings paint where `blockId` equals the target rather than where it equals `blockOrder[0]`, and `contentGesturesPointerSelection.ts` lands the click-below caret in the target rather than in `editor.firstBlock()` — so eligibility cannot answer yes where nothing paints and no caret lands. `contentRole` defaults to `"content"`, so nothing changes for a host that does not set it, and `"chrome"` is the host stating that a block type is furniture the document carries rather than writing the document is waiting for — an email signature or a quoted message, present because the product put it there and not because the user typed it. Pen cannot infer that: `divider` declares the same `fieldEditor: "none"`, `selectionRole: "structural"`, and `flowCapability: "flow-structural"` as such a block, and `[empty paragraph, divider]` is a document with content in it. The same answer decides where a click below the blocks lands — a target is activated, no target falls through to G4's geometry — so an undeclared chrome block costs the host both the hint and the caret, which is one bug with two faces rather than two bugs. A document that _opens_ with chrome behaves like one that ends with it, because `resolveInlinePlaceholderVisibility` takes a single `isDocumentPlaceholderTarget` in place of the `isFirstBlock` and `isDocumentEmpty` pair it used to carry, and a document of nothing but chrome has no target and so no hint — there is no block to paint it on or to put a caret in, and a click below the content inserts a paragraph through the ordinary click-outside path. Chrome does not reach `documentState.isEmpty` or the `data-empty` attribute it drives: emptiness of storage is a different question from whether to invite the user to write, and only the second one has a host-relative answer. The per-block placeholder stays focus-gated in `resolveInlinePlaceholderVisibility`, which is why a document-level hint belongs on `emptyPlaceholder` and not broadcast as every paragraph's `placeholder`: the block channel paints on each empty block the caret visits, inside an opened container included. Evidence: `packages/rendering/dom/src/__tests__/ri8DocumentPlaceholder.test.ts` for the target, and `packages/rendering/react/src/__tests__/placeholderBehavior.documentPlaceholder.test.tsx` for the paint site agreeing with it on a chrome-first document. + ## Retired -No member of SCH, FE, G, OV, DIR, BR, M, or RI is retired. FE9 was added after a host `editor.apply` remapped the authority caret but left EditContext and `edit-context-textupdate` on the pre-apply offset, so the next keystroke clamped or inserted in the wrong place. FE10 was added after a drag from the content padding beside the column selected nothing at all: `mousedown` outside a block opened no gesture, so the two handlers that grow a selection had none to grow and the model's collapsed caret was what survived the gesture. RI5 was added after Shift+Enter shipped storing a `\n` that nothing rendered. RI6 was added after a host-defined container proved unrenderable on every surface despite a working data model. RI7 was added after `textColor` and `backgroundColor` fell through to the unknown-mark span and dropped the stored colour, so on-screen paint and host restyling had nothing to select; `highlight` was folded into the same path in the same change rather than left on its older direct-inline-style taste, which would have kept one of the three colour marks unremappable without `!important`. The geometry rules no longer depend on a storage sentinel: the `\u200B` empty-block sentinel that G1's original wording measured through is gone from storage, so offsets resolve through `offsetDomain.ts` alone and no measurement path tests for a sentinel. +No member of SCH, FE, G, OV, DIR, BR, M, or RI is retired. FE9 was added after a host `editor.apply` remapped the authority caret but left EditContext and `edit-context-textupdate` on the pre-apply offset, so the next keystroke clamped or inserted in the wrong place. FE10 was added after a drag from the content padding beside the column selected nothing at all: `mousedown` outside a block opened no gesture, so the two handlers that grow a selection had none to grow and the model's collapsed caret was what survived the gesture. RI5 was added after Shift+Enter shipped storing a `\n` that nothing rendered. RI6 was added after a host-defined container proved unrenderable on every surface despite a working data model. RI7 was added after `textColor` and `backgroundColor` fell through to the unknown-mark span and dropped the stored colour, so on-screen paint and host restyling had nothing to select; `highlight` was folded into the same path in the same change rather than left on its older direct-inline-style taste, which would have kept one of the three colour marks unremappable without `!important`. RI8 was added after a composer with a configured signature showed no empty-document hint at all: the signature is a second root block, the eligibility test counted root blocks, and the product had been covering for it by handing the same string to every paragraph as a block placeholder — which only paints on focus, so the hint looked like it arrived when the user tabbed in. The geometry rules no longer depend on a storage sentinel: the `\u200B` empty-block sentinel that G1's original wording measured through is gone from storage, so offsets resolve through `offsetDomain.ts` alone and no measurement path tests for a sentinel.