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;
+}
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') {