Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/insert-block-sibling-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@portabletext/editor': patch
---

fix: validate `insert.block` sibling placements against the parent array's schema

`insert.block` with `placement: 'before'` or `'after'` at a point inside a container validated the block against the destination's own sub-schema view while landing it as a sibling in the parent array. When the two scopes differed, a text block addressed at a table row, for example, the block passed validation and landed as schema-invalid data inside an array that rejects it. Such inserts now no-op, the same outcome as other schema-rejected inserts.
68 changes: 64 additions & 4 deletions packages/editor/src/operations/operation.insert.block.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isSpan, isTextBlock} from '@portabletext/schema'
import {getSubSchema, isSpan, isTextBlock} from '@portabletext/schema'
import {end as editorEnd} from '../engine/editor/end'
import {pathRef} from '../engine/editor/path-ref'
import {rangeRef} from '../engine/editor/range-ref'
Expand All @@ -23,6 +23,7 @@ import {deleteRange} from '../internal-utils/delete-range'
import {isEqualChildren, isEqualMarks} from '../internal-utils/equality'
import {setNodeProperties} from '../internal-utils/set-node-properties'
import {toEngineBlock} from '../internal-utils/values'
import {getEnclosingContainer} from '../schema/get-enclosing-container'
import {getEnclosingBlock} from '../traversal/get-enclosing-block'
import {getParent} from '../traversal/get-parent'
import {getPathSubSchema} from '../traversal/get-path-sub-schema'
Expand All @@ -39,6 +40,51 @@ import type {
OperationSnapshot,
} from './operation.types'

/**
* The validation scope for `placement: 'before' | 'after'`. Those land the
* block as a sibling of the enclosing block at the destination (see
* `resolveTarget`), so the scope is that block's parent array, not the
* destination path's own sub-schema view. The two only differ when the
* destination points at a container member: a point at a table row
* resolves toward the cell content view, while a sibling insertion lands
* in `rows`.
*
* Acceptance is an explicit membership check against the parent array's
* `of`: a sub-schema alone cannot reject a text block, since `Schema`
* structurally always carries a text-block type. Returns `undefined` for
* other placements and for root-level destinations, where the root schema
* accepts everything it declares.
*/
function siblingPlacementScope(
snapshot: OperationSnapshot,
destinationPath: Path,
placement: 'auto' | 'before' | 'after',
) {
if (placement !== 'before' && placement !== 'after') {
return undefined
}
const enclosingBlock = getEnclosingBlock(snapshot, destinationPath)
if (!enclosingBlock) {
return undefined
}
const enclosingContainer = getEnclosingContainer(
snapshot,
enclosingBlock.path,
)
if (!enclosingContainer) {
return undefined
}
return {
schema: getSubSchema(snapshot.context.schema, enclosingContainer.of),
accepts: (blockType: string) =>
enclosingContainer.of.some((member) =>
member.type === 'block'
? blockType === snapshot.context.schema.block.name
: ((member as {name?: string}).name ?? member.type) === blockType,
),
}
}

/**
* Describes a concrete insertion strategy resolved from the operation's
* inputs (editor value, current selection, block being inserted, placement).
Expand Down Expand Up @@ -92,9 +138,23 @@ export const insertBlockOperationImplementation: OperationImplementation<
: editor.snapshot.context.value.length > 0
? editorEnd(editor, []).path
: undefined
const schema = destinationPath
? getPathSubSchema(snapshot, destinationPath)
: context.schema
const siblingScope = destinationPath
? siblingPlacementScope(snapshot, destinationPath, operation.placement)
: undefined
const insertedType = operation.block._type
if (
siblingScope &&
(typeof insertedType !== 'string' || !siblingScope.accepts(insertedType))
) {
// Sibling placements land beside the enclosing block at the
// destination; a type its parent array rejects must not land there.
return
}
const schema =
siblingScope?.schema ??
(destinationPath
? getPathSubSchema(snapshot, destinationPath)
: context.schema)

const parsedBlock = parseBlock({
block: operation.block,
Expand Down
201 changes: 201 additions & 0 deletions packages/editor/tests/insert-block-placement-scope.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {defineSchema} from '@portabletext/schema'
import {createTestKeyGenerator} from '@portabletext/test'
import {describe, expect, test, vi} from 'vitest'
import {NodePlugin} from '../src/plugins/plugin.node'
import {defineContainer} from '../src/renderers/renderer.types'
import {createTestEditor} from '../src/test/vitest'

/**
* `insert.block` with `placement: 'before' | 'after'` lands the block as a
* sibling of the enclosing block at the destination, so its validation
* scope is that block's parent array. Deriving the scope from the
* destination path's own sub-schema view let a text block pass validation
* against a table cell's content view and land inside `rows`.
*/

const schemaDefinition = defineSchema({
blockObjects: [
{
name: 'grid',
fields: [
{
name: 'rows',
type: 'array',
of: [
{
type: 'object',
name: 'gridRow',
fields: [
{
name: 'cells',
type: 'array',
of: [
{
type: 'object',
name: 'gridCell',
fields: [
{name: 'value', type: 'array', of: [{type: 'block'}]},
],
},
],
},
],
},
],
},
],
},
],
})

const gridCell = (key: string) => ({
_type: 'gridCell',
_key: key,
value: [
{
_type: 'block',
_key: `b-${key}`,
style: 'normal',
markDefs: [],
children: [{_type: 'span', _key: `s-${key}`, text: 'x', marks: []}],
},
],
})

const gridValue = [
{
_type: 'grid',
_key: 'g0',
rows: [{_type: 'gridRow', _key: 'r0', cells: [gridCell('c00')]}],
},
]

const rowPoint = {
path: [{_key: 'g0'}, 'rows', {_key: 'r0'}],
offset: 0,
}

const spanPoint = (offset: number) => ({
path: [
{_key: 'g0'},
'rows',
{_key: 'r0'},
'cells',
{_key: 'c00'},
'value',
{_key: 'b-c00'},
'children',
{_key: 's-c00'},
],
offset,
})

async function createGridEditor() {
const {editor, locator} = await createTestEditor({
keyGenerator: createTestKeyGenerator(),
schemaDefinition,
initialValue: gridValue,
children: (
<NodePlugin
nodes={[
defineContainer({
type: 'grid',
arrayField: 'rows',
of: [
defineContainer({
type: 'gridRow',
arrayField: 'cells',
of: [defineContainer({type: 'gridCell', arrayField: 'value'})],
}),
],
}),
]}
/>
),
})
await vi.waitFor(() => expect.element(locator).toBeInTheDocument())
editor.send({type: 'focus'})
return editor
}

describe('insert.block sibling-placement validation scope', () => {
test('a text block beside a row no-ops: `rows` does not accept it', async () => {
const editor = await createGridEditor()

editor.send({
type: 'insert.block',
block: {_type: 'block'},
placement: 'before',
at: {anchor: rowPoint, focus: rowPoint},
})
editor.send({
type: 'insert.block',
block: {_type: 'block'},
placement: 'after',
at: {anchor: rowPoint, focus: rowPoint},
})

await vi.waitFor(() => {
expect(editor.getSnapshot().context.value).toEqual(gridValue)
})
})

test('a row beside a row inserts: `rows` accepts it', async () => {
const editor = await createGridEditor()

editor.send({
type: 'insert.block',
block: {_type: 'gridRow', cells: [{_type: 'gridCell', value: []}]},
placement: 'after',
at: {anchor: rowPoint, focus: rowPoint},
})

await vi.waitFor(() => {
const grid = editor.getSnapshot().context.value?.[0] as unknown as {
rows: Array<{_type: string}>
}
expect(grid.rows.map((row) => row._type)).toEqual(['gridRow', 'gridRow'])
})
})

test('a text block beside a cell block inserts: the cell accepts it', async () => {
const editor = await createGridEditor()

editor.send({
type: 'insert.block',
block: {_type: 'block'},
placement: 'after',
at: {anchor: spanPoint(1), focus: spanPoint(1)},
})

await vi.waitFor(() => {
const grid = editor.getSnapshot().context.value?.[0] as unknown as {
rows: Array<{cells: Array<{value: Array<{_type: string}>}>}>
}
expect(grid.rows[0]?.cells[0]?.value.map((block) => block._type)).toEqual(
['block', 'block'],
)
})
expect(editor.getSnapshot().context.value).toHaveLength(1)
})

test('a text block beside the grid inserts at root', async () => {
const editor = await createGridEditor()

editor.send({
type: 'insert.block',
block: {_type: 'block'},
placement: 'before',
at: {
anchor: {path: [{_key: 'g0'}], offset: 0},
focus: {path: [{_key: 'g0'}], offset: 0},
},
})

await vi.waitFor(() => {
expect(
editor.getSnapshot().context.value?.map((block) => block._type),
).toEqual(['block', 'grid'])
})
})
})
Loading