docs/solutions/logic-errors/2026-04-02-markdown-container-keyboard-rules-must-lift-one-level.md
Once blockquote became a real container, the old keyboard primitives stopped matching the model.
exit inserts a sibling after a container. reset rewrites the current block in place. Markdown-first quotes needed a different behavior: remove exactly one container level from the current block and leave surrounding structure intact.
Enter in a quoted paragraph did not express "leave this quote level" cleanly.Backspace at the start of a quoted paragraph risked resetting the block instead of peeling off one quote layer.Backspace at the start of an empty quoted paragraph with a quoted sibling above it could outdent the empty block instead of just removing it inside the quote.Tab on quoted paragraphs could get swallowed by paragraph indent logic before quote lift had a chance to run.exit for quote exit. That inserts after the quote; it does not lift the current block out of the quote.reset for quote delete-at-start. That changes block type, but it does not preserve container splitting semantics.delete.start as a lift. That overreached on empty non-first quoted paragraphs where default same-container delete or merge should win.Tab even when there was no paragraph indent to remove. That blocked quote lift.Tab like a no-op for quoted paragraphs. That let focus escape to other UI instead of keeping Tab editor-owned.Add an explicit structural primitive and wire quote rules to it:
editor.tf.liftBlock(...)'lift' to rules.break.empty and rules.delete.startBlockquotePlugin claim lift behavior only for plain quoted paragraphsdelete.start for non-empty quoted paragraphs or the first empty quoted paragraph in the quoteTab editor-owned through indent behaviorTab editor-owned through the same indent behaviorTab on a quoted plain paragraph lift one quote levelTab remove paragraph indent before lifting the quoteThe key transform is narrow on purpose:
export const liftBlock = (editor, { at, match } = {}) => {
const block = editor.api.block({ at });
if (!block || !match) return;
const [, blockPath] = block;
const ancestor = editor.api.above({
at: blockPath,
match: combineMatchOptions(
editor,
(_node, path) => path.length < blockPath.length,
{ match }
),
});
if (!ancestor) return;
editor.tf.unwrapNodes({
at: blockPath,
match,
split: true,
});
return true;
};
And the blockquote rule seam becomes:
rules: {
break: { empty: 'lift' },
delete: { start: 'lift' },
}
Markdown containers should behave like lists: one keypress changes one structural depth.
unwrapNodes(..., { split: true }) gives exactly that. The current block leaves the nearest matching ancestor, nested containers only lose one level, and quoted siblings stay wrapped instead of exploding into flat content.
But destructive keys still need one more law: empty blocks should die in place before structure peels away. A second empty paragraph inside the same quote is not a quote-exit gesture. It is just dead air.
reset or generic sibling insertion.Enter, Backspace@start, and reverse Tab as structural ownership questions, not isolated key handlers.These checks passed:
bun test packages/core/src/lib/plugins/slate-extension/transforms/liftBlock.spec.tsx packages/core/src/lib/plugins/slate-extension/SlateExtensionPlugin.spec.tsx packages/indent/src/lib/withIndent.spec.tsx packages/core/src/lib/plugins/override/withBreakRules.spec.tsx packages/core/src/lib/plugins/override/withDeleteRules.spec.tsx packages/basic-nodes/src/lib/BaseBlockquotePlugin.spec.ts packages/list/src/lib/withList.spec.tsx packages/code-block/src/lib/withCodeBlock.spec.tsx packages/table/src/lib/withTable.spec.tsx
bun test apps/www/src/registry/components/editor/transforms.spec.ts apps/www/src/__tests__/package-integration/autoformat/blockquote.slow.tsx apps/www/src/__tests__/package-integration/autoformat/list.slow.tsx packages/markdown/src/lib/deserializer/deserializeMd.spec.ts packages/markdown/src/lib/deserializer/deserializeMdList.spec.tsx packages/markdown/src/lib/serializer/convertNodesSerialize.spec.ts
pnpm build
pnpm lint:fix
Browser checks on /blocks/editor-ai also confirmed:
Enter exits a top-level quoteEnter inside a nested quote exits one quote level, not all of themTab stays in the editor and adds paragraph indentTab stays in the editor, keeps quote depth, and adds paragraph indent#4898