Skip to content

Commit ae2ae95

Browse files
committed
🐛 Keep database backlink locating within its own viewport #19174
1 parent ae9f3e1 commit ae2ae95

9 files changed

Lines changed: 147 additions & 39 deletions

File tree

app/src/assets/scss/business/_av.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
margin-top: 4px !important;
77
margin-bottom: 4px !important;
88

9+
&--backlink {
10+
max-height: min(400px, 60vh);
11+
overflow: auto;
12+
overscroll-behavior: contain;
13+
align-items: flex-start;
14+
}
15+
916
&:hover {
1017
.av__views .block__icon,
1118
.av__group-icon--hover {
Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import {setAVLocateRequest} from "./locate";
2-
import {getBacklinkScrollElement} from "./backlinkScroll";
2+
import {trimAVRows} from "./virtualScroll";
3+
import {stickyRow} from "./row";
4+
5+
const initialized = new WeakSet<HTMLElement>();
36

47
export interface IBacklinkAVTarget {
58
blockID: string;
@@ -14,7 +17,7 @@ export interface IBacklinkAVTarget {
1417
}
1518

1619
// 每个反链副本独立定位,避免同 ID 的正文或其他反链数据库接收到定位请求。
17-
export const prepareBacklinkAV = (element: HTMLElement, targets: IBacklinkAVTarget[], scrollState?: {pending: boolean}) => {
20+
export const prepareBacklinkAV = (element: HTMLElement, targets: IBacklinkAVTarget[]) => {
1821
const databases = element.matches(".av[data-node-id]") ? [element] :
1922
Array.from(element.querySelectorAll<HTMLElement>(".av[data-node-id]"));
2023
databases.forEach(database => {
@@ -23,20 +26,24 @@ export const prepareBacklinkAV = (element: HTMLElement, targets: IBacklinkAVTarg
2326
return;
2427
}
2528
const match = target.matches[0];
29+
database.classList.add("av--backlink");
30+
if (!initialized.has(database)) {
31+
initialized.add(database);
32+
database.addEventListener("scroll", () => {
33+
stickyRow(database, database, "all");
34+
trimAVRows(database, database.getBoundingClientRect());
35+
}, {passive: true});
36+
}
2637
database.querySelectorAll(".def--mark").forEach(item => item.classList.remove("def--mark"));
27-
// 首次展开仅滚动到首个数据库命中,刷新和其他副本保留阅读位置
38+
// 每个数据库副本独立展示命中条目,不移动外层阅读位置
2839
setAVLocateRequest(database, {
2940
itemID: match.itemID,
3041
keyID: match.keyID,
3142
defIDs: match.defIDs,
3243
select: false,
3344
highlight: true,
3445
persistView: false,
35-
scroll: scrollState?.pending === true,
36-
scrollElement: getBacklinkScrollElement(database),
46+
scroll: true,
3747
});
38-
if (scrollState) {
39-
scrollState.pending = false;
40-
}
4148
});
4249
};

app/src/protyle/render/av/backlinkScroll.test.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import * as assert from "node:assert/strict";
22
import {describe, it} from "node:test";
3-
import {getBacklinkScrollElement, revealBacklinkReference} from "./backlinkScroll";
3+
import {getBacklinkScrollElement, revealBacklinkReference, scrollBacklinkTarget} from "./backlinkScroll";
44

55
const element = (classes: string[], parent?: HTMLElement): HTMLElement => {
66
const node = {
77
parentElement: parent,
88
classList: {contains: (name: string) => classes.includes(name)},
9+
contains(target: HTMLElement) {
10+
let current = target;
11+
while (current) {
12+
if (current === this as unknown as HTMLElement) {
13+
return true;
14+
}
15+
current = current.parentElement;
16+
}
17+
return false;
18+
},
919
closest(selector: string) {
1020
let current = this as unknown as HTMLElement;
1121
while (current) {
@@ -21,26 +31,55 @@ const element = (classes: string[], parent?: HTMLElement): HTMLElement => {
2131
};
2232

2333
describe("database backlink scrolling", () => {
24-
it("scrolls the dock list instead of the expanded inner editor", () => {
34+
it("uses the database viewport inside the dock without selecting the list", () => {
2535
const panel = element(["sy__backlink"]);
2636
const list = element(["backlinkList"], panel);
2737
const innerEditor = element(["protyle-content"], list);
28-
assert.equal(getBacklinkScrollElement(element(["av"], innerEditor)), list);
38+
const database = element(["av", "av--backlink"], innerEditor);
39+
assert.equal(getBacklinkScrollElement(database), database);
40+
assert.equal(getBacklinkScrollElement(element([], database)), database);
2941
});
3042

31-
it("scrolls the owning document for bottom backlinks", () => {
43+
it("uses the database viewport in bottom backlinks without selecting the owning document", () => {
3244
const ownerEditor = element(["protyle-content"]);
3345
const panel = element(["sy__backlink", "sy__backlink--bottom"], ownerEditor);
3446
const list = element(["backlinkList"], panel);
3547
const innerEditor = element(["protyle-content"], list);
36-
assert.equal(getBacklinkScrollElement(element(["av"], innerEditor)), ownerEditor);
48+
const database = element(["av", "av--backlink"], innerEditor);
49+
assert.equal(getBacklinkScrollElement(database), database);
3750
});
3851

3952
it("keeps ordinary database locating on the existing editor scroll path", () => {
4053
const editor = element(["protyle-content"]);
4154
assert.equal(getBacklinkScrollElement(element(["av"], editor)), undefined);
4255
});
4356

57+
it("locates each database independently while preserving the outer reading position", () => {
58+
const outer = element(["backlinkList"]);
59+
outer.scrollTop = 240;
60+
const first = element(["av", "av--backlink"], outer);
61+
const second = element(["av", "av--backlink"], outer);
62+
[first, second].forEach(database => {
63+
Object.assign(database, {
64+
scrollTop: 0, clientHeight: 400,
65+
getBoundingClientRect: () => ({top: 100}),
66+
});
67+
});
68+
const reference = element([], first);
69+
reference.getBoundingClientRect = () => ({top: 1300}) as DOMRect;
70+
assert.equal(scrollBacklinkTarget(first, reference), true);
71+
assert.equal(first.scrollTop, 1000);
72+
assert.equal(second.scrollTop, 0);
73+
assert.equal(outer.scrollTop, 240);
74+
assert.equal(scrollBacklinkTarget(second, reference), false);
75+
assert.equal(second.scrollTop, 0);
76+
const secondReference = element([], second);
77+
secondReference.getBoundingClientRect = reference.getBoundingClientRect;
78+
assert.equal(scrollBacklinkTarget(second, secondReference), true);
79+
assert.equal(second.scrollTop, 1000);
80+
assert.equal(outer.scrollTop, 240);
81+
});
82+
4483
it("reveals a clipped reference inside a long field without scrolling outside the cell", () => {
4584
const cell = element(["av__cell"]);
4685
Object.assign(cell, {

app/src/protyle/render/av/backlinkScroll.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
// 反链内嵌编辑器随内容撑开,定位需要滚动面板或所属正文
1+
// 定位仅滚动当前数据库副本,不查找反链面板或正文的滚动容器
22
export const getBacklinkScrollElement = (element: HTMLElement): HTMLElement | undefined => {
3-
const backlink = element.closest<HTMLElement>(".sy__backlink");
4-
if (!backlink) {
5-
return;
6-
}
7-
if (backlink.classList.contains("sy__backlink--bottom")) {
8-
return backlink.closest<HTMLElement>(".protyle-content") || undefined;
3+
return element.closest<HTMLElement>(".av--backlink") || undefined;
4+
};
5+
6+
export const scrollBacklinkTarget = (database: HTMLElement, target: HTMLElement) => {
7+
const scroller = getBacklinkScrollElement(database);
8+
if (!scroller || !scroller.contains(target)) {
9+
return false;
910
}
10-
return element.closest<HTMLElement>(".backlinkList, .backlinkMList") || undefined;
11+
const rect = scroller.getBoundingClientRect();
12+
scroller.scrollTop += target.getBoundingClientRect().top - rect.top - scroller.clientHeight / 2;
13+
return true;
1114
};
1215

13-
// 单行或限高字段会裁剪引用,先移动字段内部视口,再定位外层面板
16+
// 单行或限高字段会裁剪引用,仅移动单元格内部视口
1417
export const revealBacklinkReference = (reference: HTMLElement, cell: HTMLElement) => {
1518
let parent = reference.parentElement;
1619
while (parent && cell.contains(parent)) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as assert from "node:assert/strict";
2+
import {readFileSync} from "node:fs";
3+
import {test} from "node:test";
4+
import {runInNewContext} from "node:vm";
5+
import {ModuleKind, ScriptTarget, transpileModule} from "typescript";
6+
7+
const compiled = transpileModule(readFileSync("src/protyle/render/av/row.ts", "utf8"), {
8+
compilerOptions: {module: ModuleKind.CommonJS, target: ScriptTarget.ES2021},
9+
}).outputText;
10+
11+
test("backlink scrolling clears fixed rows and their spacers without calculating window positions", () => {
12+
const exports: {stickyRow?: (block: unknown, scroll: unknown, status: string) => void} = {};
13+
runInNewContext(compiled, {exports, require: () => ({})});
14+
const makeRow = (fixedClass: string, placeholderClass: string) => {
15+
const classes = new Set([fixedClass]);
16+
const placeholder = {
17+
removed: false,
18+
classList: {contains: (name: string) => name === placeholderClass},
19+
remove() { this.removed = true; },
20+
};
21+
return {
22+
classList: {contains: (name: string) => classes.has(name), remove: (name: string) => classes.delete(name)},
23+
style: {top: "120px", bottom: "10px", left: "30px", width: "400px", transform: "translateX(-100px)", clipPath: "inset(0 50px 0 0)"},
24+
nextElementSibling: placeholder,
25+
};
26+
};
27+
const views = makeRow("av__views--fixed", "av__views-placeholder");
28+
const header = makeRow("av__row--header--fixed", "av__row--header-placeholder");
29+
const footer = makeRow("av__row--footer--fixed", "av__row--footer--placeholder");
30+
const block = {
31+
classList: {contains: (name: string) => name === "av--backlink"},
32+
querySelector: () => views,
33+
querySelectorAll: (selector: string) => selector.includes("header") ? [header] : [footer],
34+
getBoundingClientRect: () => { throw new Error("Backlink fixed layout must not read window coordinates"); },
35+
};
36+
const outer = {scrollTop: 250};
37+
for (let i = 0; i < 4; i++) {
38+
exports.stickyRow(block, outer, "all");
39+
}
40+
[views, header, footer].forEach(row => {
41+
assert.equal(row.nextElementSibling.removed, true);
42+
assert.deepEqual(Object.values(row.style), ["", "", "", "", "", ""]);
43+
});
44+
assert.equal(outer.scrollTop, 250);
45+
});

app/src/protyle/render/av/locate.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ import {setAVCellAnchor, setAVItemAnchor} from "./rangeSelect";
88
import {updateAVRowSelect} from "./virtualScroll";
99
import {getAVLocateViewChange} from "./locateView";
1010
import {applyAVColorPalette, getAVCustomColors} from "./color";
11-
import {revealBacklinkReference} from "./backlinkScroll";
11+
import {getBacklinkScrollElement, revealBacklinkReference, scrollBacklinkTarget} from "./backlinkScroll";
1212

1313
export interface IAVLocateRequest {
1414
itemID: string;
1515
keyID?: string;
1616
defIDs?: string[];
1717
scroll?: boolean;
18-
scrollElement?: HTMLElement;
1918
groupID?: string;
2019
viewID?: string;
2120
select?: boolean;
@@ -402,18 +401,14 @@ export const finishAVLocate = (blockElement: HTMLElement, protyle: IProtyle, dat
402401
const refs = Array.from(cell.querySelectorAll<HTMLElement>('[data-type~="block-ref"][data-id]'))
403402
.filter(ref => request.defIDs?.includes(ref.dataset.id));
404403
refs.forEach(ref => ref.classList.add("def--mark"));
405-
if (refs[0] && request.scrollElement) {
404+
if (refs[0] && getBacklinkScrollElement(blockElement)) {
406405
revealBacklinkReference(refs[0], cell);
407406
}
408407
targetElement = refs[0] || cell;
409408
}
410409
}
411-
if (request.scroll !== false) {
412-
if (request.scrollElement) {
413-
const scroller = request.scrollElement;
414-
const rect = scroller.getBoundingClientRect();
415-
scroller.scrollTop += targetElement.getBoundingClientRect().top - rect.top - scroller.clientHeight / 2;
416-
} else if (!request.keyID && data.viewType === "table" && data.target.index === 0 && !data.target.groupID) {
410+
if (request.scroll !== false && !scrollBacklinkTarget(blockElement, targetElement)) {
411+
if (!request.keyID && data.viewType === "table" && data.target.index === 0 && !data.target.groupID) {
417412
const contentRect = protyle.contentElement.getBoundingClientRect();
418413
protyle.contentElement.scrollTop += blockElement.getBoundingClientRect().top - contentRect.top;
419414
} else {

app/src/protyle/render/av/row.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,8 @@ const syncFixedRowPos = (item: HTMLElement, bodyRect: DOMRect, scrollLeft: numbe
571571
};
572572

573573
export const stickyRow = (blockElement: HTMLElement, scrollElement: HTMLElement, status: "top" | "bottom" | "all") => {
574-
const skipFixed = hasTopClosestByAttribute(blockElement, "fold", "1");
574+
// 内部滚动的反链数据库不使用相对窗口固定的表头,避免占位和固定坐标干扰内部布局。
575+
const skipFixed = blockElement.classList.contains("av--backlink") || hasTopClosestByAttribute(blockElement, "fold", "1");
575576
if (skipFixed) {
576577
const viewsElement = blockElement.querySelector(".av__views") as HTMLElement;
577578
if (viewsElement) {
@@ -581,7 +582,7 @@ export const stickyRow = (blockElement: HTMLElement, scrollElement: HTMLElement,
581582
removeFixedRow(item, "av__row--header--fixed", "av__row--header-placeholder");
582583
});
583584
blockElement.querySelectorAll(".av__row--footer--fixed").forEach((item: HTMLElement) => {
584-
removeFixedRow(item, "av__row--footer--fixed", "av__row--footer-placeholder");
585+
removeFixedRow(item, "av__row--footer--fixed", "av__row--footer--placeholder");
585586
});
586587
return;
587588
}

app/src/protyle/render/av/virtualScroll.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {getRowHTML} from "./row";
33
import {IAVSelectedCell, reconcileAVSelectedItemIDs, restoreAVCellSelection} from "./selectionState";
44
import {getGroupTableViewportWindow} from "./groupTableVirtual";
55
import {renderAVRichTextElements} from "./richText";
6+
import {getBacklinkScrollElement} from "./backlinkScroll";
67

78
const BUFFER_RATIO = 1;
89

@@ -31,6 +32,7 @@ const blockDataStore = new WeakMap<HTMLElement, IAV>();
3132
const bodyStates = new WeakMap<HTMLElement, IBodyState>();
3233
const trimPending = new WeakSet<HTMLElement>();
3334
let lastScrollTop: number;
35+
const localScrollTops = new WeakMap<HTMLElement, number>();
3436

3537
// 测量 DOM 变更前后容器 scrollHeight 的差值,用于精确计算 gallery 多列网格中行移除/回填的实际高度(含 gap)
3638
const measureHeightDiff = (el: HTMLElement, mutate: () => void): number => {
@@ -69,6 +71,10 @@ const syncTableBottomSpacer = (bodyEl: HTMLElement, state: IBodyState, dataEnd:
6971
};
7072

7173
const doTrim = (blockElement: HTMLElement, elementRect: DOMRect): void => {
74+
const localScroller = getBacklinkScrollElement(blockElement);
75+
if (localScroller) {
76+
elementRect = localScroller.getBoundingClientRect();
77+
}
7278
const viewportHeight = elementRect.bottom - elementRect.top;
7379
const buffer = viewportHeight * BUFFER_RATIO;
7480
const topLimit = elementRect.top - buffer;
@@ -82,8 +88,14 @@ const doTrim = (blockElement: HTMLElement, elementRect: DOMRect): void => {
8288
return;
8389
}
8490
const protyle = stored.protyle;
85-
const isScrollingUp = lastScrollTop && lastScrollTop > protyle.contentElement.scrollTop;
86-
lastScrollTop = protyle.contentElement.scrollTop;
91+
const scrollTop = (localScroller || protyle.contentElement).scrollTop;
92+
const previousScrollTop = localScroller ? localScrollTops.get(localScroller) : lastScrollTop;
93+
const isScrollingUp = previousScrollTop !== undefined && previousScrollTop > scrollTop;
94+
if (localScroller) {
95+
localScrollTops.set(localScroller, scrollTop);
96+
} else {
97+
lastScrollTop = scrollTop;
98+
}
8799

88100
if ((blockRect.bottom < elementRect.top && !isScrollingUp) || (blockRect.top > elementRect.bottom && isScrollingUp)) {
89101
return;

app/src/protyle/wysiwyg/renderBacklink.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,15 @@ const createBacklinkDOMRecord = (item: IBacklinkData, index: number, id: string)
6969
};
7070
};
7171

72-
const renderBacklinkDOMNodes = (protyle: IProtyle, nodes: Node[], record: IBacklinkDOMRecord, scrollState: {pending: boolean}) => {
72+
const renderBacklinkDOMNodes = (protyle: IProtyle, nodes: Node[], record: IBacklinkDOMRecord) => {
7373
return Promise.all(nodes.map(async item => {
7474
if (!(item instanceof HTMLElement)) {
7575
return;
7676
}
7777
improveBreadcrumbAppearance(item);
7878
processRender(item);
7979
highlightRender(item);
80-
prepareBacklinkAV(item, record.targets, scrollState);
80+
prepareBacklinkAV(item, record.targets);
8181
await avRender(item, protyle);
8282
blockRender(protyle, item);
8383
}));
@@ -87,7 +87,6 @@ export const renderBacklink = (protyle: IProtyle, backlinkData: IBacklinkData[])
8787
protyle.block.showAll = true;
8888
const element = protyle.wysiwyg.element;
8989
let records = backlinkDOMRecords.get(protyle);
90-
const scrollState = {pending: !records || records.size === 0};
9190
if (!records) {
9291
records = new Map<string, IBacklinkDOMRecord>();
9392
backlinkDOMRecords.set(protyle, records);
@@ -146,7 +145,7 @@ export const renderBacklink = (protyle: IProtyle, backlinkData: IBacklinkData[])
146145
});
147146
const applyPromises: Promise<void>[] = [];
148147
changedNodes.forEach(({nodes, record}) => {
149-
applyPromises.push(renderBacklinkDOMNodes(protyle, nodes, record, scrollState).then(async () => {
148+
applyPromises.push(renderBacklinkDOMNodes(protyle, nodes, record).then(async () => {
150149
await Promise.all(nodes.map(node => {
151150
if (node instanceof HTMLElement) {
152151
return applyViewFoldStates(protyle, node);

0 commit comments

Comments
 (0)