From 5694eed2ff739bb4647987cd1bd2f93ef548d6b6 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 6 Jun 2026 19:00:33 +0800 Subject: [PATCH 1/2] fix(muyajs): split correct paragraph on Enter in multi-block list items (#4374) (#4376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(muyajs): split correct paragraph on Enter in multi-block list items (#4374) enterHandler walked up from

to the parent

  • and then called chopBlockByCursor(li.children[0], start.key, ...) — assuming the active line lives in li.children[0] (or [1] for task lists). For loose lists, items with nested sublists, or task items with trailing paragraphs the caret's span is not under children[0], so findIndex returned -1 and the next statement crashed on `children[-1].nextSibling = null`. Capture the active paragraph before the block→li promotion, look it up in li.children, and migrate the blocks after it (sublist and/or trailing paragraphs) into the new li so split semantics preserve order. Added a defensive `index === -1` early return inside chopBlockByCursor. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(muyajs): address Copilot review on #4376 - chopBlockByCursor: return a well-formed empty paragraph via createBlockP('') in the index === -1 guard, so downstream code that assumes p.children[0] exists keeps the invariant. - issue-4374 spec: assert one extra
  • is rendered after splitting a single-paragraph list item, so the sanity check catches functional regressions (not just crashes). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- packages/desktop/test/e2e/issue-4374.spec.ts | 152 ++++++++++++++++++ packages/muyajs/lib/contentState/enterCtrl.js | 41 +++-- 2 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 packages/desktop/test/e2e/issue-4374.spec.ts diff --git a/packages/desktop/test/e2e/issue-4374.spec.ts b/packages/desktop/test/e2e/issue-4374.spec.ts new file mode 100644 index 0000000000..01fa3533e8 --- /dev/null +++ b/packages/desktop/test/e2e/issue-4374.spec.ts @@ -0,0 +1,152 @@ +// Regression guard for issue #4374: +// TypeError: Cannot set properties of undefined (setting 'nextSibling') +// at ContentState.chopBlockByCursor (enterCtrl.js) +// at ContentState.enterHandler (enterCtrl.js) +// +// Root cause: enterHandler() walked up from

    to the parent

  • and +// then called chopBlockByCursor(li.children[0], start.key, ...) — assuming +// the active line lives in li.children[0] (or [1] for task lists). When +// the list item carries multiple content blocks (loose list paragraphs, +// trailing paragraph after a sublist, etc.), the caret's span isn't a +// child of children[0], so findIndex returns -1 and the next statement +// crashes on `children[-1].nextSibling = null`. +// +// Fix (packages/muyajs/lib/contentState/enterCtrl.js): capture the active +// paragraph before promoting `block` to its `li` parent, locate it inside +// li.children, and move any blocks AFTER it (sublist + trailing +// paragraphs) into the new list item. Added a defensive `index === -1` +// early-return inside chopBlockByCursor. +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { + clearRendererErrors, + expectNoRendererErrors, + launchWithMarkdown, + placeCaretInEditor, + setSourceMarkdown +} from './helpers' + +const placeCaretInSpanContaining = async(page: Page, needle: string) => { + await page.evaluate((text) => { + const spans = document.querySelectorAll('.editor-component span.ag-paragraph') + let target: HTMLElement | null = null + for (const span of spans) { + if ((span.textContent ?? '').includes(text)) { + target = span as HTMLElement + break + } + } + if (!target) return + const range = document.createRange() + range.selectNodeContents(target) + range.collapse(false) // caret at end + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) + document.dispatchEvent(new Event('selectionchange')) + }, needle) + await page.waitForTimeout(150) +} + +test.describe('Issue #4374: enterHandler chopBlockByCursor nextSibling crash', () => { + let app: ElectronApplication + let page: Page + + test.beforeEach(async() => { + const launched = await launchWithMarkdown('# Repro\n\n', { suppressErrorDialog: true }) + app = launched.app + page = launched.page + await placeCaretInEditor(page) + await clearRendererErrors(app) + }) + + test.afterEach(async() => { + if (app) await app.close() + }) + + test('Enter inside the second paragraph of a loose list item does not crash', async() => { + const md = '# Doc\n\n- first paragraph\n\n second paragraph\n\n- another item\n' + await setSourceMarkdown(page, app, md) + await page.waitForTimeout(500) + await placeCaretInSpanContaining(page, 'second paragraph') + await clearRendererErrors(app) + + await page.keyboard.press('Enter') + await page.waitForTimeout(300) + await expectNoRendererErrors(app) + }) + + test('Enter in the trailing paragraph of a task list item does not crash', async() => { + const md = '# Doc\n\n- [ ] task line\n\n trailing paragraph\n' + await setSourceMarkdown(page, app, md) + await page.waitForTimeout(500) + await placeCaretInSpanContaining(page, 'trailing paragraph') + await clearRendererErrors(app) + + await page.keyboard.press('Enter') + await page.waitForTimeout(300) + await expectNoRendererErrors(app) + }) + + test('Enter in a paragraph after a nested sublist in a loose item does not crash', async() => { + const md = + '# Doc\n\n' + + '- main paragraph\n' + + '\n' + + ' - sub one\n' + + ' - sub two\n' + + '\n' + + ' tail paragraph\n' + await setSourceMarkdown(page, app, md) + await page.waitForTimeout(500) + await placeCaretInSpanContaining(page, 'tail paragraph') + await clearRendererErrors(app) + + await page.keyboard.press('Enter') + await page.waitForTimeout(300) + await expectNoRendererErrors(app) + }) + + test('Enter mid-paragraph in second paragraph of loose list item does not crash', async() => { + const md = '# Doc\n\n- alpha\n\n beta gamma delta\n' + await setSourceMarkdown(page, app, md) + await page.waitForTimeout(500) + await placeCaretInSpanContaining(page, 'beta gamma delta') + await page.keyboard.press('Home') + for (let i = 0; i < 4; i++) await page.keyboard.press('ArrowRight') + await clearRendererErrors(app) + + await page.keyboard.press('Enter') + await page.waitForTimeout(300) + await expectNoRendererErrors(app) + }) + + test('Enter at end of a single-paragraph list item still creates a new item', async() => { + // Sanity check that the historical [p] / [p, sublist] code paths still + // behave: a single-paragraph normal list item splitting on Enter must + // continue to yield a new list item, not regress to a paragraph break. + const md = '# Doc\n\n- one\n- two\n' + await setSourceMarkdown(page, app, md) + await page.waitForTimeout(500) + + const liCountBefore = await page.evaluate( + () => document.querySelectorAll('.editor-component ul > li').length + ) + + await placeCaretInSpanContaining(page, 'one') + await clearRendererErrors(app) + + await page.keyboard.press('Enter') + await page.waitForTimeout(200) + await page.keyboard.type('inserted', { delay: 5 }) + await page.waitForTimeout(200) + + const liCountAfter = await page.evaluate( + () => document.querySelectorAll('.editor-component ul > li').length + ) + // Splitting `- one` into `- one` + `- inserted` must yield one more
  • , + // not collapse to a paragraph break or duplicate the original item. + expect(liCountAfter).toBe(liCountBefore + 1) + await expectNoRendererErrors(app) + }) +}) diff --git a/packages/muyajs/lib/contentState/enterCtrl.js b/packages/muyajs/lib/contentState/enterCtrl.js index 171fd1fd87..2f3340a0cf 100644 --- a/packages/muyajs/lib/contentState/enterCtrl.js +++ b/packages/muyajs/lib/contentState/enterCtrl.js @@ -21,9 +21,16 @@ const getIndentSpace = (text) => { const enterCtrl = (ContentState) => { // TODO@jocs this function need opti. ContentState.prototype.chopBlockByCursor = function(block, key, offset) { - const newBlock = this.createBlock('p') const { children } = block const index = children.findIndex((child) => child.key === key) + // Defensive guard for issue #4374: when the caller resolves the wrong + // owning paragraph (e.g. a list item with multiple paragraphs), `key` + // isn't in `children`. Return a well-formed empty paragraph (p with a + // single empty span child) instead of crashing on + // `children[-1].nextSibling = null` — a bare `createBlock('p')` would + // violate downstream code that assumes `p.children[0]` exists. + if (index === -1) return this.createBlockP('') + const newBlock = this.createBlock('p') const activeLine = this.getBlock(key) const { text } = activeLine newBlock.children = children.splice(index + 1) @@ -448,6 +455,10 @@ const enterCtrl = (ContentState) => { // we only want to select the li if and only if we are currently in the

    of an li // the

    is the "text content" of the li + // Capture the active paragraph BEFORE we walk up to the li so the li + // branch below can split the actual line container, not a guessed + // children[0]/[1]. Fixes #4374. + const activeParagraphBlock = block if (parent && parent.type === 'li' && block.type === 'p') { block = parent parent = this.getParent(block) @@ -481,22 +492,34 @@ const enterCtrl = (ContentState) => { } else if (block.type === 'p') { newBlock = this.chopBlockByCursor(block, start.key, start.offset) } else if (type === 'li') { - // handle task item + // Locate the paragraph that actually owns the caret. Loose lists + // and lists with nested sublists yield li.children shaped like + // [p, ul, p] or [p1, p2] — the caret may live in any of them, + // not just children[0]/[1]. Fallback to the historical index so + // single-paragraph items behave exactly as before. (#4374) + const fallbackIdx = block.listItemType === 'task' ? 1 : 0 + let activeIdx = block.children.findIndex((c) => c.key === activeParagraphBlock.key) + if (activeIdx === -1) activeIdx = fallbackIdx + const activeChild = block.children[activeIdx] || block.children[fallbackIdx] + const trailing = block.children.slice(activeIdx + 1) + if (block.listItemType === 'task') { const { checked } = block.children[0] // block.children[0] is input[type=checkbox] - newBlock = this.chopBlockByCursor(block.children[1], start.key, start.offset) + newBlock = this.chopBlockByCursor(activeChild, start.key, start.offset) newBlock = this.createTaskItemBlock(newBlock, checked) } else { - newBlock = this.chopBlockByCursor(block.children[0], start.key, start.offset) + newBlock = this.chopBlockByCursor(activeChild, start.key, start.offset) newBlock = this.createBlockLi(newBlock) newBlock.listItemType = block.listItemType newBlock.bulletMarkerOrDelimiter = block.bulletMarkerOrDelimiter + } - if (block.children.length > 1) { - // If we have a sublist, we need to move the sublist (not the contents) to the new block instead of inserting an "empty" block at the next line - this.appendChild(newBlock, block.children[1]) - this.removeBlock(block.children[1]) - } + // Move every block that came AFTER the active paragraph (sublist + // and/or trailing paragraphs) into the new li so split semantics + // preserve continuation order. + for (const child of trailing) { + this.appendChild(newBlock, child) + this.removeBlock(child) } newBlock.isLooseListItem = block.isLooseListItem } else if (block.type === 'hr') { From 410871187e331cb8c6d9ff03b0ca261755d4470d Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 6 Jun 2026 21:41:06 +0800 Subject: [PATCH 2/2] fix(renderer): improve graphite contrast and unify accent colors (#4377) (#4378) - graphite: switch --itemBgColor from a dark semi-transparent value to #e5e5e5 so the "New File" / "Open Folder" buttons are legible on the light page background. - el-switch: make the checked state use --themeColor instead of the Element Plus default blue, matching el-slider which already follows --themeColor. - el-radio: make the checked dot use --themeColor while keeping the label text in the normal color (no highlight on the active label). - el-select dropdown: paint the selected item with --themeColor and a --themeColor10 background; use --themeColor for the trigger focus ring as well. - el-input: use --themeColor for the focus ring. Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/renderer/src/assets/styles/index.css | 14 ++++++++++++++ .../renderer/src/assets/themes/graphite.theme.css | 2 +- .../src/prefComponents/common/bool/index.vue | 5 +++++ .../src/prefComponents/common/select/index.vue | 8 ++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/renderer/src/assets/styles/index.css b/packages/desktop/src/renderer/src/assets/styles/index.css index 6934f68950..d69b3660f7 100644 --- a/packages/desktop/src/renderer/src/assets/styles/index.css +++ b/packages/desktop/src/renderer/src/assets/styles/index.css @@ -202,6 +202,15 @@ body { background-color: var(--themeColor); } +.el-radio__input.is-checked .el-radio__inner { + border-color: var(--themeColor); + background-color: var(--themeColor); +} + +.el-radio__input.is-checked + .el-radio__label { + color: var(--editorColor); +} + .el-dialog, .el-dialog.ag-dialog-table { border-radius: 8px; @@ -221,6 +230,11 @@ input.el-input__inner { color: var(--editorColor80); } +.el-input.is-focus .el-input__wrapper, +.el-input__wrapper.is-focus { + box-shadow: 0 0 0 1px var(--themeColor) inset; +} + .el-input-number.is-controls-right span.el-input-number__decrease, .el-input-number.is-controls-right span.el-input-number__increase { background: var(--buttonBgColor); diff --git a/packages/desktop/src/renderer/src/assets/themes/graphite.theme.css b/packages/desktop/src/renderer/src/assets/themes/graphite.theme.css index ef9b65d98a..be195f1ce2 100644 --- a/packages/desktop/src/renderer/src/assets/themes/graphite.theme.css +++ b/packages/desktop/src/renderer/src/assets/themes/graphite.theme.css @@ -77,7 +77,7 @@ --sideBarTextColor: rgba(43, 48, 50, 0.6); --sideBarBgColor: #ececec; --sideBarItemHoverBgColor: rgba(255, 255, 255, 0.03); - --itemBgColor: rgba(43, 48, 50, 0.5); + --itemBgColor: #e5e5e5; --floatFontColor: rgba(43, 48, 50, 0.7); --floatBgColor: rgb(237, 237, 238); diff --git a/packages/desktop/src/renderer/src/prefComponents/common/bool/index.vue b/packages/desktop/src/renderer/src/prefComponents/common/bool/index.vue index 14af504b3d..f7b98a3081 100644 --- a/packages/desktop/src/renderer/src/prefComponents/common/bool/index.vue +++ b/packages/desktop/src/renderer/src/prefComponents/common/bool/index.vue @@ -130,4 +130,9 @@ span.el-switch__label { .el-switch:not(.is-checked) .el-switch__core::after { background: var(--iconColor); } + +.el-switch.is-checked .el-switch__core { + border-color: var(--themeColor); + background-color: var(--themeColor); +} diff --git a/packages/desktop/src/renderer/src/prefComponents/common/select/index.vue b/packages/desktop/src/renderer/src/prefComponents/common/select/index.vue index 349469f782..f13050c1dc 100644 --- a/packages/desktop/src/renderer/src/prefComponents/common/select/index.vue +++ b/packages/desktop/src/renderer/src/prefComponents/common/select/index.vue @@ -124,6 +124,11 @@ li.el-select-dropdown__item.hover, li.el-select-dropdown__item:hover { background: var(--floatHoverColor); } +li.el-select-dropdown__item.selected, +li.el-select-dropdown__item.is-selected { + color: var(--themeColor); + background: var(--themeColor10); +} div.el-select-dropdown { background: var(--floatBgColor); border-color: var(--floatBorderColor); @@ -131,4 +136,7 @@ div.el-select-dropdown { display: none; } } +.el-select__wrapper.is-focused { + box-shadow: 0 0 0 1px var(--themeColor) inset; +}