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
@@ -7,7 +7,7 @@ import { effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { type TocContext, tocContext } from '../config';
import { getNotesFromDoc } from '../utils/query';
import { getNotesFromStore } from '../utils/query';
import * as styles from './outline-notice.css';
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
@@ -31,7 +31,7 @@ export class OutlineNotice extends SignalWatcher(
}
const shouldShowNotice =
getNotesFromDoc(this._context.editor$.value.store, [
getNotesFromStore(this._context.editor$.value.store, [
NoteDisplayMode.DocOnly,
]).length > 0;
@@ -35,7 +35,7 @@ import type {
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import {
getHeadingBlocksFromDoc,
getNotesFromDoc,
getNotesFromStore,
isHeadingBlock,
} from '../utils/query';
import {
@@ -91,7 +91,7 @@ export class OutlinePanelBody extends SignalWatcher(
return this._context.editor$.value;
}
private get doc() {
private get store() {
return this.editor.store;
}
@@ -154,11 +154,11 @@ export class OutlinePanelBody extends SignalWatcher(
}
private _moveSelectedNotes(insertIndex: number) {
if (!this.doc.root) return;
if (!this.store.root) return;
const pageVisibleNotes = this._pageVisibleNotes$.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>();
children.forEach((block, index) => {
@@ -189,14 +189,14 @@ export class OutlinePanelBody extends SignalWatcher(
const newChildren = [...leftPart, ...selected, ...rightPart];
this.doc.updateBlock(this.doc.root, {
this.store.updateBlock(this.store.root, {
children: newChildren,
});
}
private async _scrollToBlock(blockId: string) {
// if focus title
if (blockId === this.doc.root?.id) {
if (blockId === this.store.root?.id) {
this.editor.std.selection.setGroup('note', []);
this.editor.std.event.active = false;
focusTitle(this.editor);
@@ -221,7 +221,7 @@ export class OutlinePanelBody extends SignalWatcher(
const { selected, id, multiselect } = e.detail;
const gfx = this.editor.std.get(GfxControllerIdentifier);
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;
// map from signal to value
@@ -302,12 +302,12 @@ export class OutlinePanelBody extends SignalWatcher(
return hasHeadings || this._context.enableSorting$.value;
};
this._pageVisibleNotes$.value = getNotesFromDoc(this.doc, [
this._pageVisibleNotes$.value = getNotesFromStore(this.store, [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
]).filter(isRenderableNote);
this._edgelessOnlyNotes$.value = getNotesFromDoc(this.doc, [
this._edgelessOnlyNotes$.value = getNotesFromStore(this.store, [
NoteDisplayMode.EdgelessOnly,
]).filter(isRenderableNote);
})
@@ -379,23 +379,23 @@ export class OutlinePanelBody extends SignalWatcher(
}
private _renderDocTitle() {
if (!this.doc.root) return nothing;
if (!this.store.root) return nothing;
const hasNotEmptyHeadings =
getHeadingBlocksFromDoc(
this.doc,
this.store,
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless],
true
).length > 0;
if (!hasNotEmptyHeadings) return nothing;
const rootId = this.doc.root.id;
const rootId = this.store.root.id;
const active = rootId === this._activeHeadingId$.value;
return html`<affine-outline-block-preview
class=${classMap({ active: active })}
.block=${this.doc.root}
.block=${this.store.root}
@click=${() => {
this._scrollToBlock(rootId).catch(console.error);
}}
@@ -200,6 +200,7 @@ export class OutlineViewer extends SignalWatcher(
)
);
// title update
this.disposables.add(
this.editor.store.workspace.meta.docMetaUpdated.subscribe(() => {
this.requestUpdate();
@@ -9,15 +9,15 @@ import type { BlockModel, Store } from '@blocksuite/store';
import { headingKeys } from '../config.js';
export function getNotesFromDoc(
doc: Store,
export function getNotesFromStore(
store: Store,
modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
]
) {
const rootModel = doc.root;
const rootModel = store.root;
if (!rootModel) return [];
const notes: NoteBlockModel[] = [];
@@ -59,7 +59,7 @@ export function getHeadingBlocksFromNote(
}
export function getHeadingBlocksFromDoc(
doc: Store,
store: Store,
modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
@@ -67,6 +67,6 @@ export function getHeadingBlocksFromDoc(
],
ignoreEmpty = false
) {
const notes = getNotesFromDoc(doc, modes);
const notes = getNotesFromStore(store, modes);
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
}
@@ -15,31 +15,22 @@ export const EditorOutlineViewer = ({
}) => {
const outlineViewerRef = useRef<OutlineViewer | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
if (outlineViewerRef.current === null) {
console.error('outline viewer should be initialized');
return;
const onRefChange = useCallback(
(container: HTMLDivElement | null) => {
if (container && editor) {
if (outlineViewerRef.current) {
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;
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;
}
if (!editor || !show) return null;
return <div className={styles.root} ref={onRefChange} />;
};
@@ -179,6 +179,25 @@ test('should hide edgeless-only note headings', async ({ page }) => {
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 ({
page,
}) => {