fix: guard stale checklist toggles
This commit is contained in:
@@ -573,7 +573,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
|
||||
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
|
||||
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Checklist checkbox handlers also re-resolve the live block before updating `checked`, making delayed clicks after note reloads a no-op instead of a stale block mutation. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
|
||||
@@ -346,6 +346,20 @@ index b2761001278486a8b2ac1d10c82420b4994e96d9..c44e9c09100c6d1803bd71b2a338c43e
|
||||
n.updateBlock(t.id, { props: { language: d } });
|
||||
};
|
||||
i.addEventListener("change", l), s = () => i.removeEventListener("change", l);
|
||||
@@ -1802,9 +1804,12 @@ const ln = () => ({
|
||||
parseContent: ({ el: e, schema: t }) => Y(e, t, "checkListItem"),
|
||||
render(e, t) {
|
||||
const n = document.createDocumentFragment(), o = document.createElement("input");
|
||||
o.type = "checkbox", o.checked = e.props.checked, e.props.checked && o.setAttribute("checked", ""), o.addEventListener("change", () => {
|
||||
- t.updateBlock(e, { props: { checked: !e.props.checked } });
|
||||
+ const r = t.getBlock(e.id);
|
||||
+ if (!r)
|
||||
+ return;
|
||||
+ t.updateBlock(r, { props: { checked: !r.props.checked } });
|
||||
});
|
||||
const r = document.createElement("p"), a = document.createElement("div");
|
||||
return a.contentEditable = "false", a.appendChild(o), n.appendChild(a), n.appendChild(r), {
|
||||
dom: n,
|
||||
@@ -2621,6 +2623,9 @@ class On {
|
||||
this.state.referencePos = o.getBoundingClientRect().toJSON(), this.emitUpdate(this.pluginState.triggerCharacter);
|
||||
}
|
||||
@@ -441,6 +455,24 @@ index dbb7fc33a9add7a96488349876bc56ad60111a3f..58c3cf181f25467d85cd8c3788b5b73d
|
||||
editor.updateBlock(block.id, { props: { language } });
|
||||
};
|
||||
select.addEventListener("change", handleLanguageChange);
|
||||
diff --git a/src/blocks/ListItem/CheckListItem/block.ts b/src/blocks/ListItem/CheckListItem/block.ts
|
||||
index 682cbba711bd9ca107fe3940f44ab39fc757cfe0..2d2f871ac13836b736543b29a620580a7a3f1057 100644
|
||||
--- a/src/blocks/ListItem/CheckListItem/block.ts
|
||||
+++ b/src/blocks/ListItem/CheckListItem/block.ts
|
||||
@@ -82,7 +82,12 @@ export const createCheckListItemBlockSpec = createBlockSpec(
|
||||
checkbox.setAttribute("checked", "");
|
||||
}
|
||||
checkbox.addEventListener("change", () => {
|
||||
- editor.updateBlock(block, { props: { checked: !block.props.checked } });
|
||||
+ const liveBlock = editor.getBlock(block.id);
|
||||
+ if (!liveBlock) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ editor.updateBlock(liveBlock, { props: { checked: !liveBlock.props.checked } });
|
||||
});
|
||||
// We use a <p> tag, because for <li> tags we'd need a <ul> element to put
|
||||
// them in to be semantically correct, which we can't have due to the
|
||||
diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts
|
||||
index 45f0acf8e5d869eb13a3c3d70e6f67059f86c598..2a4d15c8e8465181cc009ed9912cd284e0930251 100644
|
||||
--- a/src/editor/managers/ExtensionManager/extensions.ts
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -16,7 +16,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf
|
||||
hash: 93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
'@blocknote/react@0.46.2':
|
||||
hash: e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76
|
||||
@@ -37,10 +37,10 @@ importers:
|
||||
version: 0.78.0(zod@4.3.6)
|
||||
'@blocknote/code-block':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
'@blocknote/core':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
version: 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/mantine':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -5608,9 +5608,9 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
@@ -5618,7 +5618,7 @@ snapshots:
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.22.0
|
||||
|
||||
'@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
'@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
dependencies:
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
|
||||
@@ -5670,7 +5670,7 @@ snapshots:
|
||||
|
||||
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/react': 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/hooks': 8.3.14(react@19.2.4)
|
||||
@@ -5692,7 +5692,7 @@ snapshots:
|
||||
|
||||
'@blocknote/react@0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
82
src/lib/blockNoteChecklist.regression.test.ts
Normal file
82
src/lib/blockNoteChecklist.regression.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createCheckListItemBlockSpec } from '../../node_modules/@blocknote/core/src/blocks/ListItem/CheckListItem/block'
|
||||
|
||||
const checkListItemSpec = createCheckListItemBlockSpec()
|
||||
|
||||
type CheckListItemBlock = Parameters<typeof checkListItemSpec.implementation.render>[0]
|
||||
type CheckListItemEditor = Parameters<typeof checkListItemSpec.implementation.render>[1]
|
||||
type RenderedCheckListItem = ReturnType<typeof checkListItemSpec.implementation.render>
|
||||
|
||||
type CheckListItemControlEditor = {
|
||||
getBlock: (id: string) => CheckListItemBlock | undefined
|
||||
updateBlock: (block: CheckListItemBlock, update: { props: { checked: boolean } }) => void
|
||||
}
|
||||
|
||||
function createCheckListItem(checked = false): CheckListItemBlock {
|
||||
return {
|
||||
id: 'check-list-item-1',
|
||||
type: 'checkListItem',
|
||||
props: { checked },
|
||||
content: [],
|
||||
children: [],
|
||||
} as CheckListItemBlock
|
||||
}
|
||||
|
||||
function renderCheckListItem(editor: CheckListItemControlEditor, checked = false) {
|
||||
const block = createCheckListItem(checked)
|
||||
const view = checkListItemSpec.implementation.render(
|
||||
block,
|
||||
editor as CheckListItemEditor,
|
||||
) as RenderedCheckListItem
|
||||
const host = document.createElement('div')
|
||||
host.appendChild(view.dom)
|
||||
document.body.appendChild(host)
|
||||
|
||||
const checkbox = host.querySelector('input[type="checkbox"]')
|
||||
if (!(checkbox instanceof HTMLInputElement)) throw new Error('Expected checklist checkbox')
|
||||
|
||||
return { block, checkbox, host, view }
|
||||
}
|
||||
|
||||
function dispatchChange(checkbox: HTMLInputElement) {
|
||||
checkbox.dispatchEvent(new window.Event('change'))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('patched BlockNote checklist controls', () => {
|
||||
it('ignores stale checkbox changes when the target checklist block disappeared', () => {
|
||||
const editor: CheckListItemControlEditor = {
|
||||
getBlock: vi.fn(() => undefined),
|
||||
updateBlock: vi.fn(),
|
||||
}
|
||||
|
||||
const { block, checkbox, view } = renderCheckListItem(editor)
|
||||
checkbox.checked = true
|
||||
dispatchChange(checkbox)
|
||||
|
||||
expect(editor.getBlock).toHaveBeenCalledWith(block.id)
|
||||
expect(editor.updateBlock).not.toHaveBeenCalled()
|
||||
view.destroy?.()
|
||||
})
|
||||
|
||||
it('applies live checkbox changes to the current checklist block', () => {
|
||||
const existingBlock = createCheckListItem()
|
||||
const editor: CheckListItemControlEditor = {
|
||||
getBlock: vi.fn(() => existingBlock),
|
||||
updateBlock: vi.fn(),
|
||||
}
|
||||
|
||||
const { block, checkbox, view } = renderCheckListItem(editor)
|
||||
checkbox.checked = true
|
||||
dispatchChange(checkbox)
|
||||
|
||||
expect(editor.getBlock).toHaveBeenCalledWith(block.id)
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith(existingBlock, {
|
||||
props: { checked: true },
|
||||
})
|
||||
view.destroy?.()
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ let tempVaultDir: string
|
||||
function isEditorTypingCrash(message: string): boolean {
|
||||
return (
|
||||
message.includes('beforeinput') ||
|
||||
message.includes('Block with ID') ||
|
||||
message.includes('stale editor view') ||
|
||||
message.includes('Cannot read properties') ||
|
||||
message.includes('undefined is not an object') ||
|
||||
@@ -72,6 +73,44 @@ async function expectNoteFileToContain(filePath: string, marker: string): Promis
|
||||
await expect.poll(() => fs.readFileSync(filePath, 'utf8'), { timeout: 10_000 }).toContain(marker)
|
||||
}
|
||||
|
||||
function writeChecklistNote(filePath: string, marker: string, checked = false): void {
|
||||
fs.writeFileSync(filePath, `---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
---
|
||||
|
||||
# Note B
|
||||
|
||||
- [${checked ? 'x' : ' '}] Toggle me
|
||||
- [ ] Keep me
|
||||
|
||||
${marker}
|
||||
`, 'utf8')
|
||||
}
|
||||
|
||||
function checklistCheckbox(page: Page, index: number) {
|
||||
return page.locator('.bn-block-content[data-content-type="checkListItem"] input[type="checkbox"]').nth(index)
|
||||
}
|
||||
|
||||
async function retainCurrentChecklistCheckbox(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const testWindow = window as typeof window & { __staleChecklistCheckbox?: HTMLInputElement | null }
|
||||
testWindow.__staleChecklistCheckbox = document.querySelector(
|
||||
'.bn-block-content[data-content-type="checkListItem"] input[type="checkbox"]',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchRetainedChecklistChange(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const testWindow = window as typeof window & { __staleChecklistCheckbox?: HTMLInputElement | null }
|
||||
const checkbox = testWindow.__staleChecklistCheckbox
|
||||
if (!checkbox) throw new Error('Expected retained checklist checkbox')
|
||||
checkbox.checked = !checkbox.checked
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function reloadVault(page: Page): Promise<void> {
|
||||
await triggerMenuCommand(page, 'vault-reload')
|
||||
await expect(page.getByText(/Vault reloaded \(\d+ entries\)/).last()).toBeVisible({
|
||||
@@ -118,3 +157,30 @@ test('@smoke typing after a rich-editor reload and note switch stays usable', as
|
||||
await page.waitForTimeout(500)
|
||||
expect(crashes).toEqual([])
|
||||
})
|
||||
|
||||
test('checklist toggles after a rich-editor reload ignore stale checkbox events', async ({ page }) => {
|
||||
const crashes = trackEditorTypingCrashes(page)
|
||||
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
const initialMarker = `initial checklist body ${Date.now()}`
|
||||
const reloadMarker = `reloaded checklist body ${Date.now()}`
|
||||
|
||||
writeChecklistNote(noteBPath, initialMarker)
|
||||
await openNote(page, 'Note B')
|
||||
await expect(checklistCheckbox(page, 0)).not.toBeChecked()
|
||||
await retainCurrentChecklistCheckbox(page)
|
||||
|
||||
writeChecklistNote(noteBPath, reloadMarker)
|
||||
await reloadVault(page)
|
||||
await openNote(page, 'Alpha Project')
|
||||
await openNote(page, 'Note B')
|
||||
await expect(page.locator('.bn-editor')).toContainText(reloadMarker)
|
||||
|
||||
await dispatchRetainedChecklistChange(page)
|
||||
|
||||
const liveCheckbox = checklistCheckbox(page, 0)
|
||||
await liveCheckbox.click()
|
||||
await expect(liveCheckbox).toBeChecked()
|
||||
await expectNoteFileToContain(noteBPath, '- [x] Toggle me')
|
||||
await page.waitForTimeout(500)
|
||||
expect(crashes).toEqual([])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user