Skip to content
Open
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
11 changes: 8 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import '@logseq/libs'
import parseMarkdownTable from './utils/parseRawInputByMarkdownIt'
import { splitStrByTable } from './utils/splitStrByTable'
import { looksLikeMarkdownTable, markdownTableToMatrix } from './utils/detectMarkdownTable'
import { attachInlineEditing, deleteInFocusedTableCell, insertInFocusedTableCell, moveCaretInFocusedTableCell, moveInFocusedTableCell, prepareInlineRenderer, resumePinnedToolbar } from './utils/inlineEditable'
import { attachInlineEditing, deleteInFocusedTableCell, insertInFocusedTableCell, moveCaretInFocusedTableCell, moveInFocusedTableCell, prepareInlineRenderer, resumePinnedToolbar, syncCellText } from './utils/inlineEditable'
import i18n from './locales/i18n'
import './index.css'

Expand Down Expand Up @@ -359,16 +359,21 @@ if (isInBrowser) {
// Inline styles beat Logseq's higher-specificity prose/table
// rules (which were overriding our class-based stylesheet).
const cellStyle = { textAlign: 'left', verticalAlign: 'top', whiteSpace: 'pre' }
// Cells carry NO `children` prop: text is applied by the
// syncCellText ref instead (uncontrolled contenteditable). If
// React owned the text child, the updateBlock echo re-render
// could rewrite it via setTextContent and yank the caret to the
// start of the cell (worst for empty cells).
return React.createElement('div',
{ key: 's' + si, className: 'lsp-mdtable-scroll' },
React.createElement('table', { className: 'lsp-mdt' }, [
React.createElement('thead', { key: 'h' },
React.createElement('tr', null,
head.map((c, i) => React.createElement('th', { key: i, style: cellStyle }, c)))),
head.map((c, i) => React.createElement('th', { key: i, style: cellStyle, ref: (el) => syncCellText(el, c) })))),
React.createElement('tbody', { key: 'b' },
body.map((row, ri) => React.createElement('tr', { key: ri },
head.map((_, ci) =>
React.createElement('td', { key: ci, style: cellStyle }, row[ci] ?? '')))))
React.createElement('td', { key: ci, style: cellStyle, ref: (el) => syncCellText(el, row[ci] ?? '') })))))
]))
})
const dbRaw = Number(logseq.settings?.inlineEditDebounceMs)
Expand Down
43 changes: 43 additions & 0 deletions src/utils/__tests__/inlineEditable.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { syncCellText } from '../inlineEditable'

const makeCell = (text) => {
const el = document.createElement('td')
el.textContent = text
return el
}

describe('syncCellText', () => {
it('no-ops when the live text already matches (echo re-render)', () => {
const el = makeCell('hello')
syncCellText(el, 'hello')
expect(el.textContent).toBe('hello')
// The original text node must be reused, not rebuilt — replacing it is
// what resets the caret to the start of the cell.
const textNodes = Array.from(el.childNodes).filter(n => n.nodeType === Node.TEXT_NODE)
expect(textNodes).toHaveLength(1)
expect(textNodes[0].nodeValue).toBe('hello')
})

it('writes text into an empty cell', () => {
const el = makeCell('')
syncCellText(el, 'initial')
expect(el.textContent).toBe('initial')
})

it('clears a cell when the new text is empty', () => {
const el = makeCell('old')
syncCellText(el, '')
expect(el.textContent).toBe('')
})

it('overwrites genuinely different text (structural ops)', () => {
const el = makeCell('old')
syncCellText(el, 'new')
expect(el.textContent).toBe('new')
})

it('survives null/undefined elements', () => {
expect(() => syncCellText(null, 'x')).not.toThrow()
expect(() => syncCellText(undefined, 'x')).not.toThrow()
})
})
28 changes: 24 additions & 4 deletions src/utils/inlineEditable.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
//
// Two hazards are designed around here:
//
// 1. Caret jumps. Every updateBlock re-runs the block renderer. We never
// re-sync cell text from props; React reconciles the identical serialized
// text as a no-op, so the live contentEditable DOM (and caret) is left
// untouched. We also ignore the echo of our own write.
// 1. Caret jumps. Every updateBlock re-runs the block renderer, so the React
// tree is reconciled against the freshly-written content. Cells are
// rendered WITHOUT a `children` prop (see syncCellText): React never owns
// their live text, so it can never rewrite it and displace the caret. The
// previous design relied on React treating identical serialized text as a
// no-op, which broke for empty cells — `setTextContent(node, '')`
// rebuilds the text child and the caret jumps to the start of the cell.
// Initial text is applied once via the cell ref, and only when it differs
// from the live DOM, so the normal echo re-render is a no-op. We also
// ignore the echo of our own write.
//
// 2. Structural corruption. A literal `|` or stray `\r` typed into a cell
// would break re-parsing (the parser splits naively on `|`). Cells are
Expand All @@ -34,6 +40,20 @@ const pendingToolbar = new Map()
// ancestor, so we measure the real clipping ancestor and pin the scroll
// box to a pixel width — the table then scrolls inside its own box.

// Apply initial cell text via a cell ref. Cells are rendered without a
// `children` prop (index.js) so React never manages their text: a cell has no
// React-owned text child, `shouldSetTextContent` stays false, and React can
// never call setTextContent on it during a re-render. That is what prevents
// the caret jump for empty cells (see the header note). We write the text once
// here, guarded by a comparison so the normal echo re-render (content === live
// DOM text) is a no-op and the caret is left exactly where the user put it.
// Structural ops (insert/delete/sort/move) produce genuinely different text,
// which this overwrites; resumePinnedToolbar re-anchors the caret afterwards.
export const syncCellText = (el, text) => {
if (!el) return
if (el.textContent !== text) el.textContent = text
}

const isScrollContainer = (cs) =>
/(auto|scroll|hidden)/.test(cs.overflowX) || /(auto|scroll)/.test(cs.overflowY)

Expand Down