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
34 changes: 34 additions & 0 deletions .changeset/locked-blocks-are-actually-locked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/eslint-plugin": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
"@nextlyhq/module-specifiers": patch
---

A locked block in the page builder now resists being moved or deleted, and says why. Until now the
flag was honoured only when dragging, so a block the layers panel showed as locked could still be
moved with the keyboard or deleted outright. Deleting a container that holds a locked block is
refused too, since removing the container would destroy the block inside it, and the refusal names
which block is locked. Moving a container is still allowed, because its locked children keep their
place inside it.
9 changes: 8 additions & 1 deletion packages/builder/src/canvas-drag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import type { EditorState } from "./editor-state";
import type { Point, Rect } from "./geometry";
import { canvasContentPoint, canvasContentRect } from "./geometry-dom";
import type { SlotSource } from "./inserter";
import { lockBlockingMove } from "./locking";
import {
nextTargetSwitchState,
NO_TARGET,
Expand Down Expand Up @@ -220,7 +221,13 @@ export function useCanvasDrag({

// A locked block is one the author has asked the editor not to move. The
// press is left alone rather than swallowed, so it still selects.
if (node.locked === true) return;
//
// Asked through the shared rule rather than by reading the flag here: the
// keyboard moves and delete ask the same question, and delete's answer
// differs — it checks the whole subtree, because removing a container
// destroys what is inside it. Three inline reads of `node.locked` would
// agree until one of them gained that distinction.
if (lockBlockingMove(current.document, nodeId) !== undefined) return;

const rects = snapshotRects(root);
gesture.current = {
Expand Down
11 changes: 11 additions & 0 deletions packages/builder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,14 @@ export {
type LayerSearch,
} from "./layers";
export { blockLabel } from "./inserter";

/**
* @experimental Whether the editor may move or delete a block the author has
* locked.
*
* From this entry because it is plain functions over a document. A host or an
* agent deciding whether an action is permitted has to ask the SAME question
* the editor asks — a second reading of `node.locked` would miss that deleting
* a container is refused by a lock anywhere inside it, while moving one is not.
*/
export { isLocked, lockBlockingDelete, lockBlockingMove } from "./locking";
146 changes: 146 additions & 0 deletions packages/builder/src/keyboard-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,66 @@ describe("useBlockKeyboardActions", () => {
expect(op.to).toEqual({ index: 1 });
});

it("refuses to move a locked block, and says why", () => {
const editor = editorSpy(
documentOf([
{ id: "a", type: "acme/text", version: 1, props: {}, locked: true },
{ id: "b", type: "acme/text", version: 1, props: {} },
] as BlockDocument["nodes"]),
"a"
);
mount(editor);

press("ArrowDown", { altKey: true });

expect(editor.apply).not.toHaveBeenCalled();
expect(screen.getByRole("status").textContent ?? "").toMatch(
/is locked\. unlock it to move it/i
);
});

it("ALLOWS moving a container that holds a locked block", () => {
/*
* The direction that is easy to get wrong by being cautious, and the reason
* move and delete ask different questions.
*
* Moving the container leaves the locked child in the same slot at the same
* index with the same neighbours, so nothing the lock protects has changed.
* Refusing here would let one locked caption freeze the entire section
* around it, which an author would experience as the editor breaking rather
* than as a lock working.
*/
const editor = editorSpy(
documentOf([
{
id: "wrap",
type: "acme/box",
version: 1,
props: {},
slots: {
children: [
{
id: "kid",
type: "acme/text",
version: 1,
props: {},
locked: true,
},
],
},
},
{ id: "after", type: "acme/text", version: 1, props: {} },
] as BlockDocument["nodes"]),
"wrap"
);
mount(editor);

press("ArrowDown", { altKey: true });

expect(editor.apply).toHaveBeenCalledTimes(1);
expect(editor.apply.mock.calls[0][0].kind).toBe("move");
});

it("moves the selected block up", () => {
const editor = editorSpy(pair(), "b");
mount(editor);
Expand Down Expand Up @@ -393,6 +453,92 @@ describe("useBlockKeyboardActions", () => {
expect(said).toContain("Undo");
});

it("refuses to delete a locked block, and says why", () => {
/*
* A lock is a POLICY refusal, so it is announced where a structural one is
* not. Nothing on the page explains why the key did nothing, the remedy is
* one the author can act on, and a keyboard user has no lock badge to look
* at — which is the whole reason silence would be wrong here.
*/
const editor = editorSpy(
documentOf([
{ id: "a", type: "acme/text", version: 1, props: {}, locked: true },
{ id: "b", type: "acme/text", version: 1, props: {} },
] as BlockDocument["nodes"]),
"a"
);
mount(editor);

press("Delete");

expect(editor.apply).not.toHaveBeenCalled();
expect(screen.getByRole("status").textContent ?? "").toMatch(
/is locked\. unlock it to delete it/i
);
});

it("refuses to delete a container holding a locked block, and names it", () => {
// THE case. Deleting the container destroys the locked child, which is the
// one outcome the flag exists to prevent — and it happens through an action
// aimed at something else, with the descendant count as the only clue.
const editor = editorSpy(
documentOf([
{
id: "wrap",
type: "acme/box",
version: 1,
props: {},
slots: {
children: [
{
id: "kid",
type: "acme/text",
version: 1,
props: {},
locked: true,
},
],
},
},
] as BlockDocument["nodes"]),
"wrap"
);
mount(editor);

press("Delete");

expect(editor.apply).not.toHaveBeenCalled();
expect(screen.getByRole("status").textContent ?? "").toMatch(
/contains .*which is locked/i
);
});

it("still deletes a container whose children are all unlocked", () => {
// The control for both refusals above. Without it, a delete that refused
// everything would satisfy them and nothing would notice.
const editor = editorSpy(
documentOf([
{
id: "wrap",
type: "acme/box",
version: 1,
props: {},
slots: {
children: [{ id: "kid", type: "acme/text", version: 1, props: {} }],
},
},
] as BlockDocument["nodes"]),
"wrap"
);
mount(editor);

press("Delete");

expect(editor.apply).toHaveBeenCalledWith(
expect.objectContaining({ kind: "remove", id: "wrap" })
);
});

it("says how many blocks a container takes with it", () => {
// A collapsed section looks exactly like an empty one, so the count is the
// only thing telling an author what they just lost.
Expand Down
38 changes: 38 additions & 0 deletions packages/builder/src/keyboard-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type MoveDirection,
type MoveEffect,
} from "./keyboard-move";
import { lockBlockingDelete, lockBlockingMove } from "./locking";

/**
* The bindings, and why these keys.
Expand Down Expand Up @@ -183,6 +184,31 @@ export function useBlockKeyboardActions({
// said, because there is no event to report.
if (deletion === null) return;

/*
* A lock is a POLICY refusal, so it is announced where a structural one is
* not.
*
* The moves below stay silent when a block simply has nowhere to go — that
* is a fact about the document an author can see, and saying it on every
* press at the end of a list is noise. A lock is the opposite: nothing about
* the page explains why the key did nothing, the remedy is one the author
* can act on, and a keyboard user has no badge to look at.
*
* The subtree is checked, not just the node. Deleting a container destroys
* what is inside it, so an author who locked a caption and then removed its
* section would lose the thing they locked through an action aimed at
* something else.
*/
const blocked = lockBlockingDelete(editorNow.document, deletion.id);
if (blocked !== undefined) {
announce(
blocked.id === deletion.id
? `${blockLabel(blocked.type)} is locked. Unlock it to delete it.`
: `This block contains ${blockLabel(blocked.type)}, which is locked. Unlock it to delete.`
);
return;
}

const applied = editorNow.apply({
kind: "remove",
id: deletion.id,
Expand Down Expand Up @@ -217,6 +243,18 @@ export function useBlockKeyboardActions({
const selectedId = editorNow.selectedId;
if (selectedId === null) return;

// Only the node itself, never its subtree: moving a container leaves
// a locked child in the same slot at the same index, so the lock is
// not violated and refusing would let one locked caption freeze the
// whole section around it.
const lockedNode = lockBlockingMove(editorNow.document, selectedId);
if (lockedNode !== undefined) {
announce(
`${blockLabel(lockedNode.type)} is locked. Unlock it to move it.`
);
return;
}

const move = keyboardMovePosition(
editorNow.document.nodes,
selectedId,
Expand Down
Loading
Loading