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
71 changes: 68 additions & 3 deletions packages/desktop/src/renderer/src/components/sideBar/toc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@
{{ t('sideBar.toc.title') }}
</div>
<el-tree
v-if="toc.length"
:data="toc"
:default-expand-all="true"
v-if="keyedToc.length"
:data="keyedToc"
node-key="key"
:default-expanded-keys="expandedKeys"
:props="defaultProps"
:expand-on-click-node="false"
:indent="10"
:icon="ArrowRight"
@node-click="handleClick"
@node-expand="onExpand"
@node-collapse="onCollapse"
/>
</div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEditorStore } from '@/store/editor'
import { usePreferencesStore } from '@/store/preferences'
import bus from '../../bus'
Expand All @@ -40,6 +44,67 @@ const defaultProps = {
const { toc } = storeToRefs(editorStore)
const { wordWrapInToc } = storeToRefs(preferencesStore)

interface KeyedTocNode {
key: string
label: unknown
slug: unknown
children: KeyedTocNode[]
}

// The TOC nodes carry no stable id, so el-tree (without a node-key) discarded
// the user's expand/collapse state on every content edit (#3028). Derive a
// stable key per node — its slug, deduplicated in document order so duplicate
// headings stay unique — and let el-tree preserve state by that key.
const keyedToc = computed<KeyedTocNode[]>(() => {
const seen = new Map<string, number>()
const assign = (nodes: Array<Record<string, unknown>>): KeyedTocNode[] =>
nodes.map((node) => {
const base = typeof node.slug === 'string' && node.slug ? node.slug : 'heading'
const count = seen.get(base) ?? 0
seen.set(base, count + 1)
return {
key: count === 0 ? base : `${base}-${count}`,
label: node.label,
slug: node.slug,
children: assign((node.children as Array<Record<string, unknown>>) ?? [])
}
})
return assign(toc.value as unknown as Array<Record<string, unknown>>)
})

// Track which headings the user collapsed, by stable key (#3028). Headings are
// expanded by default; a collapse is remembered here.
const collapsedKeys = ref<Set<string>>(new Set())

const onCollapse = (data: { key?: string }): void => {
if (data.key) collapsedKeys.value = new Set(collapsedKeys.value).add(data.key)
}

const onExpand = (data: { key?: string }): void => {
if (!data.key) return
const next = new Set(collapsedKeys.value)
next.delete(data.key)
collapsedKeys.value = next
}

// The set el-tree should have expanded: every node that is neither collapsed
// nor inside a collapsed ancestor. On each content edit el-tree rebuilds and
// re-applies these keys, so binding the *correct* set makes it paint the right
// state directly — instead of expanding everything and then collapsing, which
// flickered.
const expandedKeys = computed<string[]>(() => {
const keys: string[] = []
const walk = (nodes: KeyedTocNode[], hiddenByAncestor: boolean): void => {
for (const node of nodes) {
const collapsed = hiddenByAncestor || collapsedKeys.value.has(node.key)
if (!collapsed) keys.push(node.key)
walk(node.children, collapsed)
}
}
walk(keyedToc.value, false)
return keys
})

const handleClick = (data: { slug?: unknown }): void => {
// editor.vue builds a CSS selector with `#${slug}` — bail out if the
// node has no slug (e.g. unsluggable headings) to avoid emitting
Expand Down
113 changes: 113 additions & 0 deletions packages/desktop/test/e2e/toc-collapse-state-3028.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test'
import type { ElectronApplication, Page } from 'playwright'
import { launchWithMarkdown, clickMenuById, waitForEditor } from './helpers'

// #3028 — collapsing a heading in the TOC must survive a document edit.
//
// The TOC el-tree was bound with `:default-expand-all` and reseeded from a
// fresh `listToTree(toc)` on every `json-change`, so any edit re-expanded the
// whole tree and discarded the user's collapse state.
//
// # A
// ## B
// ### B1
// ## C
const INITIAL_DOC = '# A\n\n## B\n\n### B1\n\n## C\n'

// Labels of TOC nodes that are actually visible (a collapsed parent hides its
// children via `display:none`, so they drop out of this list).
const readVisibleTocLabels = (page: Page): Promise<string[]> =>
page.evaluate(() => {
const nodes = Array.from(
document.querySelectorAll('.side-bar-toc .el-tree .el-tree-node')
) as HTMLElement[]
return nodes
.filter((n) => n.offsetParent !== null)
.map((n) => {
const labelEl = n.querySelector(':scope > .el-tree-node__content .el-tree-node__label')
return (labelEl?.textContent || '').trim()
})
})

const collapseNode = (page: Page, label: string): Promise<void> =>
page.evaluate((lbl) => {
const nodes = Array.from(
document.querySelectorAll('.side-bar-toc .el-tree .el-tree-node')
) as HTMLElement[]
const node = nodes.find((n) => {
const l = n.querySelector(':scope > .el-tree-node__content .el-tree-node__label')
return (l?.textContent || '').trim() === lbl
})
if (!node) throw new Error(`TOC node "${lbl}" not found`)
const icon = node.querySelector(
':scope > .el-tree-node__content .el-tree-node__expand-icon'
) as HTMLElement | null
if (!icon) throw new Error(`TOC node "${lbl}" has no expand icon`)
icon.click()
}, label)

const ensureSidebarVisible = async(app: ElectronApplication, page: Page): Promise<void> => {
const visible = await page.evaluate(() => {
const el = document.querySelector('.side-bar') as HTMLElement | null
return !!(el && el.offsetParent !== null)
})
if (!visible) {
await clickMenuById(app, 'sideBarMenuItem')
await page.waitForFunction(
() => {
const el = document.querySelector('.side-bar') as HTMLElement | null
return !!(el && el.offsetParent !== null)
},
null,
{ timeout: 5000 }
)
}
}

test.describe('TOC collapse state survives edits (#3028)', () => {
let app: ElectronApplication
let page: Page

test.beforeAll(async() => {
const launched = await launchWithMarkdown(INITIAL_DOC)
app = launched.app
page = launched.page
await waitForEditor(page)
await ensureSidebarVisible(app, page)
await clickMenuById(app, 'tocMenuItem')
await page.waitForSelector('.side-bar-toc .el-tree', { state: 'visible', timeout: 10000 })
await page.waitForFunction(
() => document.querySelectorAll('.side-bar-toc .el-tree-node__label').length >= 4,
null,
{ timeout: 10000 }
)
})

test.afterAll(async() => {
if (app) await app.close()
})

test('a collapsed heading stays collapsed after a content edit', async() => {
// Everything expanded initially.
await expect.poll(() => readVisibleTocLabels(page), { timeout: 8000 })
.toEqual(['A', 'B', 'B1', 'C'])

// Collapse "B": its child "B1" disappears.
await collapseNode(page, 'B')
await expect.poll(() => readVisibleTocLabels(page), { timeout: 5000 })
.toEqual(['A', 'B', 'C'])

// Edit a different heading ("C" -> "C2"), triggering UPDATE_TOC.
const cContent = page
.locator('.mu-container h2 .mu-atxheading-content')
.filter({ hasText: 'C' })
.last()
await cContent.click()
await page.keyboard.press('End')
await page.keyboard.type('2', { delay: 20 })

// After the live TOC update, "B" must still be collapsed (B1 hidden).
await expect.poll(() => readVisibleTocLabels(page), { timeout: 8000 })
.toEqual(['A', 'B', 'C2'])
})
})
56 changes: 56 additions & 0 deletions packages/muya/e2e/tests/drag/image-resize-bar-reflow-2939.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Locator } from '@playwright/test';
import { expect, test } from '../fixtures/muya';
import { editor, floats } from '../helpers/selectors';

/**
* #2939 — the ImageResizeBar positioned its left/right handles once on image
* click and never tracked layout reflow (unlike imageToolbar, which rides
* baseFloat's `autoUpdate`). When the surrounding layout shifted — the desktop
* sidebar toggling, or any window/ancestor resize — the handles stayed at their
* stale coordinates, detached from the image.
*
* Reproduce the reflow with a viewport resize (the muya container is centered,
* so narrowing the viewport moves the image's left edge) and assert the left
* handle stays aligned with the image.
*/

const DATA_URI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=';

test.describe('ImageResizeBar reflow (#2939)', () => {
test('left handle tracks the image when the viewport resizes', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await page.evaluate(uri => window.muya!.setContent(`![alt](${uri})`), DATA_URI);

const image = page.locator(editor.image).first();
await expect(image).toBeVisible();
await expect.poll(async () => image.evaluate(el =>
el.classList.contains('mu-image-success')), { timeout: 5_000 }).toBe(true);

const innerImg = image.locator('img').first();
await innerImg.click();

const leftHandle = page.locator(`${floats.imageTransformer} .bar.left`);
await expect(leftHandle).toBeVisible();

const offsetBefore = await getHandleOffset(innerImg, leftHandle);
expect(Math.abs(offsetBefore)).toBeLessThan(4);

// Narrow the viewport: the centered container — and the image — shift right.
await page.setViewportSize({ width: 760, height: 900 });

await expect.poll(async () => Math.abs(await getHandleOffset(innerImg, leftHandle)), {
timeout: 5_000,
intervals: [50, 100, 250, 500],
}).toBeLessThan(4);
});
});

async function getHandleOffset(img: Locator, handle: Locator): Promise<number> {
const imgBox = await img.boundingBox();
const handleBox = await handle.boundingBox();
if (!imgBox || !handleBox)
throw new Error('missing bounding box');
// The left handle sits at `image.left - 5` (CIRCLE_RADIO); compare centers.
return (handleBox.x + handleBox.width / 2) - imgBox.x;
}
14 changes: 14 additions & 0 deletions packages/muya/src/assets/styles/blockSyntax.css
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,20 @@ figure.mu-diagram-block .mu-diagram-preview svg path[stroke='#000000'] {
stroke: var(--editor-color);
}

/* js-sequence-diagrams creates its <text> with no fill attribute and its
note/actor boxes with the 3-char `#fff`, so the attribute-keyed rules above
miss them and the labels/boxes stay black-on-white on dark themes (#3047).
Recolor the sequence SVG by element, scoped to `svg.sequence` so mermaid /
vega / flowchart (which set fills via attributes) are untouched. */
figure.mu-diagram-block .mu-diagram-preview svg.sequence text {
fill: var(--editor-color);
}

figure.mu-diagram-block .mu-diagram-preview svg.sequence rect[fill='#fff'],
figure.mu-diagram-block .mu-diagram-preview svg.sequence path[fill='#fff'] {
fill: var(--editor-bg-color);
}

figure.mu-active.mu-math-block > div.mu-math-preview,
figure.mu-active.mu-diagram-block > div.mu-diagram-preview {
position: absolute;
Expand Down
2 changes: 1 addition & 1 deletion packages/muya/src/block/content/codeBlockContent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ class CodeBlockContent extends Content {
// transform alias to original language
const fullLengthLang = transformAliasToOrigin([lang])[0];
if (fullLengthLang && /\S/.test(text) && loadedLanguages.has(fullLengthLang)) {
const tokens = prism.tokenize(text, prism.languages[lang]);
const tokens = prism.tokenize(text, prism.languages[fullLengthLang]);
let offset = start.offset;
let code = '';
let needRender = false;
Expand Down
10 changes: 10 additions & 0 deletions packages/muya/src/ui/imageResizeBar/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type Format from '../../block/base/format';
import type { Muya } from '../../index';
import type { ImageToken } from '../../inlineRenderer/types';
import { autoUpdate } from '@floating-ui/dom';

import { isHTMLElement, isMouseEvent } from '../../utils';
import { findScrollContainer } from '../../utils/dom';
Expand All @@ -26,6 +27,8 @@ export class ImageResizeBar {
private _eventId: string[] = [];
private _lastScrollTop: number | null = null;
private _resizing: boolean = false;
// Stops the autoUpdate reposition loop set up in `_render`.
private _cleanup: (() => void) | null = null;
// A container for storing drag strips
private _container: HTMLDivElement;

Expand Down Expand Up @@ -89,6 +92,9 @@ export class ImageResizeBar {

this._createElements();
this._update();
// Reposition the handles whenever the image moves (window/ancestor
// resize, sidebar toggle, scroll), so they stay attached to it (#2939).
this._cleanup = autoUpdate(this._reference!, this._container, () => this._update());
eventCenter.emit('muya-float', this, true);
}

Expand Down Expand Up @@ -202,6 +208,8 @@ export class ImageResizeBar {

hide() {
const { eventCenter } = this.muya;
this._cleanup?.();
this._cleanup = null;
const circles = this._container.querySelectorAll('.bar');
Array.from(circles).forEach(c => c.remove());
this._status = false;
Expand All @@ -211,6 +219,8 @@ export class ImageResizeBar {
// Remove the `.mu-transformer` container appended to document.body in the
// constructor; invoked by `Muya.destroy()` so it is not leaked (#3315).
destroy() {
this._cleanup?.();
this._cleanup = null;
this._container.remove();
}
}
30 changes: 30 additions & 0 deletions packages/muya/src/utils/prism/__tests__/languageAlias.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest';
import prism, { loadLanguage, transformAliasToOrigin } from '../index';

describe('c++ language alias (#2910)', () => {
it('resolves `c++` to the cpp grammar', () => {
expect(transformAliasToOrigin(['c++'])[0]).toBe('cpp');
});

it('resolves `h++` to the cpp grammar', () => {
expect(transformAliasToOrigin(['h++'])[0]).toBe('cpp');
});

it('leaves the canonical `cpp` id untouched', () => {
expect(transformAliasToOrigin(['cpp'])[0]).toBe('cpp');
});

// The runtime grammar is registered under the resolved id (`cpp`), never the
// alias, so code paths must tokenize with `transformAliasToOrigin(lang)` —
// tokenizing with the raw `c++` grammar is `undefined` and crashes (the
// backspaceHandler regression this alias exposed).
it('exposes a runtime grammar for the resolved id but not the raw alias', async () => {

Check failure on line 22 in packages/muya/src/utils/prism/__tests__/languageAlias.spec.ts

View workflow job for this annotation

GitHub Actions / unit

src/utils/prism/__tests__/languageAlias.spec.ts > c++ language alias (#2910) > exposes a runtime grammar for the resolved id but not the raw alias

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ src/utils/prism/__tests__/languageAlias.spec.ts:22:5
await loadLanguage('c++');
const resolved = transformAliasToOrigin(['c++'])[0];
expect(resolved).toBe('cpp');
expect(prism.languages[resolved]).toBeTruthy();
expect(prism.languages['c++']).toBeUndefined();
expect(() => prism.tokenize('int main(){}', prism.languages[resolved])).not.toThrow();
});
});
Loading
Loading