Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/wild-pots-smile.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions packages/core/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
- filterPendingBlocksForDocumentProfile
- foldAndNormalize
- getApplyOptionsGroupId
- getBlockContentRole
- getBlockSelectionRoleFromSchema
- getBlockSelectionRoleFromType
- getCellCaretFocus
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/__tests__/blockCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { BlockSchema, ContentType, PropSchema } from "@input/pen-types";
import {
getBlockContentRole,
getBlockSelectionRoleFromSchema,
getBlockSelectionRoleFromType,
getFlowCapabilityFromSchema,
Expand Down Expand Up @@ -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", () => {
Expand Down
32 changes: 27 additions & 5 deletions packages/core/src/editor/profilePolicy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
BlockAuthoring,
BlockContentRole,
BlockSelectionRole,
DocumentOp,
DocumentProfile,
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
filterOpsForDocumentProfile,
filterPendingBlocksForDocumentProfile,
createImportResult,
getBlockContentRole,
getBlockSelectionRoleFromSchema,
getBlockSelectionRoleFromType,
getFlowCapabilityFromSchema,
Expand Down Expand Up @@ -94,6 +95,7 @@ export {
createImportResult,
filterOpsForDocumentProfile,
filterPendingBlocksForDocumentProfile,
getBlockContentRole,
getBlockSelectionRoleFromSchema,
getBlockSelectionRoleFromType,
getFlowCapabilityFromSchema,
Expand Down
2 changes: 1 addition & 1 deletion packages/rendering/dom/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ _no exports_
### function

- computeDocumentEmpty
- computeDocumentPlaceholderVisible
- getDocumentPlaceholderTargetBlockId
- isInlineContentEmpty

## ./utils/environment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {

const baseOptions = {
blockTextEmpty: true,
isDocumentEmpty: false,
isFirstBlock: false,
isDocumentPlaceholderTarget: false,
isFocusedBlock: true,
hasEmptyPlaceholder: true,
hasExplicitPlaceholder: false,
Expand All @@ -20,8 +19,7 @@ describe("resolveInlinePlaceholderVisibility", () => {
expect(
resolveInlinePlaceholderVisibility({
...baseOptions,
isDocumentEmpty: true,
isFirstBlock: true,
isDocumentPlaceholderTarget: true,
hasExplicitPlaceholder: true,
suppressPlaceholders: true,
}),
Expand All @@ -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({
Expand Down
137 changes: 137 additions & 0 deletions packages/rendering/dom/src/__tests__/ri8DocumentPlaceholder.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 0 additions & 3 deletions packages/rendering/dom/src/field-editor/contentGestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export interface AttachContentGesturesOptions<
regionSelectionStore: RegionSelectionStore;
state: ContentGestureState<InteractionModel>;
blockSelectionEnabled: boolean;
isDocumentPlaceholderVisible: boolean;
runSync?: ((run: () => void) => void) | undefined;
}

Expand All @@ -61,7 +60,6 @@ export function attachContentGestures<
regionSelectionStore,
state,
blockSelectionEnabled,
isDocumentPlaceholderVisible,
} = options;
const runSync = options.runSync ?? ((run: () => void) => run());
const {
Expand Down Expand Up @@ -91,7 +89,6 @@ export function attachContentGestures<
interactionModelRef,
clearPointerSelectionState,
blockSelectionEnabled,
isDocumentPlaceholderVisible,
runSync,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,7 +47,6 @@ export function createPointerSelectionGestures<
interactionModelRef,
clearPointerSelectionState,
blockSelectionEnabled,
isDocumentPlaceholderVisible,
} = ctx;

const handleClickOutsideBlocks = (event: MouseEvent): boolean => {
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export interface ContentGesturesContext<
interactionModelRef: GestureSlot<InteractionModel>;
clearPointerSelectionState(): void;
blockSelectionEnabled: boolean;
isDocumentPlaceholderVisible: boolean;
runSync: (run: () => void) => void;
}

Expand Down
Loading
Loading