Skip to content
Merged
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
14 changes: 14 additions & 0 deletions packages/desktop/src/renderer/src/assets/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,19 @@ 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);
& .popper__arrow {
display: none;
}
}
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--themeColor) inset;
}
</style>
152 changes: 152 additions & 0 deletions packages/desktop/test/e2e/issue-4374.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <p> to the parent <li> 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 <li>,
// not collapse to a paragraph break or duplicate the original item.
expect(liCountAfter).toBe(liCountBefore + 1)
await expectNoRendererErrors(app)
})
})
41 changes: 32 additions & 9 deletions packages/muyajs/lib/contentState/enterCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -448,6 +455,10 @@ const enterCtrl = (ContentState) => {

// we only want to select the li if and only if we are currently in the <p> of an li
// the <p> 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)
Expand Down Expand Up @@ -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') {
Expand Down
Loading