Skip to content

Commit b5556f1

Browse files
authored
fix(editor): preserve expanded headings in readonly mode (#15549)
## Description Closes #13579. On readonly shared pages, selecting text under a heading persisted as collapsed caused reactive selection handling to restore the persisted collapsed value over the viewer-local expanded state. This closed the heading again and prevented selecting or copying its revealed content. This change: - separates persisted collapsed-state synchronization from selection cleanup; - makes the readonly-local collapsed state reactive for both BlockSuite effects and Lit rendering; - preserves selection and copying while a readonly heading is locally expanded; - still clears hidden text selection when the heading is collapsed again; - adds a Playwright regression covering collapse → readonly → expand → drag-select → copy → re-collapse. ## Checklist - [x] I have signed the [AFFiNE Contributor License Agreement](https://cla-assistant.io/toeverything/AFFiNE) — required before merge; the `license/cla` check must be green ([how it works](https://github.com/toeverything/AFFiNE/blob/canary/docs/BUILDING.md#sign-the-cla-first)) - [x] The PR targets the `canary` branch and its title follows [Conventional Commits](https://www.conventionalcommits.org/) - [x] Tests are added or updated where it makes sense - [x] `yarn lint` and `yarn typecheck` pass locally ## Testing - Focused Chromium regression: 1 passed - Adjacent readonly paragraph scenarios: 3 passed - Targeted `oxfmt --check`: passed - Targeted `oxlint --deny-warnings`: passed - `git diff --check`: passed `yarn typecheck` could not be confirmed in this checkout because required generated workspace declaration outputs are missing. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved collapsed heading behavior in read-only mode. * Preserved local heading expansion and text selection when copying content and collapsing the heading. * Ensured local expansion state resets correctly when switching out of and back into read-only mode. * **Tests** * Added coverage for read-only heading expansion, keyboard copying, clipboard access, mode switching, and text selection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 8c59371 commit b5556f1

2 files changed

Lines changed: 93 additions & 8 deletions

File tree

blocksuite/affine/blocks/paragraph/src/paragraph-block.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
} from '@blocksuite/std/inline';
2626
import { computed, effect, signal } from '@preact/signals-core';
2727
import { html, nothing, type TemplateResult } from 'lit';
28-
import { query, state } from 'lit/decorators.js';
28+
import { query } from 'lit/decorators.js';
2929
import { classMap } from 'lit/directives/class-map.js';
3030
import { repeat } from 'lit/directives/repeat.js';
3131
import { styleMap } from 'lit/directives/style-map.js';
@@ -48,6 +48,8 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
4848

4949
private readonly _displayPlaceholder = signal(false);
5050

51+
private readonly _readonlyCollapsed = signal(false);
52+
5153
private _inlineRangeProvider: InlineRangeProvider | null = null;
5254

5355
private readonly _isInDatabase = () => {
@@ -67,6 +69,11 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
6769
?.getPlaceholder(this.model);
6870
}
6971

72+
private _setReadonlyCollapsed(collapsed: boolean) {
73+
this._readonlyCollapsed.value = collapsed;
74+
this.requestUpdate();
75+
}
76+
7077
get citationService() {
7178
return this.std.get(CitationProvider);
7279
}
@@ -185,9 +192,17 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
185192

186193
this.disposables.add(
187194
effect(() => {
188-
const collapsed = this.model.props.collapsed$.value;
189-
this._readonlyCollapsed = collapsed;
195+
if (this.store.readonly$.value) {
196+
this._setReadonlyCollapsed(this.model.props.collapsed$.value);
197+
}
198+
})
199+
);
190200

201+
this.disposables.add(
202+
effect(() => {
203+
const collapsed = this.store.readonly
204+
? this._readonlyCollapsed.value
205+
: this.model.props.collapsed$.value;
191206
// reset text selection when selected block is collapsed
192207
if (this.model.props.type$.value.startsWith('h') && collapsed) {
193208
const collapsedSiblings = this.collapsedSiblings;
@@ -244,7 +259,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
244259

245260
const { type$ } = this.model.props;
246261
const collapsed = this.store.readonly
247-
? this._readonlyCollapsed
262+
? this._readonlyCollapsed.value
248263
: this.model.props.collapsed;
249264
const collapsedSiblings = this.collapsedSiblings;
250265

@@ -324,7 +339,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
324339
.controls=${childrenId}
325340
.updateCollapsed=${(value: boolean) => {
326341
if (this.store.readonly) {
327-
this._readonlyCollapsed = value;
342+
this._setReadonlyCollapsed(value);
328343
} else {
329344
this.store.captureSync();
330345
this.store.updateBlock(this.model, {
@@ -380,9 +395,6 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
380395
`;
381396
}
382397

383-
@state()
384-
private accessor _readonlyCollapsed = false;
385-
386398
@query('rich-text')
387399
private accessor _richTextElement: RichText | null = null;
388400

tests/blocksuite/e2e/paragraph.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { expect } from '@playwright/test';
33

44
import {
55
captureHistory,
6+
copyByKeyboard,
67
dragBetweenIndices,
78
dragOverTitle,
89
enterPlaygroundRoom,
910
focusRichText,
1011
focusRichTextEnd,
1112
focusTitle,
1213
getBlockIds,
14+
getClipboardText,
1315
getIndexCoordinate,
1416
getInlineSelectionIndex,
1517
getPageSnapshot,
@@ -35,6 +37,7 @@ import {
3537
setInlineRangeInInlineEditor,
3638
setSelection,
3739
SHORT_KEY,
40+
switchEditorMode,
3841
switchReadonly,
3942
type,
4043
undoByClick,
@@ -56,6 +59,7 @@ import {
5659
assertDocTitleFocus,
5760
assertRichTextInlineRange,
5861
assertRichTexts,
62+
assertTextSelection,
5963
assertTitle,
6064
} from './utils/asserts.js';
6165
import { test } from './utils/playwright.js';
@@ -1329,6 +1333,75 @@ test('select divider using delete keyboard from prev/next paragraph', async ({
13291333
});
13301334

13311335
test.describe('readonly mode', () => {
1336+
test('heading restores persisted collapse state after readonly mode is re-entered', async ({
1337+
page,
1338+
}) => {
1339+
// Given a persisted collapsed heading that a readonly viewer expands locally
1340+
await enterPlaygroundRoom(page);
1341+
await initEmptyEdgelessState(page);
1342+
await focusRichText(page);
1343+
await type(page, 'Heading');
1344+
await updateBlockType(page, 'affine:paragraph', 'h2');
1345+
await pressEnter(page);
1346+
await type(page, 'Shared content');
1347+
1348+
const content = page.locator('affine-paragraph').nth(1);
1349+
await page.getByRole('button', { name: 'Collapse content' }).click();
1350+
await switchReadonly(page);
1351+
await page.getByRole('button', { name: 'Expand content' }).click();
1352+
await expect(content).toBeVisible();
1353+
1354+
// When the document leaves and re-enters readonly mode
1355+
await switchReadonly(page, false);
1356+
await expect(content).not.toBeVisible();
1357+
await switchReadonly(page);
1358+
1359+
// Then the viewer-local state is reset from the persisted collapse state
1360+
await expect(content).not.toBeVisible();
1361+
});
1362+
1363+
test('expanded heading stays open after selecting shared content', async ({
1364+
page,
1365+
}) => {
1366+
// Given a heading persisted as collapsed before the document becomes readonly
1367+
await enterPlaygroundRoom(page);
1368+
await initEmptyEdgelessState(page);
1369+
await focusRichText(page);
1370+
await type(page, 'Heading');
1371+
await updateBlockType(page, 'affine:paragraph', 'h2');
1372+
await pressEnter(page);
1373+
await type(page, 'Shared content');
1374+
1375+
const content = page.locator('affine-paragraph').nth(1);
1376+
await page.getByRole('button', { name: 'Collapse content' }).click();
1377+
await expect(content).not.toBeVisible();
1378+
1379+
await switchReadonly(page);
1380+
await switchEditorMode(page);
1381+
await switchEditorMode(page);
1382+
1383+
// When a reader expands the heading and selects its revealed content
1384+
await page.getByRole('button', { name: 'Expand content' }).click();
1385+
await expect(content).toBeVisible();
1386+
await dragBetweenIndices(page, [1, 0], [1, 6]);
1387+
1388+
// Then the reader's local expansion and text selection remain intact
1389+
await expect(content).toBeVisible();
1390+
const contentId = await content.getAttribute('data-block-id');
1391+
expect(contentId).not.toBeNull();
1392+
await assertTextSelection(page, {
1393+
blockId: contentId ?? '',
1394+
index: 0,
1395+
length: 6,
1396+
});
1397+
await copyByKeyboard(page);
1398+
expect(await getClipboardText(page)).toBe('Shared');
1399+
1400+
await page.getByRole('button', { name: 'Collapse content' }).click();
1401+
await expect(content).not.toBeVisible();
1402+
await assertTextSelection(page);
1403+
});
1404+
13321405
test('should placeholder not show at readonly mode', async ({ page }) => {
13331406
await enterPlaygroundRoom(page);
13341407
await initEmptyParagraphState(page);

0 commit comments

Comments
 (0)