From 7e6710072542a0a016a1d63ef682af4b5211a20a Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 29 May 2026 13:32:30 +0200 Subject: [PATCH] Add Cmd+Enter exit-break to escape nested content Walks the selection path to the outermost node_array and inserts a new default node after the outer block there. Lets the user escape nested content like list items or block-level annotated_text properties (hero title, story description, etc.) and continue at the document's top level. Closes #18 Co-Authored-By: Claude Opus 4.7 --- src/lib/Command.svelte.js | 20 ++++++++++++- src/lib/transforms.svelte.js | 49 +++++++++++++++++++++++++++++++ src/routes/create_demo_session.js | 3 ++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/lib/Command.svelte.js b/src/lib/Command.svelte.js index e90d05a6..6787cb0a 100644 --- a/src/lib/Command.svelte.js +++ b/src/lib/Command.svelte.js @@ -1,4 +1,4 @@ -import { insert_default_node, break_text_node } from './transforms.svelte.js'; +import { insert_default_node, break_text_node, exit_break } from './transforms.svelte.js'; import { can_switch_annotation_type } from './doc_utils.js'; import { is_selection_collapsed, is_mobile_browser, get_char_length } from './utils.js'; @@ -327,3 +327,21 @@ export class InsertDefaultNodeCommand extends Command { this.context.session.apply(tr); } } + +/** + * Exit-break command: escapes the current node property or nested node_array + * and inserts a new default node after the outermost ancestor in the + * document's top-level node_array. + */ +export class ExitBreakCommand extends Command { + is_enabled() { + return this.context.editable && !!this.context.session.selection; + } + + execute() { + const tr = this.context.session.tr; + if (exit_break(tr)) { + this.context.session.apply(tr); + } + } +} diff --git a/src/lib/transforms.svelte.js b/src/lib/transforms.svelte.js index 09822729..0394e222 100644 --- a/src/lib/transforms.svelte.js +++ b/src/lib/transforms.svelte.js @@ -163,3 +163,52 @@ export function insert_default_node(tr) { throw new Error(`No inserter function available for default node type '${default_type}'`); } } + +/** + * Exit-break: leaves the current node property or nested node_array and inserts + * a new default node after the outermost ancestor that's inside the document's + * top-level node_array. Useful for escaping nested content like list items. + */ +export function exit_break(tr) { + const selection = tr.selection; + if (!selection?.path || selection.path.length < 3) return false; + + const path = selection.path; + + // Walk the path from the root and find the OUTERMOST node_array. + // The path alternates between property names and array indices, so each + // even-length prefix ends on a property name we can inspect. + let outer_array_path = null; + let outer_node_index = null; + + for (let i = 1; i < path.length - 1; i++) { + const candidate = path.slice(0, i + 1); + if (tr.inspect(candidate)?.type === 'node_array') { + outer_array_path = candidate; + outer_node_index = parseInt(path[i + 1], 10); + break; + } + } + + if (!outer_array_path || Number.isNaN(outer_node_index)) return false; + + const insert_offset = outer_node_index + 1; + const node_array_node = tr.get(outer_array_path.slice(0, -1)); + const property_name = outer_array_path.at(-1); + const property_definition = tr.schema[node_array_node.type].properties[property_name]; + const default_type = get_default_node_type(property_definition); + + if (!tr.config?.inserters?.[default_type]) { + throw new Error(`No inserter function available for default node type '${default_type}'`); + } + + tr.set_selection({ + type: 'node', + path: outer_array_path, + anchor_offset: insert_offset, + focus_offset: insert_offset + }); + + tr.config.inserters[default_type](tr); + return true; +} diff --git a/src/routes/create_demo_session.js b/src/routes/create_demo_session.js index 3faa50eb..c650c91f 100644 --- a/src/routes/create_demo_session.js +++ b/src/routes/create_demo_session.js @@ -4,6 +4,7 @@ import { InsertDefaultNodeCommand, AddNewLineCommand, BreakTextNodeCommand, + ExitBreakCommand, ToggleAnnotationCommand, UndoCommand, RedoCommand, @@ -753,6 +754,7 @@ export const session_config = { insert_default_node: new InsertDefaultNodeCommand(context), add_new_line: new AddNewLineCommand(context), break_text_node: new BreakTextNodeCommand(context), + exit_break: new ExitBreakCommand(context), toggle_strong: new ToggleAnnotationCommand('strong', context), toggle_emphasis: new ToggleAnnotationCommand('emphasis', context), toggle_highlight: new ToggleAnnotationCommand('highlight', context), @@ -773,6 +775,7 @@ export const session_config = { // In case of a node caret, fall back to inserting a default node. This is needed // because on iOS selecting a node caret triggers auto capitalization (shift pressed) 'shift+enter': [commands.add_new_line, commands.insert_default_node], + 'meta+enter,ctrl+enter': [commands.exit_break], 'meta+b,ctrl+b': [commands.toggle_strong], 'meta+i,ctrl+i': [commands.toggle_emphasis], 'meta+u,ctrl+u': [commands.toggle_highlight],