Skip to content

Commit ad18260

Browse files
committed
feat(renderer): in-cell arrow keybinds for caret nav and row/col moves
Reorganize the in-table Arrow-key bindings to free up Alt+Shift+Arrow for a new move-row/move-column action: - Alt+Ctrl+Arrow: move caret to the adjacent cell (new) - Alt+Shift+Arrow: move the focused row/column (new) - Alt+Ctrl+Shift+Arrow: insert row/column (was Alt+Shift+Arrow) `moveCaretInFocusedTableCell` now also handles 'left' and 'right'. New `moveInFocusedTableCell` + `doMoveOp` helper mirror the existing insert path, including pendingToolbar bookkeeping so the pinned toolbar follows the moved cell across the re-render. Right-click menu entries for the move ops now show the shortcut as a tooltip, and matching command- palette entries are registered for both caret-nav (left/right) and the four move ops so they're rebindable in Logseq's keymap UI.
1 parent eadfe17 commit ad18260

2 files changed

Lines changed: 144 additions & 21 deletions

File tree

src/index.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import '@logseq/libs'
33
import parseMarkdownTable from './utils/parseRawInputByMarkdownIt'
44
import { splitStrByTable } from './utils/splitStrByTable'
55
import { looksLikeMarkdownTable, markdownTableToMatrix } from './utils/detectMarkdownTable'
6-
import { attachInlineEditing, insertInFocusedTableCell, moveCaretInFocusedTableCell, prepareInlineRenderer, resumePinnedToolbar } from './utils/inlineEditable'
6+
import { attachInlineEditing, insertInFocusedTableCell, moveCaretInFocusedTableCell, moveInFocusedTableCell, prepareInlineRenderer, resumePinnedToolbar } from './utils/inlineEditable'
77
import i18n from './locales/i18n'
88
import './index.css'
99

@@ -141,8 +141,16 @@ if (isInBrowser) {
141141
key: 'mdtable-move-caret-up',
142142
label: i18n.t('Markdown table: move caret to cell above')
143143
}, () => moveCaretInFocusedTableCell('up'))
144+
logseq.App.registerCommandPalette({
145+
key: 'mdtable-move-caret-left',
146+
label: i18n.t('Markdown table: move caret to cell left')
147+
}, () => moveCaretInFocusedTableCell('left'))
148+
logseq.App.registerCommandPalette({
149+
key: 'mdtable-move-caret-right',
150+
label: i18n.t('Markdown table: move caret to cell right')
151+
}, () => moveCaretInFocusedTableCell('right'))
144152

145-
// Row/column insertion. Default Alt+Shift+Arrow keys are wired up
153+
// Row/column insertion. Default Alt+Ctrl+Shift+Arrow keys are wired up
146154
// by a local handler in `attachInlineEditing` (see Ctrl+Enter
147155
// comment for why the local path is needed); these palette entries
148156
// mirror those actions so they show up in Logseq's keymap UI and
@@ -164,6 +172,26 @@ if (isInBrowser) {
164172
label: i18n.t('Markdown table: insert column left')
165173
}, () => insertInFocusedTableCell('colLeft'))
166174

175+
// Row/column move. Default Alt+Shift+Arrow keys are wired up by a
176+
// local handler in `attachInlineEditing`; these palette entries
177+
// mirror them for the command palette / keymap UI.
178+
logseq.App.registerCommandPalette({
179+
key: 'mdtable-move-row-up',
180+
label: i18n.t('Markdown table: move row up')
181+
}, () => moveInFocusedTableCell('rowUp'))
182+
logseq.App.registerCommandPalette({
183+
key: 'mdtable-move-row-down',
184+
label: i18n.t('Markdown table: move row down')
185+
}, () => moveInFocusedTableCell('rowDown'))
186+
logseq.App.registerCommandPalette({
187+
key: 'mdtable-move-col-left',
188+
label: i18n.t('Markdown table: move column left')
189+
}, () => moveInFocusedTableCell('colLeft'))
190+
logseq.App.registerCommandPalette({
191+
key: 'mdtable-move-col-right',
192+
label: i18n.t('Markdown table: move column right')
193+
}, () => moveInFocusedTableCell('colRight'))
194+
167195
// Inline block renderer: replace Logseq's native view for markdown-table
168196
// blocks with an editable table. Host-mounted via the experimental
169197
// Experiments API; a clean no-op on older Logseq hosts.

src/utils/inlineEditable.js

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,11 @@ const caretToEnd = (el) => {
186186
sel.addRange(range)
187187
}
188188

189-
// Move the caret to the cell directly above/below the currently focused
190-
// markdown-table cell (same column). No-op if focus isn't in a cell, or
191-
// at the table edge. Invoked from Logseq command-shortcut callbacks so
192-
// the keybinding is editable in the host's keymap.
189+
// Move the caret to a neighbouring cell of the currently focused markdown-
190+
// table cell. `direction` ∈ 'up' | 'down' | 'left' | 'right'. No-op if
191+
// focus isn't in a cell, or at the table edge. Invoked from Logseq
192+
// command-shortcut callbacks so the keybinding is editable in the host's
193+
// keymap.
193194
export const moveCaretInFocusedTableCell = (direction) => {
194195
let hostDoc = null
195196
try { hostDoc = (window.top || window.parent)?.document } catch (_) { /* cross-origin */ }
@@ -200,9 +201,15 @@ export const moveCaretInFocusedTableCell = (direction) => {
200201
const table = cell.closest('table.lsp-mdt')
201202
const tr = cell.closest('tr')
202203
const rows = Array.from(table.querySelectorAll('tr'))
203-
const colIdx = Array.from(tr.querySelectorAll('th,td')).indexOf(cell)
204-
const target = rows[rows.indexOf(tr) + (direction === 'up' ? -1 : 1)]
205-
const next = target && target.querySelectorAll('th,td')[colIdx]
204+
const cells = Array.from(tr.querySelectorAll('th,td'))
205+
const colIdx = cells.indexOf(cell)
206+
let next = null
207+
if (direction === 'up' || direction === 'down') {
208+
const target = rows[rows.indexOf(tr) + (direction === 'up' ? -1 : 1)]
209+
next = target && target.querySelectorAll('th,td')[colIdx]
210+
} else if (direction === 'left' || direction === 'right') {
211+
next = cells[colIdx + (direction === 'left' ? -1 : 1)]
212+
}
206213
if (next) caretToEnd(next)
207214
}
208215

@@ -223,7 +230,53 @@ export const insertInFocusedTableCell = (which) => {
223230
doInsertOp(root, opts, cell, which)
224231
}
225232

226-
// Shared by the local Alt+Shift+Arrow handler and the registered insert
233+
// Move the focused cell's row/column. `which` ∈ 'rowUp' | 'rowDown' |
234+
// 'colLeft' | 'colRight'. No-op if focus isn't in a cell or the op is
235+
// invalid at this edge (e.g. moving the first body row up would swap it
236+
// with the header).
237+
export const moveInFocusedTableCell = (which) => {
238+
let hostDoc = null
239+
try { hostDoc = (window.top || window.parent)?.document } catch (_) { /* cross-origin */ }
240+
if (!hostDoc) return
241+
const active = hostDoc.activeElement
242+
const cell = active && active.closest && active.closest('table.lsp-mdt th, table.lsp-mdt td')
243+
if (!cell) return
244+
const root = cell.closest('.lsp-mdtable-renderer')
245+
const opts = root && root._lspInlineOpts
246+
if (!opts) return
247+
doMoveOp(root, opts, cell, which)
248+
}
249+
250+
// Shared by the local Alt+Shift+Arrow handler and the registered move
251+
// commands. Stashes a `pendingToolbar` entry at the moved cell's new
252+
// position so `resumePinnedToolbar` follows the cell after re-render.
253+
const doMoveOp = (root, opts, cell, which) => {
254+
const tableEl = cell.closest('table.lsp-mdt')
255+
const tr = cell.closest('tr')
256+
const rows = Array.from(tableEl.querySelectorAll('tr'))
257+
const rowIdx = rows.indexOf(tr)
258+
const cells = Array.from(tr.querySelectorAll('th,td'))
259+
const colIdx = cells.indexOf(cell)
260+
const ord = Array.from(root.querySelectorAll('table.lsp-mdt')).indexOf(tableEl)
261+
let op, newRow = rowIdx, newCol = colIdx
262+
if (which === 'rowUp') {
263+
if (rowIdx < 2) return // can't swap a body row over the header
264+
op = tableOps.moveRowUp; newRow = rowIdx - 1
265+
} else if (which === 'rowDown') {
266+
if (rowIdx < 1 || rowIdx >= rows.length - 1) return
267+
op = tableOps.moveRowDown; newRow = rowIdx + 1
268+
} else if (which === 'colLeft') {
269+
if (colIdx < 1) return
270+
op = tableOps.moveColLeft; newCol = colIdx - 1
271+
} else if (which === 'colRight') {
272+
if (colIdx >= cells.length - 1) return
273+
op = tableOps.moveColRight; newCol = colIdx + 1
274+
} else return
275+
pendingToolbar.set(opts.blockId, { ord, rowIdx: newRow, colIdx: newCol })
276+
commitStructural(root, opts, (m, i) => (i === ord ? op(m, rowIdx, colIdx) : m))
277+
}
278+
279+
// Shared by the local Alt+Ctrl+Shift+Arrow handler and the registered insert
227280
// commands. Stashes a `pendingToolbar` entry at the new cell's position
228281
// so `resumePinnedToolbar` refocuses it after the re-render (and re-pins
229282
// the toolbar if the user has it pinned).
@@ -398,24 +451,24 @@ const buildItems = (root, opts, cell) => {
398451
const L = opts.menuLabels || {}
399452
const items = [
400453
{ icon: ICONS.insertRowAbove, label: L.insertRowAbove || 'Insert row above', enabled: rowIdx >= 1,
401-
shortcut: 'Alt+Shift+Up', run: m => tableOps.insertRowAbove(m, rowIdx) },
454+
shortcut: 'Alt+Ctrl+Shift+Up', run: m => tableOps.insertRowAbove(m, rowIdx) },
402455
{ icon: ICONS.insertRowBelow, label: L.insertRowBelow || 'Insert row below', enabled: true,
403-
shortcut: 'Alt+Shift+Down', run: m => tableOps.insertRowBelow(m, rowIdx) },
456+
shortcut: 'Alt+Ctrl+Shift+Down', run: m => tableOps.insertRowBelow(m, rowIdx) },
404457
{ icon: ICONS.moveRowUp, label: L.moveRowUp || 'Move row up', enabled: rowIdx >= 2,
405-
run: m => tableOps.moveRowUp(m, rowIdx) },
458+
shortcut: 'Alt+Shift+Up', run: m => tableOps.moveRowUp(m, rowIdx) },
406459
{ icon: ICONS.moveRowDown, label: L.moveRowDown || 'Move row down', enabled: rowIdx >= 1 && rowIdx < rowCount - 1,
407-
run: m => tableOps.moveRowDown(m, rowIdx) },
460+
shortcut: 'Alt+Shift+Down', run: m => tableOps.moveRowDown(m, rowIdx) },
408461
{ icon: ICONS.deleteRow, label: L.deleteRow || 'Delete row', enabled: rowCount >= 2,
409462
run: m => tableOps.deleteRow(m, rowIdx) },
410463
{ sep: true },
411464
{ icon: ICONS.insertColLeft, label: L.insertColLeft || 'Insert column left', enabled: true,
412-
shortcut: 'Alt+Shift+Left', run: m => tableOps.insertColLeft(m, rowIdx, colIdx) },
465+
shortcut: 'Alt+Ctrl+Shift+Left', run: m => tableOps.insertColLeft(m, rowIdx, colIdx) },
413466
{ icon: ICONS.insertColRight, label: L.insertColRight || 'Insert column right', enabled: true,
414-
shortcut: 'Alt+Shift+Right', run: m => tableOps.insertColRight(m, rowIdx, colIdx) },
467+
shortcut: 'Alt+Ctrl+Shift+Right', run: m => tableOps.insertColRight(m, rowIdx, colIdx) },
415468
{ icon: ICONS.moveColLeft, label: L.moveColLeft || 'Move column left', enabled: colIdx >= 1,
416-
run: m => tableOps.moveColLeft(m, rowIdx, colIdx) },
469+
shortcut: 'Alt+Shift+Left', run: m => tableOps.moveColLeft(m, rowIdx, colIdx) },
417470
{ icon: ICONS.moveColRight, label: L.moveColRight || 'Move column right', enabled: colIdx < colCount - 1,
418-
run: m => tableOps.moveColRight(m, rowIdx, colIdx) },
471+
shortcut: 'Alt+Shift+Right', run: m => tableOps.moveColRight(m, rowIdx, colIdx) },
419472
{ icon: ICONS.deleteCol, label: L.deleteCol || 'Delete column', enabled: colCount >= 2,
420473
run: m => tableOps.deleteCol(m, rowIdx, colIdx) },
421474
{ sep: true },
@@ -836,13 +889,35 @@ export const attachInlineEditing = (root, opts) => {
836889
moveCaretInFocusedTableCell(e.shiftKey ? 'up' : 'down')
837890
}, true)
838891

839-
// Alt+Shift+Arrow: insert a row above/below or a column left/right of
840-
// the focused cell. Local handler for the same reason as Ctrl+Enter —
892+
// Alt+Ctrl+Arrow: caret nav between adjacent cells in the table. Local
893+
// handler for the same reason as the Ctrl+Enter / Alt+Ctrl+Shift+Arrow paths
894+
// (capture-phase swallowing hides the keys from Logseq's dispatcher);
895+
// matching commands are registered with Logseq so they appear in the
896+
// command palette / keymap UI.
897+
const caretNavKey = (e) => {
898+
if (!e.altKey || !e.ctrlKey || e.shiftKey || e.metaKey) return null
899+
if (e.key === 'ArrowDown') return 'down'
900+
if (e.key === 'ArrowUp') return 'up'
901+
if (e.key === 'ArrowRight') return 'right'
902+
if (e.key === 'ArrowLeft') return 'left'
903+
return null
904+
}
905+
root.addEventListener('keydown', (e) => {
906+
const dir = caretNavKey(e)
907+
if (!dir) return
908+
const cell = e.target.closest && e.target.closest('table.lsp-mdt th, table.lsp-mdt td')
909+
if (!cell) return
910+
e.preventDefault(); e.stopPropagation()
911+
moveCaretInFocusedTableCell(dir)
912+
}, true)
913+
914+
// Alt+Ctrl+Shift+Arrow: insert a row above/below or a column left/right
915+
// of the focused cell. Local handler for the same reason as Ctrl+Enter —
841916
// capture-phase swallowing means Logseq never sees the key, so a
842917
// global shortcut would be unreliable; the registered commands below
843918
// mirror these actions for the palette / keymap UI.
844919
const insertKey = (e) => {
845-
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return null
920+
if (!e.altKey || !e.shiftKey || !e.ctrlKey || e.metaKey) return null
846921
if (e.key === 'ArrowDown') return 'rowBelow'
847922
if (e.key === 'ArrowUp') return 'rowAbove'
848923
if (e.key === 'ArrowRight') return 'colRight'
@@ -858,6 +933,26 @@ export const attachInlineEditing = (root, opts) => {
858933
doInsertOp(root, opts, cell, which)
859934
}, true)
860935

936+
// Alt+Shift+Arrow: move the focused row/column. Local handler (same
937+
// capture-phase reason as the other in-table keybinds); registered
938+
// commands below mirror these for the palette / keymap UI.
939+
const moveKey = (e) => {
940+
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return null
941+
if (e.key === 'ArrowDown') return 'rowDown'
942+
if (e.key === 'ArrowUp') return 'rowUp'
943+
if (e.key === 'ArrowRight') return 'colRight'
944+
if (e.key === 'ArrowLeft') return 'colLeft'
945+
return null
946+
}
947+
root.addEventListener('keydown', (e) => {
948+
const which = moveKey(e)
949+
if (!which) return
950+
const cell = e.target.closest && e.target.closest('table.lsp-mdt th, table.lsp-mdt td')
951+
if (!cell) return
952+
e.preventDefault(); e.stopPropagation()
953+
doMoveOp(root, opts, cell, which)
954+
}, true)
955+
861956
// Force plain-text paste so markup can't smuggle structure into a cell.
862957
root.addEventListener('paste', (e) => {
863958
const cell = e.target.closest('table.lsp-mdt th, table.lsp-mdt td')

0 commit comments

Comments
 (0)