From 1fcc423913f77a9216b35cc16be6f4f628c32df8 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 29 May 2026 12:52:49 +0200 Subject: [PATCH 1/2] Add Cmd+Shift+Enter to insert a node before the current one Mirrors Enter (which inserts a new default node after the current node) but places the new node before it. Only triggers on text selections, where "the current node" is well defined. Co-Authored-By: Claude Opus 4.7 --- src/lib/Command.svelte.js | 23 ++++++++++++++++++- src/lib/transforms.svelte.js | 37 +++++++++++++++++++++++++++++++ src/routes/create_demo_session.js | 3 +++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/lib/Command.svelte.js b/src/lib/Command.svelte.js index e90d05a6..da613058 100644 --- a/src/lib/Command.svelte.js +++ b/src/lib/Command.svelte.js @@ -1,4 +1,8 @@ -import { insert_default_node, break_text_node } from './transforms.svelte.js'; +import { + insert_default_node, + insert_default_node_before, + break_text_node +} 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 +331,20 @@ export class InsertDefaultNodeCommand extends Command { this.context.session.apply(tr); } } + +/** + * Command that inserts a default node before the current node. + * Only works in text selections, where "the current node" is well defined. + */ +export class InsertDefaultNodeBeforeCommand extends Command { + is_enabled() { + return this.context.editable && this.context.session.selection?.type === 'text'; + } + + execute() { + const tr = this.context.session.tr; + if (insert_default_node_before(tr)) { + this.context.session.apply(tr); + } + } +} diff --git a/src/lib/transforms.svelte.js b/src/lib/transforms.svelte.js index 09822729..70082248 100644 --- a/src/lib/transforms.svelte.js +++ b/src/lib/transforms.svelte.js @@ -163,3 +163,40 @@ export function insert_default_node(tr) { throw new Error(`No inserter function available for default node type '${default_type}'`); } } + +export function insert_default_node_before(tr) { + const selection = tr.selection; + + // Only work with text selections (where "the current node" is well defined) + if (selection?.type !== 'text') return false; + + // Path to the containing node (e.g. paragraph) + const node_path = selection.path.slice(0, -1); + + // Ensure the containing node lives inside a node_array + const parent_path = node_path.slice(0, -1); + if (tr.inspect(parent_path)?.type !== 'node_array') return false; + + const node_index = parseInt(node_path.at(-1), 10); + const node_array_node = tr.get(parent_path.slice(0, -1)); + const property_name = parent_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}'`); + } + + // Position a node caret at the current node's index so the inserter places + // the new node before it (existing node shifts to index + 1). + tr.set_selection({ + type: 'node', + path: parent_path, + anchor_offset: node_index, + focus_offset: node_index + }); + + 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..d5c40c3f 100644 --- a/src/routes/create_demo_session.js +++ b/src/routes/create_demo_session.js @@ -2,6 +2,7 @@ import { Session, SelectAllCommand, InsertDefaultNodeCommand, + InsertDefaultNodeBeforeCommand, AddNewLineCommand, BreakTextNodeCommand, ToggleAnnotationCommand, @@ -751,6 +752,7 @@ export const session_config = { const commands = { select_all: new SelectAllCommand(context), insert_default_node: new InsertDefaultNodeCommand(context), + insert_default_node_before: new InsertDefaultNodeBeforeCommand(context), add_new_line: new AddNewLineCommand(context), break_text_node: new BreakTextNodeCommand(context), toggle_strong: new ToggleAnnotationCommand('strong', 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+shift+enter,ctrl+shift+enter': [commands.insert_default_node_before], 'meta+b,ctrl+b': [commands.toggle_strong], 'meta+i,ctrl+i': [commands.toggle_emphasis], 'meta+u,ctrl+u': [commands.toggle_highlight], From 96d3b0d08782d845bfa4bb71afccd76a69b7d6e7 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 29 May 2026 13:19:15 +0200 Subject: [PATCH 2/2] Also handle node-caret selections in insert-before In a node gap, "the current node" is the node above the caret. Insert at offset - 1 (before that node). No-op when the caret is at offset 0 since there is no node above to insert before. Co-Authored-By: Claude Opus 4.7 --- src/lib/Command.svelte.js | 8 ++++++- src/lib/transforms.svelte.js | 41 ++++++++++++++++++++---------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/lib/Command.svelte.js b/src/lib/Command.svelte.js index da613058..9a1b62bd 100644 --- a/src/lib/Command.svelte.js +++ b/src/lib/Command.svelte.js @@ -338,7 +338,13 @@ export class InsertDefaultNodeCommand extends Command { */ export class InsertDefaultNodeBeforeCommand extends Command { is_enabled() { - return this.context.editable && this.context.session.selection?.type === 'text'; + const selection = this.context.session.selection; + if (!this.context.editable) return false; + if (selection?.type === 'text') return true; + if (selection?.type === 'node' && selection.anchor_offset === selection.focus_offset) { + return selection.anchor_offset > 0; + } + return false; } execute() { diff --git a/src/lib/transforms.svelte.js b/src/lib/transforms.svelte.js index 70082248..9b557349 100644 --- a/src/lib/transforms.svelte.js +++ b/src/lib/transforms.svelte.js @@ -167,20 +167,27 @@ export function insert_default_node(tr) { export function insert_default_node_before(tr) { const selection = tr.selection; - // Only work with text selections (where "the current node" is well defined) - if (selection?.type !== 'text') return false; - - // Path to the containing node (e.g. paragraph) - const node_path = selection.path.slice(0, -1); - - // Ensure the containing node lives inside a node_array - const parent_path = node_path.slice(0, -1); - if (tr.inspect(parent_path)?.type !== 'node_array') return false; - - const node_index = parseInt(node_path.at(-1), 10); - const node_array_node = tr.get(parent_path.slice(0, -1)); - const property_name = parent_path.at(-1); + let node_array_path; + let insert_offset; + + if (selection?.type === 'text') { + // "Current node" is the node containing the text. Insert at its index. + const node_path = selection.path.slice(0, -1); + node_array_path = node_path.slice(0, -1); + if (tr.inspect(node_array_path)?.type !== 'node_array') return false; + insert_offset = parseInt(node_path.at(-1), 10); + } else if (selection?.type === 'node' && selection.anchor_offset === selection.focus_offset) { + // In a node gap, "current node" is the node above the caret (offset - 1). + // Inserting before it means inserting at offset - 1. + if (selection.anchor_offset === 0) return false; + node_array_path = selection.path; + insert_offset = selection.anchor_offset - 1; + } else { + return false; + } + const node_array_node = tr.get(node_array_path.slice(0, -1)); + const property_name = node_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); @@ -188,13 +195,11 @@ export function insert_default_node_before(tr) { throw new Error(`No inserter function available for default node type '${default_type}'`); } - // Position a node caret at the current node's index so the inserter places - // the new node before it (existing node shifts to index + 1). tr.set_selection({ type: 'node', - path: parent_path, - anchor_offset: node_index, - focus_offset: node_index + path: node_array_path, + anchor_offset: insert_offset, + focus_offset: insert_offset }); tr.config.inserters[default_type](tr);