fix(editor): toc viewer no update after delete heading in edgeless mode (#12411)

Close [BS-3494](https://linear.app/affine-design/issue/BS-3494/在白板删除note的标题,切回page模式,toc没更新)

Other changes: `doc` -> `store`

### Before

https://github.com/user-attachments/assets/ddce20b9-eda2-414b-9452-d8d54a811cf1

### After

https://github.com/user-attachments/assets/7124b8a1-9ab4-4e09-b0ff-7ea2cc9613c2

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Bug Fixes**
  - Improved synchronization of the outline viewer to ensure updates after editing headings in edgeless mode.
- **Tests**
  - Added an end-to-end test verifying that the outline viewer correctly reflects changes made in edgeless mode.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-05-21 06:03:48 +00:00
parent d70f09b498
commit 6430a9842f
6 changed files with 54 additions and 43 deletions

View File

@@ -7,7 +7,7 @@ import { effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { type TocContext, tocContext } from '../config'; import { type TocContext, tocContext } from '../config';
import { getNotesFromDoc } from '../utils/query'; import { getNotesFromStore } from '../utils/query';
import * as styles from './outline-notice.css'; import * as styles from './outline-notice.css';
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice'; export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
@@ -31,7 +31,7 @@ export class OutlineNotice extends SignalWatcher(
} }
const shouldShowNotice = const shouldShowNotice =
getNotesFromDoc(this._context.editor$.value.store, [ getNotesFromStore(this._context.editor$.value.store, [
NoteDisplayMode.DocOnly, NoteDisplayMode.DocOnly,
]).length > 0; ]).length > 0;

View File

@@ -35,7 +35,7 @@ import type {
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag'; import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import { import {
getHeadingBlocksFromDoc, getHeadingBlocksFromDoc,
getNotesFromDoc, getNotesFromStore,
isHeadingBlock, isHeadingBlock,
} from '../utils/query'; } from '../utils/query';
import { import {
@@ -91,7 +91,7 @@ export class OutlinePanelBody extends SignalWatcher(
return this._context.editor$.value; return this._context.editor$.value;
} }
private get doc() { private get store() {
return this.editor.store; return this.editor.store;
} }
@@ -154,11 +154,11 @@ export class OutlinePanelBody extends SignalWatcher(
} }
private _moveSelectedNotes(insertIndex: number) { private _moveSelectedNotes(insertIndex: number) {
if (!this.doc.root) return; if (!this.store.root) return;
const pageVisibleNotes = this._pageVisibleNotes$.peek(); const pageVisibleNotes = this._pageVisibleNotes$.peek();
const selected = this._allSelectedNotes$.peek(); const selected = this._allSelectedNotes$.peek();
const children = this.doc.root.children.slice(); const children = this.store.root.children.slice();
const noteIndex = new Map<NoteBlockModel, number>(); const noteIndex = new Map<NoteBlockModel, number>();
children.forEach((block, index) => { children.forEach((block, index) => {
@@ -189,14 +189,14 @@ export class OutlinePanelBody extends SignalWatcher(
const newChildren = [...leftPart, ...selected, ...rightPart]; const newChildren = [...leftPart, ...selected, ...rightPart];
this.doc.updateBlock(this.doc.root, { this.store.updateBlock(this.store.root, {
children: newChildren, children: newChildren,
}); });
} }
private async _scrollToBlock(blockId: string) { private async _scrollToBlock(blockId: string) {
// if focus title // if focus title
if (blockId === this.doc.root?.id) { if (blockId === this.store.root?.id) {
this.editor.std.selection.setGroup('note', []); this.editor.std.selection.setGroup('note', []);
this.editor.std.event.active = false; this.editor.std.event.active = false;
focusTitle(this.editor); focusTitle(this.editor);
@@ -221,7 +221,7 @@ export class OutlinePanelBody extends SignalWatcher(
const { selected, id, multiselect } = e.detail; const { selected, id, multiselect } = e.detail;
const gfx = this.editor.std.get(GfxControllerIdentifier); const gfx = this.editor.std.get(GfxControllerIdentifier);
const editorMode = this.editor.std.get(DocModeProvider).getEditorMode(); const editorMode = this.editor.std.get(DocModeProvider).getEditorMode();
const note = this.doc.getBlock(id)?.model; const note = this.store.getBlock(id)?.model;
if (!note || !matchModels(note, [NoteBlockModel])) return; if (!note || !matchModels(note, [NoteBlockModel])) return;
// map from signal to value // map from signal to value
@@ -302,12 +302,12 @@ export class OutlinePanelBody extends SignalWatcher(
return hasHeadings || this._context.enableSorting$.value; return hasHeadings || this._context.enableSorting$.value;
}; };
this._pageVisibleNotes$.value = getNotesFromDoc(this.doc, [ this._pageVisibleNotes$.value = getNotesFromStore(this.store, [
NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly, NoteDisplayMode.DocOnly,
]).filter(isRenderableNote); ]).filter(isRenderableNote);
this._edgelessOnlyNotes$.value = getNotesFromDoc(this.doc, [ this._edgelessOnlyNotes$.value = getNotesFromStore(this.store, [
NoteDisplayMode.EdgelessOnly, NoteDisplayMode.EdgelessOnly,
]).filter(isRenderableNote); ]).filter(isRenderableNote);
}) })
@@ -379,23 +379,23 @@ export class OutlinePanelBody extends SignalWatcher(
} }
private _renderDocTitle() { private _renderDocTitle() {
if (!this.doc.root) return nothing; if (!this.store.root) return nothing;
const hasNotEmptyHeadings = const hasNotEmptyHeadings =
getHeadingBlocksFromDoc( getHeadingBlocksFromDoc(
this.doc, this.store,
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless], [NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless],
true true
).length > 0; ).length > 0;
if (!hasNotEmptyHeadings) return nothing; if (!hasNotEmptyHeadings) return nothing;
const rootId = this.doc.root.id; const rootId = this.store.root.id;
const active = rootId === this._activeHeadingId$.value; const active = rootId === this._activeHeadingId$.value;
return html`<affine-outline-block-preview return html`<affine-outline-block-preview
class=${classMap({ active: active })} class=${classMap({ active: active })}
.block=${this.doc.root} .block=${this.store.root}
@click=${() => { @click=${() => {
this._scrollToBlock(rootId).catch(console.error); this._scrollToBlock(rootId).catch(console.error);
}} }}

View File

@@ -200,6 +200,7 @@ export class OutlineViewer extends SignalWatcher(
) )
); );
// title update
this.disposables.add( this.disposables.add(
this.editor.store.workspace.meta.docMetaUpdated.subscribe(() => { this.editor.store.workspace.meta.docMetaUpdated.subscribe(() => {
this.requestUpdate(); this.requestUpdate();

View File

@@ -9,15 +9,15 @@ import type { BlockModel, Store } from '@blocksuite/store';
import { headingKeys } from '../config.js'; import { headingKeys } from '../config.js';
export function getNotesFromDoc( export function getNotesFromStore(
doc: Store, store: Store,
modes: NoteDisplayMode[] = [ modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly, NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly, NoteDisplayMode.EdgelessOnly,
] ]
) { ) {
const rootModel = doc.root; const rootModel = store.root;
if (!rootModel) return []; if (!rootModel) return [];
const notes: NoteBlockModel[] = []; const notes: NoteBlockModel[] = [];
@@ -59,7 +59,7 @@ export function getHeadingBlocksFromNote(
} }
export function getHeadingBlocksFromDoc( export function getHeadingBlocksFromDoc(
doc: Store, store: Store,
modes: NoteDisplayMode[] = [ modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly, NoteDisplayMode.DocOnly,
@@ -67,6 +67,6 @@ export function getHeadingBlocksFromDoc(
], ],
ignoreEmpty = false ignoreEmpty = false
) { ) {
const notes = getNotesFromDoc(doc, modes); const notes = getNotesFromStore(store, modes);
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat(); return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
} }

View File

@@ -15,31 +15,22 @@ export const EditorOutlineViewer = ({
}) => { }) => {
const outlineViewerRef = useRef<OutlineViewer | null>(null); const outlineViewerRef = useRef<OutlineViewer | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => { const onRefChange = useCallback(
if (container) { (container: HTMLDivElement | null) => {
if (outlineViewerRef.current === null) { if (container && editor) {
console.error('outline viewer should be initialized'); if (outlineViewerRef.current) {
return; outlineViewerRef.current.remove();
}
outlineViewerRef.current = new OutlineViewer();
outlineViewerRef.current.editor = editor;
outlineViewerRef.current.toggleOutlinePanel = openOutlinePanel ?? null;
container.append(outlineViewerRef.current);
} }
},
[editor, openOutlinePanel]
);
container.append(outlineViewerRef.current); if (!editor || !show) return null;
}
}, []);
if (!editor || !show) return;
if (!outlineViewerRef.current) {
outlineViewerRef.current = new OutlineViewer();
}
if (outlineViewerRef.current.editor !== editor) {
outlineViewerRef.current.editor = editor;
}
if (
outlineViewerRef.current.toggleOutlinePanel !== openOutlinePanel &&
openOutlinePanel
) {
outlineViewerRef.current.toggleOutlinePanel = openOutlinePanel;
}
return <div className={styles.root} ref={onRefChange} />; return <div className={styles.root} ref={onRefChange} />;
}; };

View File

@@ -179,6 +179,25 @@ test('should hide edgeless-only note headings', async ({ page }) => {
await expect(h1InPanel).toContainText(['Heading 1']); await expect(h1InPanel).toContainText(['Heading 1']);
}); });
test('outline viewer should update after change heading in edgeless mode', async ({
page,
}) => {
await createTitle(page);
await pressEnter(page);
await type(page, '# ');
await type(page, 'Heading 1');
await clickEdgelessModeButton(page);
const note = page.locator('affine-edgeless-note');
await note.dblclick();
await type(page, '# New Heading');
await clickPageModeButton(page);
const indicators = getIndicators(page);
await expect(indicators).toHaveCount(3);
});
test('outline viewer should be useable in doc peek preview', async ({ test('outline viewer should be useable in doc peek preview', async ({
page, page,
}) => { }) => {