@formicoidea/labre-core 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_pkgs/affine-block-surface/index.d.ts +1 -0
- package/dist/_pkgs/affine-block-surface/index.js +1 -0
- package/dist/_pkgs/affine-shared/services/index.d.ts +1 -0
- package/dist/_pkgs/affine-shared/services/index.js +1 -0
- package/dist/_pkgs/affine-shared/services/thread-marks-service.d.ts +100 -0
- package/dist/_pkgs/affine-shared/services/thread-marks-service.js +20 -0
- package/dist/_pkgs/affine-widget-thread-marks/effects.d.ts +1 -0
- package/dist/_pkgs/affine-widget-thread-marks/effects.js +4 -0
- package/dist/_pkgs/affine-widget-thread-marks/excerpt.d.ts +23 -0
- package/dist/_pkgs/affine-widget-thread-marks/excerpt.js +76 -0
- package/dist/_pkgs/affine-widget-thread-marks/index.d.ts +6 -0
- package/dist/_pkgs/affine-widget-thread-marks/index.js +9 -0
- package/dist/_pkgs/affine-widget-thread-marks/text-range.d.ts +26 -0
- package/dist/_pkgs/affine-widget-thread-marks/text-range.js +59 -0
- package/dist/_pkgs/affine-widget-thread-marks/toolbar.d.ts +50 -0
- package/dist/_pkgs/affine-widget-thread-marks/toolbar.js +124 -0
- package/dist/_pkgs/affine-widget-thread-marks/translations.d.ts +9 -0
- package/dist/_pkgs/affine-widget-thread-marks/translations.js +11 -0
- package/dist/_pkgs/affine-widget-thread-marks/view.d.ts +12 -0
- package/dist/_pkgs/affine-widget-thread-marks/view.js +25 -0
- package/dist/_pkgs/affine-widget-thread-marks/widget.d.ts +80 -0
- package/dist/_pkgs/affine-widget-thread-marks/widget.js +325 -0
- package/dist/extensions/view.js +2 -0
- package/dist/host-panels.d.ts +3 -0
- package/dist/host-panels.js +12 -0
- package/dist/translations.js +2 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ export { SurfaceElementModel, SurfaceGroupLikeModel, } from './element-model/bas
|
|
|
4
4
|
export { CanvasElementType } from './element-model/index.js';
|
|
5
5
|
export { CanvasRenderer } from './renderer/canvas-renderer.js';
|
|
6
6
|
export { DomRenderer } from './renderer/dom-renderer.js';
|
|
7
|
+
export { isPainted } from './renderer/paint-predicate.js';
|
|
7
8
|
export { ElementHighlightOverlay } from './renderer/element-highlight-overlay.js';
|
|
8
9
|
export type { ElementRenderer } from './renderer/elements/index.js';
|
|
9
10
|
export * from './renderer/elements/type.js';
|
|
@@ -6,6 +6,7 @@ export { SurfaceElementModel, SurfaceGroupLikeModel, } from './element-model/bas
|
|
|
6
6
|
export { CanvasElementType } from './element-model/index.js';
|
|
7
7
|
export { CanvasRenderer } from './renderer/canvas-renderer.js';
|
|
8
8
|
export { DomRenderer } from './renderer/dom-renderer.js';
|
|
9
|
+
export { isPainted } from './renderer/paint-predicate.js';
|
|
9
10
|
export { ElementHighlightOverlay } from './renderer/element-highlight-overlay.js';
|
|
10
11
|
export * from './renderer/elements/type.js';
|
|
11
12
|
export { Overlay, OverlayIdentifier } from './renderer/overlay.js';
|
|
@@ -38,6 +38,7 @@ export * from './quick-search-service.js';
|
|
|
38
38
|
export * from './selection-pane-service.js';
|
|
39
39
|
export * from './telemetry-service/index.js';
|
|
40
40
|
export * from './theme-service.js';
|
|
41
|
+
export * from './thread-marks-service.js';
|
|
41
42
|
export * from './title-band-service.js';
|
|
42
43
|
export * from './toolbar-service/index.js';
|
|
43
44
|
export * from './translation-service/index.js';
|
|
@@ -38,6 +38,7 @@ export * from './quick-search-service.js';
|
|
|
38
38
|
export * from './selection-pane-service.js';
|
|
39
39
|
export * from './telemetry-service/index.js';
|
|
40
40
|
export * from './theme-service.js';
|
|
41
|
+
export * from './thread-marks-service.js';
|
|
41
42
|
export * from './title-band-service.js';
|
|
42
43
|
export * from './toolbar-service/index.js';
|
|
43
44
|
export * from './translation-service/index.js';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ExtensionType } from '../../store/index.js';
|
|
2
|
+
import type { ReadonlySignal } from '@preact/signals-core';
|
|
3
|
+
/**
|
|
4
|
+
* What a mark sits on (ADR 0038): a block, in either view, or a canvas element
|
|
5
|
+
* no block backs (a shape, a connector, a group, a framework board). The id is
|
|
6
|
+
* the one the library already gives it: a block id, an element id.
|
|
7
|
+
*/
|
|
8
|
+
export type ThreadMarkTargetType = 'block' | 'element';
|
|
9
|
+
/**
|
|
10
|
+
* A run of text inside one block (ADR 0038, amendment of 2026-10-15): two
|
|
11
|
+
* Yjs relative positions on the block's `Y.Text`, each encoded with
|
|
12
|
+
* `Y.encodeRelativePosition` then base64. Opaque to the host, which stores
|
|
13
|
+
* and returns the two strings as they are; only the library reads them.
|
|
14
|
+
*
|
|
15
|
+
* A relative position follows the text through local and remote edits and is
|
|
16
|
+
* computed by reading, never by writing: nothing lands in the document. When
|
|
17
|
+
* the text it points at is gone, the range no longer resolves and the library
|
|
18
|
+
* falls back to the block.
|
|
19
|
+
*/
|
|
20
|
+
export interface ThreadTextRange {
|
|
21
|
+
start: string;
|
|
22
|
+
end: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One marked target of the mounted document, as the host computes it. Ids and
|
|
26
|
+
* counters only: the library never learns what the host attaches to a target,
|
|
27
|
+
* who wrote it or what it says.
|
|
28
|
+
*
|
|
29
|
+
* `count` is how many the host has on that target (a mark at `0` or below is
|
|
30
|
+
* not drawn); `unread` turns the mark to the accent colour.
|
|
31
|
+
*/
|
|
32
|
+
export interface ThreadMark {
|
|
33
|
+
targetType: ThreadMarkTargetType;
|
|
34
|
+
targetId: string;
|
|
35
|
+
count: number;
|
|
36
|
+
unread: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The text the discussion is anchored to, for a `block` target (ignored on
|
|
39
|
+
* an `element`). Optional (ADR 0038 amendment): without it only the
|
|
40
|
+
* block's mark is drawn; with it the run is also highlighted in the page
|
|
41
|
+
* view, and a click on it opens the discussion. A range that no longer
|
|
42
|
+
* resolves is not drawn, and the block's mark stays.
|
|
43
|
+
*/
|
|
44
|
+
range?: ThreadTextRange;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What the library hands the host when someone clicks a mark or "Discuss"
|
|
48
|
+
* (`com.labre.thread.discuss`) in a block or element toolbar.
|
|
49
|
+
*
|
|
50
|
+
* `excerpt` is computed here because the library knows the content: the
|
|
51
|
+
* block's text (its descendants' for a container such as a note), a canvas
|
|
52
|
+
* element's label as the selection pane reads it, else the kind of target;
|
|
53
|
+
* for a text range, the text it covers.
|
|
54
|
+
* Whitespace is collapsed and it is cut to at most 280 characters, an
|
|
55
|
+
* ellipsis included (`THREAD_EXCERPT_MAX`). It is a snapshot for the host to
|
|
56
|
+
* store beside its anchor; the library keeps no copy.
|
|
57
|
+
*/
|
|
58
|
+
export interface ThreadOpenRequest {
|
|
59
|
+
docId: string;
|
|
60
|
+
targetType: ThreadMarkTargetType;
|
|
61
|
+
targetId: string;
|
|
62
|
+
excerpt: string;
|
|
63
|
+
/**
|
|
64
|
+
* Set when the discussion starts on a text selection, or on a highlighted
|
|
65
|
+
* range (ADR 0038 amendment): the block is still `targetId` and
|
|
66
|
+
* `excerpt` is the selected text itself. Absent for a whole block or an
|
|
67
|
+
* element.
|
|
68
|
+
*/
|
|
69
|
+
range?: ThreadTextRange;
|
|
70
|
+
}
|
|
71
|
+
/** The longest excerpt `openThread` is ever handed, in code points. */
|
|
72
|
+
export declare const THREAD_EXCERPT_MAX = 280;
|
|
73
|
+
/**
|
|
74
|
+
* The "has a thread" seam (ADR 0038): the host owns the discussions, the
|
|
75
|
+
* library marks what the host says is marked and asks the host to open one.
|
|
76
|
+
*
|
|
77
|
+
* Nothing is written to the document, ever: no prop, no id in the content, no
|
|
78
|
+
* write on load. That is why it is not `CommentProvider`, which writes
|
|
79
|
+
* `block.props.comments` and ignores canvas elements.
|
|
80
|
+
*
|
|
81
|
+
* Absent or `null`: no mark is drawn and no toolbar offers "Discuss".
|
|
82
|
+
*/
|
|
83
|
+
export interface ThreadMarksService {
|
|
84
|
+
/**
|
|
85
|
+
* The marks of the mounted document. Read reactively: a new array
|
|
86
|
+
* repaints the marks. An id the editor cannot find (a deleted block, an
|
|
87
|
+
* element of the other view) is ignored, never an error.
|
|
88
|
+
*/
|
|
89
|
+
marks$: ReadonlySignal<readonly ThreadMark[]>;
|
|
90
|
+
/** Open (or start) the discussion on a target. Writes nothing here. */
|
|
91
|
+
openThread(request: ThreadOpenRequest): void;
|
|
92
|
+
}
|
|
93
|
+
export declare const ThreadMarksProvider: import("../../global/di/types.js").ServiceIdentifier<ThreadMarksService> & (<U extends ThreadMarksService = ThreadMarksService>(variant: import("../../global/di/types.js").ServiceVariant) => import("../../global/di/types.js").ServiceIdentifier<U>);
|
|
94
|
+
/**
|
|
95
|
+
* Host seam: the host's thread marks, or `null` to say there are none.
|
|
96
|
+
*
|
|
97
|
+
* `di.override`, like every panel seam, so a later registration wins and
|
|
98
|
+
* registering twice never throws at boot.
|
|
99
|
+
*/
|
|
100
|
+
export declare function ThreadMarksExtension(service: ThreadMarksService | null): ExtensionType;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createIdentifier } from '../../global/di/index.js';
|
|
2
|
+
/** The longest excerpt `openThread` is ever handed, in code points. */
|
|
3
|
+
export const THREAD_EXCERPT_MAX = 280;
|
|
4
|
+
export const ThreadMarksProvider = createIdentifier('LabreThreadMarks');
|
|
5
|
+
/**
|
|
6
|
+
* Host seam: the host's thread marks, or `null` to say there are none.
|
|
7
|
+
*
|
|
8
|
+
* `di.override`, like every panel seam, so a later registration wins and
|
|
9
|
+
* registering twice never throws at boot.
|
|
10
|
+
*/
|
|
11
|
+
export function ThreadMarksExtension(service) {
|
|
12
|
+
return {
|
|
13
|
+
setup: di => {
|
|
14
|
+
// `null` rides through the factory on purpose: `getOptional` then
|
|
15
|
+
// reports the seam as absent, which is the whole "no mark, no button"
|
|
16
|
+
// story.
|
|
17
|
+
di.override(ThreadMarksProvider, () => service);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function effects(): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ThreadMarkTargetType, type ThreadTextRange } from '../affine-shared/services/index.js';
|
|
2
|
+
import type { BlockStdScope } from '../std/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* A block or a canvas element, by the id the library gave it, and for a
|
|
5
|
+
* block optionally a run of its text (ADR 0038, amendment of 2026-10-15).
|
|
6
|
+
*/
|
|
7
|
+
export interface ThreadTarget {
|
|
8
|
+
targetType: ThreadMarkTargetType;
|
|
9
|
+
targetId: string;
|
|
10
|
+
range?: ThreadTextRange;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Collapse whitespace and cut to {@link THREAD_EXCERPT_MAX} code points, the
|
|
14
|
+
* ellipsis included. Code points, not UTF-16 units, so an emoji is never cut
|
|
15
|
+
* in half and a host counting characters (Postgres `char_length`) agrees.
|
|
16
|
+
*/
|
|
17
|
+
export declare function truncateThreadExcerpt(raw: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* The excerpt handed to `openThread` (ADR 0038): what the target says, as a
|
|
20
|
+
* reader would recognise it, at most {@link THREAD_EXCERPT_MAX} characters.
|
|
21
|
+
* Read only: nothing is written, nothing is cached.
|
|
22
|
+
*/
|
|
23
|
+
export declare function threadExcerpt(std: BlockStdScope, target: ThreadTarget): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getSurfaceBlock } from '../affine-block-surface/index.js';
|
|
2
|
+
import { THREAD_EXCERPT_MAX, } from '../affine-shared/services/index.js';
|
|
3
|
+
import { selectionPaneRowLabel, selectionPaneTypeLabel, } from '../affine-widget-edgeless-toolbar/index.js';
|
|
4
|
+
import { GfxBlockElementModel } from '../std/gfx/index.js';
|
|
5
|
+
import { resolveThreadRange } from './text-range.js';
|
|
6
|
+
const ELLIPSIS = '…';
|
|
7
|
+
/**
|
|
8
|
+
* Collapse whitespace and cut to {@link THREAD_EXCERPT_MAX} code points, the
|
|
9
|
+
* ellipsis included. Code points, not UTF-16 units, so an emoji is never cut
|
|
10
|
+
* in half and a host counting characters (Postgres `char_length`) agrees.
|
|
11
|
+
*/
|
|
12
|
+
export function truncateThreadExcerpt(raw) {
|
|
13
|
+
const text = raw.replace(/\s+/g, ' ').trim();
|
|
14
|
+
const chars = Array.from(text);
|
|
15
|
+
if (chars.length <= THREAD_EXCERPT_MAX)
|
|
16
|
+
return text;
|
|
17
|
+
return (chars
|
|
18
|
+
.slice(0, THREAD_EXCERPT_MAX - 1)
|
|
19
|
+
.join('')
|
|
20
|
+
.trimEnd() + ELLIPSIS);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A block's own text, then its descendants' in document order (a note, a
|
|
24
|
+
* callout, a list item with children), until there is more than an excerpt
|
|
25
|
+
* can hold.
|
|
26
|
+
*/
|
|
27
|
+
function blockText(model) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
let length = 0;
|
|
30
|
+
const visit = (block) => {
|
|
31
|
+
if (length > THREAD_EXCERPT_MAX)
|
|
32
|
+
return;
|
|
33
|
+
const own = block.text?.toString() ?? '';
|
|
34
|
+
if (own.trim()) {
|
|
35
|
+
parts.push(own);
|
|
36
|
+
length += own.length + 1;
|
|
37
|
+
}
|
|
38
|
+
for (const child of block.children)
|
|
39
|
+
visit(child);
|
|
40
|
+
};
|
|
41
|
+
visit(model);
|
|
42
|
+
return parts.join(' ');
|
|
43
|
+
}
|
|
44
|
+
function rawExcerpt(std, target) {
|
|
45
|
+
if (target.targetType === 'block') {
|
|
46
|
+
const model = std.store.getModelById(target.targetId);
|
|
47
|
+
if (!model)
|
|
48
|
+
return '';
|
|
49
|
+
// A run of text reads as itself: the quote a reader selected. A range
|
|
50
|
+
// that no longer resolves falls back to the block, like its mark.
|
|
51
|
+
const yText = model.text?.yText;
|
|
52
|
+
const run = target.range && yText ? resolveThreadRange(yText, target.range) : null;
|
|
53
|
+
if (yText && run) {
|
|
54
|
+
return yText.toString().slice(run.index, run.index + run.length);
|
|
55
|
+
}
|
|
56
|
+
const text = blockText(model);
|
|
57
|
+
if (text.trim())
|
|
58
|
+
return text;
|
|
59
|
+
// A canvas block with no text reads as the selection pane names it: a
|
|
60
|
+
// frame by its title, an image or a note by its kind.
|
|
61
|
+
if (model instanceof GfxBlockElementModel) {
|
|
62
|
+
return selectionPaneRowLabel(std, model);
|
|
63
|
+
}
|
|
64
|
+
return selectionPaneTypeLabel(std, model.flavour);
|
|
65
|
+
}
|
|
66
|
+
const element = getSurfaceBlock(std.store)?.getElementById(target.targetId);
|
|
67
|
+
return element ? selectionPaneRowLabel(std, element) : '';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The excerpt handed to `openThread` (ADR 0038): what the target says, as a
|
|
71
|
+
* reader would recognise it, at most {@link THREAD_EXCERPT_MAX} characters.
|
|
72
|
+
* Read only: nothing is written, nothing is cached.
|
|
73
|
+
*/
|
|
74
|
+
export function threadExcerpt(std, target) {
|
|
75
|
+
return truncateThreadExcerpt(rawExcerpt(std, target));
|
|
76
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { WidgetViewExtension } from '../std/index.js';
|
|
2
|
+
import { literal, unsafeStatic } from 'lit/static-html.js';
|
|
3
|
+
import { AFFINE_THREAD_MARKS_WIDGET } from './widget.js';
|
|
4
|
+
export * from './excerpt.js';
|
|
5
|
+
export * from './text-range.js';
|
|
6
|
+
export * from './toolbar.js';
|
|
7
|
+
export * from './translations.js';
|
|
8
|
+
export * from './widget.js';
|
|
9
|
+
export const threadMarksWidget = WidgetViewExtension('affine:page', AFFINE_THREAD_MARKS_WIDGET, literal `${unsafeStatic(AFFINE_THREAD_MARKS_WIDGET)}`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ThreadTextRange } from '../affine-shared/services/index.js';
|
|
2
|
+
import * as Y from 'yjs';
|
|
3
|
+
/** Where a range sits in its block's text today, by index. */
|
|
4
|
+
export interface ResolvedThreadRange {
|
|
5
|
+
index: number;
|
|
6
|
+
length: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The anchor of `length` characters from `index` in `yText` (ADR 0038,
|
|
10
|
+
* amendment of 2026-10-15), or `null` for an empty run or one outside the
|
|
11
|
+
* text.
|
|
12
|
+
*
|
|
13
|
+
* `start` sticks to the first selected character (`assoc = 0`) and `end` to
|
|
14
|
+
* the last one (`assoc = -1`), so typing right after the run does not grow
|
|
15
|
+
* it. Creating a relative position only reads the item ids: nothing is
|
|
16
|
+
* written to the document (held by `text-range.unit.spec.ts`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function encodeThreadRange(yText: Y.Text, index: number, length: number): ThreadTextRange | null;
|
|
19
|
+
/**
|
|
20
|
+
* Where `range` sits in `yText` now, or `null` when it cannot say: the text
|
|
21
|
+
* is detached, a string is not a position, a position belongs to another
|
|
22
|
+
* text, or the run collapsed because its text was deleted. Never throws: a
|
|
23
|
+
* host's stored anchor may be anything, and an unresolvable range simply is
|
|
24
|
+
* not drawn.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveThreadRange(yText: Y.Text, range: ThreadTextRange): ResolvedThreadRange | null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
function toBase64(bytes) {
|
|
3
|
+
let binary = '';
|
|
4
|
+
for (const byte of bytes)
|
|
5
|
+
binary += String.fromCharCode(byte);
|
|
6
|
+
return btoa(binary);
|
|
7
|
+
}
|
|
8
|
+
function fromBase64(text) {
|
|
9
|
+
return Uint8Array.from(atob(text), char => char.charCodeAt(0));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The anchor of `length` characters from `index` in `yText` (ADR 0038,
|
|
13
|
+
* amendment of 2026-10-15), or `null` for an empty run or one outside the
|
|
14
|
+
* text.
|
|
15
|
+
*
|
|
16
|
+
* `start` sticks to the first selected character (`assoc = 0`) and `end` to
|
|
17
|
+
* the last one (`assoc = -1`), so typing right after the run does not grow
|
|
18
|
+
* it. Creating a relative position only reads the item ids: nothing is
|
|
19
|
+
* written to the document (held by `text-range.unit.spec.ts`).
|
|
20
|
+
*/
|
|
21
|
+
export function encodeThreadRange(yText, index, length) {
|
|
22
|
+
if (!(length > 0) || index < 0 || index + length > yText.length) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const start = Y.createRelativePositionFromTypeIndex(yText, index, 0);
|
|
26
|
+
const end = Y.createRelativePositionFromTypeIndex(yText, index + length, -1);
|
|
27
|
+
return {
|
|
28
|
+
start: toBase64(Y.encodeRelativePosition(start)),
|
|
29
|
+
end: toBase64(Y.encodeRelativePosition(end)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function absoluteIndex(encoded, yText, doc) {
|
|
33
|
+
const position = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(fromBase64(encoded)), doc);
|
|
34
|
+
// A position of another text (a host that mixed up two anchors) is not
|
|
35
|
+
// this block's run.
|
|
36
|
+
return position && position.type === yText ? position.index : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Where `range` sits in `yText` now, or `null` when it cannot say: the text
|
|
40
|
+
* is detached, a string is not a position, a position belongs to another
|
|
41
|
+
* text, or the run collapsed because its text was deleted. Never throws: a
|
|
42
|
+
* host's stored anchor may be anything, and an unresolvable range simply is
|
|
43
|
+
* not drawn.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveThreadRange(yText, range) {
|
|
46
|
+
const doc = yText.doc;
|
|
47
|
+
if (!doc)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const start = absoluteIndex(range.start, yText, doc);
|
|
51
|
+
const end = absoluteIndex(range.end, yText, doc);
|
|
52
|
+
if (start === null || end === null || end <= start)
|
|
53
|
+
return null;
|
|
54
|
+
return { index: start, length: end - start };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ToolbarContext } from '../affine-shared/services/index.js';
|
|
2
|
+
import { type BlockStdScope } from '../std/index.js';
|
|
3
|
+
import type { ExtensionType } from '../store/index.js';
|
|
4
|
+
import { type ThreadTarget } from './excerpt.js';
|
|
5
|
+
/**
|
|
6
|
+
* The ONE target a toolbar row speaks for, or `null` when it speaks for
|
|
7
|
+
* several (a discussion anchors to one block or one element, labre ADR-0048).
|
|
8
|
+
*
|
|
9
|
+
* - On the canvas, exactly one selected model: a canvas element (shape,
|
|
10
|
+
* connector, group, framework board) is an `element`, a canvas block (note,
|
|
11
|
+
* frame, image, embed) a `block`.
|
|
12
|
+
* - A text selection: the block it starts in, with the selected run of that
|
|
13
|
+
* block's text (ADR 0038, amendment of 2026-10-15).
|
|
14
|
+
* - One selected block, or the block a hover toolbar is drawn for.
|
|
15
|
+
*/
|
|
16
|
+
export declare function threadTargetOf(ctx: ToolbarContext): ThreadTarget | null;
|
|
17
|
+
/**
|
|
18
|
+
* Ask the host to open the discussion of `target`, with the excerpt the
|
|
19
|
+
* library computes. Nothing is written: no prop, no id in the document.
|
|
20
|
+
* Without the seam, nothing happens. `range` is handed over only when there
|
|
21
|
+
* is one, so a host comparing requests sees the first shape for a whole block.
|
|
22
|
+
*/
|
|
23
|
+
export declare function openThreadOn(std: BlockStdScope, target: ThreadTarget): void;
|
|
24
|
+
export declare const THREAD_DISCUSS_ACTION_ID = "g.thread-discuss";
|
|
25
|
+
/**
|
|
26
|
+
* "Discuss" in the toolbar of every block and every canvas element (ADR 0038).
|
|
27
|
+
* Shown only when the host registered the seam and the row speaks for one
|
|
28
|
+
* target. It is not AFFiNE's `blockCommentToolbarButton`: that one writes the
|
|
29
|
+
* comment id into the block.
|
|
30
|
+
*/
|
|
31
|
+
export declare const threadDiscussToolbarConfig: {
|
|
32
|
+
readonly when: (ctx: ToolbarContext) => boolean;
|
|
33
|
+
readonly actions: [{
|
|
34
|
+
readonly id: "g.thread-discuss";
|
|
35
|
+
readonly icon: import("lit-html").TemplateResult<1>;
|
|
36
|
+
readonly labelWording: import("../affine-shared/services/index.js").ChromeWording;
|
|
37
|
+
readonly tooltipWording: import("../affine-shared/services/index.js").ChromeWording;
|
|
38
|
+
readonly run: (ctx: ToolbarContext) => void;
|
|
39
|
+
}];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Registered on the two wildcard rows, each under an owner key: the bare
|
|
43
|
+
* `affine:surface:*` is the root's built-in canvas module, and an owner key
|
|
44
|
+
* never collides with a bare one. `affine:*` is merged into every row the
|
|
45
|
+
* toolbar draws; `affine:surface:*` names the canvas rows explicitly, so a
|
|
46
|
+
* row that stopped merging `affine:*` would still offer it. Where both apply,
|
|
47
|
+
* the toolbar merges entries by id, so the row shows "Discuss" once. A new
|
|
48
|
+
* block or framework inherits it by doing nothing.
|
|
49
|
+
*/
|
|
50
|
+
export declare const threadDiscussToolbarExtensions: ExtensionType[];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CommentIcon } from '@blocksuite/icons/lit';
|
|
2
|
+
import { ToolbarModuleExtension, toolbarModuleKey, ThreadMarksProvider, } from '../affine-shared/services/index.js';
|
|
3
|
+
import { BlockFlavourIdentifier, BlockSelection, TextSelection, } from '../std/index.js';
|
|
4
|
+
import { GfxPrimitiveElementModel } from '../std/gfx/index.js';
|
|
5
|
+
import { threadExcerpt } from './excerpt.js';
|
|
6
|
+
import { encodeThreadRange } from './text-range.js';
|
|
7
|
+
import { THREAD_DISCUSS } from './translations.js';
|
|
8
|
+
/** Containers a discussion never anchors to: the page and the canvas itself. */
|
|
9
|
+
const NOT_A_TARGET = new Set(['affine:page', 'affine:surface']);
|
|
10
|
+
function blockTarget(id, ctx) {
|
|
11
|
+
if (!id)
|
|
12
|
+
return null;
|
|
13
|
+
const model = ctx.store.getModelById(id);
|
|
14
|
+
if (!model || NOT_A_TARGET.has(model.flavour))
|
|
15
|
+
return null;
|
|
16
|
+
return { targetType: 'block', targetId: model.id };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The block a text selection starts in, with the selected run of it. A
|
|
20
|
+
* selection over several blocks anchors to the first one, on the part of it
|
|
21
|
+
* that is selected (ADR 0038, amendment of 2026-10-15). A run that cannot be
|
|
22
|
+
* encoded (an empty one, a block with no text) leaves the block alone.
|
|
23
|
+
*/
|
|
24
|
+
function textTarget(text, ctx) {
|
|
25
|
+
const target = blockTarget(text.from.blockId, ctx);
|
|
26
|
+
if (!target)
|
|
27
|
+
return null;
|
|
28
|
+
const yText = ctx.store.getModelById(target.targetId)?.text?.yText;
|
|
29
|
+
const range = yText
|
|
30
|
+
? encodeThreadRange(yText, text.from.index, text.from.length)
|
|
31
|
+
: null;
|
|
32
|
+
return range ? { ...target, range } : target;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The ONE target a toolbar row speaks for, or `null` when it speaks for
|
|
36
|
+
* several (a discussion anchors to one block or one element, labre ADR-0048).
|
|
37
|
+
*
|
|
38
|
+
* - On the canvas, exactly one selected model: a canvas element (shape,
|
|
39
|
+
* connector, group, framework board) is an `element`, a canvas block (note,
|
|
40
|
+
* frame, image, embed) a `block`.
|
|
41
|
+
* - A text selection: the block it starts in, with the selected run of that
|
|
42
|
+
* block's text (ADR 0038, amendment of 2026-10-15).
|
|
43
|
+
* - One selected block, or the block a hover toolbar is drawn for.
|
|
44
|
+
*/
|
|
45
|
+
export function threadTargetOf(ctx) {
|
|
46
|
+
if (ctx.hasSelectedSurfaceModels) {
|
|
47
|
+
const models = ctx.getSurfaceModels();
|
|
48
|
+
if (models.length !== 1)
|
|
49
|
+
return null;
|
|
50
|
+
const model = models[0];
|
|
51
|
+
if (!model)
|
|
52
|
+
return null;
|
|
53
|
+
return model instanceof GfxPrimitiveElementModel
|
|
54
|
+
? { targetType: 'element', targetId: model.id }
|
|
55
|
+
: blockTarget(model.id, ctx);
|
|
56
|
+
}
|
|
57
|
+
const text = ctx.selection.find(TextSelection);
|
|
58
|
+
if (text)
|
|
59
|
+
return textTarget(text, ctx);
|
|
60
|
+
if (ctx.selection.filter(BlockSelection).length > 1)
|
|
61
|
+
return null;
|
|
62
|
+
return blockTarget(ctx.getCurrentModelBy(BlockSelection)?.id, ctx);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ask the host to open the discussion of `target`, with the excerpt the
|
|
66
|
+
* library computes. Nothing is written: no prop, no id in the document.
|
|
67
|
+
* Without the seam, nothing happens. `range` is handed over only when there
|
|
68
|
+
* is one, so a host comparing requests sees the first shape for a whole block.
|
|
69
|
+
*/
|
|
70
|
+
export function openThreadOn(std, target) {
|
|
71
|
+
const service = std.getOptional(ThreadMarksProvider);
|
|
72
|
+
if (!service)
|
|
73
|
+
return;
|
|
74
|
+
service.openThread({
|
|
75
|
+
docId: std.store.id,
|
|
76
|
+
targetType: target.targetType,
|
|
77
|
+
targetId: target.targetId,
|
|
78
|
+
excerpt: threadExcerpt(std, target),
|
|
79
|
+
...(target.range ? { range: target.range } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export const THREAD_DISCUSS_ACTION_ID = 'g.thread-discuss';
|
|
83
|
+
/**
|
|
84
|
+
* "Discuss" in the toolbar of every block and every canvas element (ADR 0038).
|
|
85
|
+
* Shown only when the host registered the seam and the row speaks for one
|
|
86
|
+
* target. It is not AFFiNE's `blockCommentToolbarButton`: that one writes the
|
|
87
|
+
* comment id into the block.
|
|
88
|
+
*/
|
|
89
|
+
export const threadDiscussToolbarConfig = {
|
|
90
|
+
when: ctx => Boolean(ctx.std.getOptional(ThreadMarksProvider)) &&
|
|
91
|
+
threadTargetOf(ctx) !== null,
|
|
92
|
+
actions: [
|
|
93
|
+
{
|
|
94
|
+
id: THREAD_DISCUSS_ACTION_ID,
|
|
95
|
+
icon: CommentIcon(),
|
|
96
|
+
labelWording: THREAD_DISCUSS,
|
|
97
|
+
tooltipWording: THREAD_DISCUSS,
|
|
98
|
+
run: ctx => {
|
|
99
|
+
const target = threadTargetOf(ctx);
|
|
100
|
+
if (target)
|
|
101
|
+
openThreadOn(ctx.std, target);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Registered on the two wildcard rows, each under an owner key: the bare
|
|
108
|
+
* `affine:surface:*` is the root's built-in canvas module, and an owner key
|
|
109
|
+
* never collides with a bare one. `affine:*` is merged into every row the
|
|
110
|
+
* toolbar draws; `affine:surface:*` names the canvas rows explicitly, so a
|
|
111
|
+
* row that stopped merging `affine:*` would still offer it. Where both apply,
|
|
112
|
+
* the toolbar merges entries by id, so the row shows "Discuss" once. A new
|
|
113
|
+
* block or framework inherits it by doing nothing.
|
|
114
|
+
*/
|
|
115
|
+
export const threadDiscussToolbarExtensions = [
|
|
116
|
+
ToolbarModuleExtension({
|
|
117
|
+
id: BlockFlavourIdentifier(toolbarModuleKey('affine:*', 'thread')),
|
|
118
|
+
config: threadDiscussToolbarConfig,
|
|
119
|
+
}),
|
|
120
|
+
ToolbarModuleExtension({
|
|
121
|
+
id: BlockFlavourIdentifier(toolbarModuleKey('affine:surface:*', 'thread')),
|
|
122
|
+
config: threadDiscussToolbarConfig,
|
|
123
|
+
}),
|
|
124
|
+
];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ChromeWording } from '../affine-shared/services/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* "Discuss": the toolbar action that asks the host to open the discussion of
|
|
4
|
+
* a block or a canvas element, and the accessible name of the mark drawn on
|
|
5
|
+
* what already has one (ADR 0038).
|
|
6
|
+
*/
|
|
7
|
+
export declare const THREAD_DISCUSS: ChromeWording;
|
|
8
|
+
/** Every wording this package declares, in the order it renders them. */
|
|
9
|
+
export declare const THREAD_MARKS_WORDINGS: readonly ChromeWording[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Discuss": the toolbar action that asks the host to open the discussion of
|
|
3
|
+
* a block or a canvas element, and the accessible name of the mark drawn on
|
|
4
|
+
* what already has one (ADR 0038).
|
|
5
|
+
*/
|
|
6
|
+
export const THREAD_DISCUSS = [
|
|
7
|
+
'com.labre.thread.discuss',
|
|
8
|
+
'Discuss',
|
|
9
|
+
];
|
|
10
|
+
/** Every wording this package declares, in the order it renders them. */
|
|
11
|
+
export const THREAD_MARKS_WORDINGS = [THREAD_DISCUSS];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ViewExtensionContext, ViewExtensionProvider } from '../affine-ext-loader/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The "has a thread" seam's view side (ADR 0038): the marks widget and the
|
|
4
|
+
* "Discuss" toolbar module, in both views. Registered unconditionally: the
|
|
5
|
+
* seam, not a flag, decides whether anything shows (no flag gates a
|
|
6
|
+
* discussion, labre P5).
|
|
7
|
+
*/
|
|
8
|
+
export declare class ThreadMarksViewExtension extends ViewExtensionProvider {
|
|
9
|
+
name: string;
|
|
10
|
+
effect(): void;
|
|
11
|
+
setup(context: ViewExtensionContext): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ViewExtensionProvider, } from '../affine-ext-loader/index.js';
|
|
2
|
+
import { effects } from './effects.js';
|
|
3
|
+
import { threadMarksWidget } from './index.js';
|
|
4
|
+
import { threadDiscussToolbarExtensions } from './toolbar.js';
|
|
5
|
+
/**
|
|
6
|
+
* The "has a thread" seam's view side (ADR 0038): the marks widget and the
|
|
7
|
+
* "Discuss" toolbar module, in both views. Registered unconditionally: the
|
|
8
|
+
* seam, not a flag, decides whether anything shows (no flag gates a
|
|
9
|
+
* discussion, labre P5).
|
|
10
|
+
*/
|
|
11
|
+
export class ThreadMarksViewExtension extends ViewExtensionProvider {
|
|
12
|
+
constructor() {
|
|
13
|
+
super(...arguments);
|
|
14
|
+
this.name = 'affine-thread-marks-widget';
|
|
15
|
+
}
|
|
16
|
+
effect() {
|
|
17
|
+
super.effect();
|
|
18
|
+
effects();
|
|
19
|
+
}
|
|
20
|
+
setup(context) {
|
|
21
|
+
super.setup(context);
|
|
22
|
+
context.register(threadMarksWidget);
|
|
23
|
+
context.register(threadDiscussToolbarExtensions);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type SelectionRect } from '../affine-shared/commands/index.js';
|
|
2
|
+
import { type ThreadMark } from '../affine-shared/services/index.js';
|
|
3
|
+
import { WidgetComponent } from '../std/index.js';
|
|
4
|
+
import { nothing } from 'lit';
|
|
5
|
+
export declare const AFFINE_THREAD_MARKS_WIDGET = "affine-thread-marks-widget";
|
|
6
|
+
/**
|
|
7
|
+
* Where a mark sits, relative to the widget's own origin: beside a block of
|
|
8
|
+
* the page (`side`, in the right gutter, at the block's top), or on the
|
|
9
|
+
* top-right corner of a canvas model or of a block inside a canvas note
|
|
10
|
+
* (`corner`, centred on it).
|
|
11
|
+
*/
|
|
12
|
+
export interface ThreadMarkPlace {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
anchor: 'side' | 'corner';
|
|
16
|
+
}
|
|
17
|
+
export interface PlacedThreadMark extends ThreadMarkPlace {
|
|
18
|
+
mark: ThreadMark;
|
|
19
|
+
}
|
|
20
|
+
/** Is the point (viewport coordinates) inside one of the rects? */
|
|
21
|
+
export declare function hitsThreadRange(rects: readonly SelectionRect[], x: number, y: number): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The marks to draw, from what the host says and where the editor finds each
|
|
24
|
+
* target. Pure, so the "ignore what is absent" rule is held by a test:
|
|
25
|
+
*
|
|
26
|
+
* - a target `locate` cannot find (deleted, of the other view, hidden for this
|
|
27
|
+
* viewer) is skipped, never an error: an orphan anchor has no mark;
|
|
28
|
+
* - a mark at `count <= 0` is not drawn;
|
|
29
|
+
* - two marks on one target are one mark: counts add up, unread wins.
|
|
30
|
+
*/
|
|
31
|
+
export declare function placeThreadMarks(marks: readonly ThreadMark[], locate: (mark: ThreadMark) => ThreadMarkPlace | null): PlacedThreadMark[];
|
|
32
|
+
/**
|
|
33
|
+
* The "has a thread" marks (ADR 0038), one widget for both views, mounted on
|
|
34
|
+
* the root like `remote-selection`: in the page view beside each marked block,
|
|
35
|
+
* on the canvas on the corner of each marked element or block. A click asks
|
|
36
|
+
* the host to open the discussion, exactly as "Discuss" does.
|
|
37
|
+
*
|
|
38
|
+
* A mark that carries a `range` also highlights that run of its block's text
|
|
39
|
+
* in the page view (ADR 0038, amendment of 2026-10-15). The run is resolved
|
|
40
|
+
* from its Yjs relative positions on every render and drawn as the range's
|
|
41
|
+
* client rects (the technique of `doc-remote-selection`), never as a mark in
|
|
42
|
+
* the text. The highlight takes no pointer event, so nothing blocks placing
|
|
43
|
+
* the caret or selecting; a click that lands on it with nothing selected
|
|
44
|
+
* opens its discussion. A run that no longer resolves is not drawn, and the
|
|
45
|
+
* block's own mark stays.
|
|
46
|
+
*
|
|
47
|
+
* It reads the seam and the layout, and writes nothing. Positions are
|
|
48
|
+
* client rects taken relative to the widget's own box and recomputed, once a
|
|
49
|
+
* frame at most, when the marks, the document, the canvas or the window
|
|
50
|
+
* change. Without the seam it renders nothing.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ThreadMarksWidget extends WidgetComponent {
|
|
53
|
+
static styles: import("lit").CSSResult;
|
|
54
|
+
private readonly _refresh;
|
|
55
|
+
private get _edgeless();
|
|
56
|
+
connectedCallback(): void;
|
|
57
|
+
/**
|
|
58
|
+
* A click on a highlighted run opens its discussion. The click is not
|
|
59
|
+
* consumed: the caret lands where it was put. A click that ends a drag
|
|
60
|
+
* (something is selected) is a selection, not a request.
|
|
61
|
+
*/
|
|
62
|
+
private readonly _onClick;
|
|
63
|
+
/** Open the discussion of the run under the point, if there is one. */
|
|
64
|
+
private _openRangeAt;
|
|
65
|
+
private _resolve;
|
|
66
|
+
/**
|
|
67
|
+
* The runs to highlight, in viewport coordinates, page view only: on the
|
|
68
|
+
* canvas a note's text may sit under a shape, and this overlay would paint
|
|
69
|
+
* over it. `ponytail:` a run in a canvas note keeps only its block's mark;
|
|
70
|
+
* drawing it there needs the canvas stacking, if a reader asks for it.
|
|
71
|
+
*/
|
|
72
|
+
private _ranges;
|
|
73
|
+
private _locate;
|
|
74
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
75
|
+
}
|
|
76
|
+
declare global {
|
|
77
|
+
interface HTMLElementTagNameMap {
|
|
78
|
+
[AFFINE_THREAD_MARKS_WIDGET]: ThreadMarksWidget;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { CommentIcon } from '@blocksuite/icons/lit';
|
|
2
|
+
import { isPainted } from '../affine-block-surface/index.js';
|
|
3
|
+
import { getRangeRects, } from '../affine-shared/commands/index.js';
|
|
4
|
+
import { ThreadMarksProvider, translateKey, } from '../affine-shared/services/index.js';
|
|
5
|
+
import { isInsideEdgelessEditor, requestThrottledConnectedFrame, } from '../affine-shared/utils/index.js';
|
|
6
|
+
import { TextSelection, WidgetComponent } from '../std/index.js';
|
|
7
|
+
import { GfxBlockElementModel, GfxControllerIdentifier, GfxPrimitiveElementModel, } from '../std/gfx/index.js';
|
|
8
|
+
import { effect } from '@preact/signals-core';
|
|
9
|
+
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
10
|
+
import { css, html, nothing, unsafeCSS } from 'lit';
|
|
11
|
+
import { repeat } from 'lit/directives/repeat.js';
|
|
12
|
+
import { styleMap } from 'lit/directives/style-map.js';
|
|
13
|
+
import { resolveThreadRange } from './text-range.js';
|
|
14
|
+
import { openThreadOn } from './toolbar.js';
|
|
15
|
+
import { THREAD_DISCUSS } from './translations.js';
|
|
16
|
+
export const AFFINE_THREAD_MARKS_WIDGET = 'affine-thread-marks-widget';
|
|
17
|
+
/** Is the point (viewport coordinates) inside one of the rects? */
|
|
18
|
+
export function hitsThreadRange(rects, x, y) {
|
|
19
|
+
return rects.some(rect => x >= rect.left &&
|
|
20
|
+
x <= rect.left + rect.width &&
|
|
21
|
+
y >= rect.top &&
|
|
22
|
+
y <= rect.top + rect.height);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The marks to draw, from what the host says and where the editor finds each
|
|
26
|
+
* target. Pure, so the "ignore what is absent" rule is held by a test:
|
|
27
|
+
*
|
|
28
|
+
* - a target `locate` cannot find (deleted, of the other view, hidden for this
|
|
29
|
+
* viewer) is skipped, never an error: an orphan anchor has no mark;
|
|
30
|
+
* - a mark at `count <= 0` is not drawn;
|
|
31
|
+
* - two marks on one target are one mark: counts add up, unread wins.
|
|
32
|
+
*/
|
|
33
|
+
export function placeThreadMarks(marks, locate) {
|
|
34
|
+
const merged = new Map();
|
|
35
|
+
for (const mark of marks) {
|
|
36
|
+
if (!(mark.count > 0))
|
|
37
|
+
continue;
|
|
38
|
+
const key = `${mark.targetType}:${mark.targetId}`;
|
|
39
|
+
const seen = merged.get(key);
|
|
40
|
+
merged.set(key, seen
|
|
41
|
+
? {
|
|
42
|
+
...seen,
|
|
43
|
+
count: seen.count + mark.count,
|
|
44
|
+
unread: seen.unread || mark.unread,
|
|
45
|
+
}
|
|
46
|
+
: mark);
|
|
47
|
+
}
|
|
48
|
+
const placed = [];
|
|
49
|
+
for (const mark of merged.values()) {
|
|
50
|
+
const place = locate(mark);
|
|
51
|
+
if (place)
|
|
52
|
+
placed.push({ ...place, mark });
|
|
53
|
+
}
|
|
54
|
+
return placed;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The "has a thread" marks (ADR 0038), one widget for both views, mounted on
|
|
58
|
+
* the root like `remote-selection`: in the page view beside each marked block,
|
|
59
|
+
* on the canvas on the corner of each marked element or block. A click asks
|
|
60
|
+
* the host to open the discussion, exactly as "Discuss" does.
|
|
61
|
+
*
|
|
62
|
+
* A mark that carries a `range` also highlights that run of its block's text
|
|
63
|
+
* in the page view (ADR 0038, amendment of 2026-10-15). The run is resolved
|
|
64
|
+
* from its Yjs relative positions on every render and drawn as the range's
|
|
65
|
+
* client rects (the technique of `doc-remote-selection`), never as a mark in
|
|
66
|
+
* the text. The highlight takes no pointer event, so nothing blocks placing
|
|
67
|
+
* the caret or selecting; a click that lands on it with nothing selected
|
|
68
|
+
* opens its discussion. A run that no longer resolves is not drawn, and the
|
|
69
|
+
* block's own mark stays.
|
|
70
|
+
*
|
|
71
|
+
* It reads the seam and the layout, and writes nothing. Positions are
|
|
72
|
+
* client rects taken relative to the widget's own box and recomputed, once a
|
|
73
|
+
* frame at most, when the marks, the document, the canvas or the window
|
|
74
|
+
* change. Without the seam it renders nothing.
|
|
75
|
+
*/
|
|
76
|
+
export class ThreadMarksWidget extends WidgetComponent {
|
|
77
|
+
constructor() {
|
|
78
|
+
super(...arguments);
|
|
79
|
+
this._refresh = requestThrottledConnectedFrame(() => {
|
|
80
|
+
this.requestUpdate();
|
|
81
|
+
}, this);
|
|
82
|
+
/**
|
|
83
|
+
* A click on a highlighted run opens its discussion. The click is not
|
|
84
|
+
* consumed: the caret lands where it was put. A click that ends a drag
|
|
85
|
+
* (something is selected) is a selection, not a request.
|
|
86
|
+
*/
|
|
87
|
+
this._onClick = (event) => {
|
|
88
|
+
if (window.getSelection()?.isCollapsed === false)
|
|
89
|
+
return;
|
|
90
|
+
this._openRangeAt(event.clientX, event.clientY);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
static { this.styles = css `
|
|
94
|
+
:host {
|
|
95
|
+
position: absolute;
|
|
96
|
+
top: 0;
|
|
97
|
+
left: 0;
|
|
98
|
+
width: 0;
|
|
99
|
+
height: 0;
|
|
100
|
+
overflow: visible;
|
|
101
|
+
pointer-events: none;
|
|
102
|
+
z-index: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.thread-mark {
|
|
106
|
+
position: absolute;
|
|
107
|
+
top: 0;
|
|
108
|
+
left: 0;
|
|
109
|
+
gap: 2px;
|
|
110
|
+
padding: 0 6px 0 4px;
|
|
111
|
+
border-radius: 12px;
|
|
112
|
+
border: 1px solid ${unsafeCSS(cssVarV2('layer/insideBorder/border'))};
|
|
113
|
+
background: ${unsafeCSS(cssVarV2('layer/background/overlayPanel'))};
|
|
114
|
+
box-shadow: var(--affine-shadow-1);
|
|
115
|
+
color: ${unsafeCSS(cssVarV2('text/secondary'))};
|
|
116
|
+
pointer-events: auto;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.thread-range {
|
|
120
|
+
position: absolute;
|
|
121
|
+
top: 0;
|
|
122
|
+
left: 0;
|
|
123
|
+
background-color: ${unsafeCSS(cssVarV2('block/comment/highlightDefault'))};
|
|
124
|
+
border-bottom: 2px solid
|
|
125
|
+
${unsafeCSS(cssVarV2('block/comment/highlightUnderline'))};
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.thread-mark[data-unread] {
|
|
130
|
+
color: ${unsafeCSS(cssVarV2('text/emphasis'))};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@media (pointer: coarse) {
|
|
134
|
+
.thread-mark::after {
|
|
135
|
+
content: '';
|
|
136
|
+
position: absolute;
|
|
137
|
+
inset: -10px;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
`; }
|
|
141
|
+
get _edgeless() {
|
|
142
|
+
return isInsideEdgelessEditor(this.std.host);
|
|
143
|
+
}
|
|
144
|
+
connectedCallback() {
|
|
145
|
+
super.connectedCallback();
|
|
146
|
+
const { _disposables, std } = this;
|
|
147
|
+
// The seam is fixed for the editor's life: without it, follow nothing,
|
|
148
|
+
// so a host that has no threads pays nothing on a pan or a keystroke.
|
|
149
|
+
if (!std.getOptional(ThreadMarksProvider))
|
|
150
|
+
return;
|
|
151
|
+
_disposables.add(std.store.slots.blockUpdated.subscribe(this._refresh));
|
|
152
|
+
// A block mounts after the widget: a mark on it can only be placed then.
|
|
153
|
+
_disposables.add(std.view.viewUpdated.subscribe(this._refresh));
|
|
154
|
+
_disposables.addFromEvent(window, 'resize', this._refresh);
|
|
155
|
+
// Capture: the page view scrolls in a container, not the window.
|
|
156
|
+
_disposables.addFromEvent(window, 'scroll', this._refresh, true);
|
|
157
|
+
// A highlighted run moves with every keystroke, local or remote, and a
|
|
158
|
+
// text edit is no block update: follow the document itself.
|
|
159
|
+
const doc = std.store.spaceDoc;
|
|
160
|
+
doc.on('update', this._refresh);
|
|
161
|
+
_disposables.add(() => doc.off('update', this._refresh));
|
|
162
|
+
_disposables.addFromEvent(std.host, 'click', this._onClick);
|
|
163
|
+
const gfx = std.getOptional(GfxControllerIdentifier);
|
|
164
|
+
if (gfx) {
|
|
165
|
+
_disposables.add(gfx.viewport.viewportUpdated.subscribe(this._refresh));
|
|
166
|
+
// The surface may load after the widget connects: follow it.
|
|
167
|
+
_disposables.add(effect(() => {
|
|
168
|
+
const surface = gfx.surface$.value;
|
|
169
|
+
if (!surface)
|
|
170
|
+
return;
|
|
171
|
+
this._refresh();
|
|
172
|
+
const subscriptions = [
|
|
173
|
+
surface.elementAdded.subscribe(this._refresh),
|
|
174
|
+
surface.elementRemoved.subscribe(this._refresh),
|
|
175
|
+
surface.elementUpdated.subscribe(this._refresh),
|
|
176
|
+
];
|
|
177
|
+
return () => subscriptions.forEach(s => s.unsubscribe());
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Open the discussion of the run under the point, if there is one. */
|
|
182
|
+
_openRangeAt(x, y) {
|
|
183
|
+
const service = this.std.getOptional(ThreadMarksProvider);
|
|
184
|
+
if (!service)
|
|
185
|
+
return;
|
|
186
|
+
const hit = this._ranges(service.marks$.peek()).find(({ rects }) => hitsThreadRange(rects, x, y));
|
|
187
|
+
if (!hit)
|
|
188
|
+
return;
|
|
189
|
+
openThreadOn(this.std, {
|
|
190
|
+
targetType: 'block',
|
|
191
|
+
targetId: hit.mark.targetId,
|
|
192
|
+
range: hit.mark.range,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
_resolve(mark) {
|
|
196
|
+
if (mark.targetType !== 'block' || !mark.range)
|
|
197
|
+
return null;
|
|
198
|
+
const yText = this.std.store.getModelById(mark.targetId)?.text?.yText;
|
|
199
|
+
return yText ? resolveThreadRange(yText, mark.range) : null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The runs to highlight, in viewport coordinates, page view only: on the
|
|
203
|
+
* canvas a note's text may sit under a shape, and this overlay would paint
|
|
204
|
+
* over it. `ponytail:` a run in a canvas note keeps only its block's mark;
|
|
205
|
+
* drawing it there needs the canvas stacking, if a reader asks for it.
|
|
206
|
+
*/
|
|
207
|
+
_ranges(marks) {
|
|
208
|
+
if (this._edgeless)
|
|
209
|
+
return [];
|
|
210
|
+
const drawn = [];
|
|
211
|
+
for (const mark of marks) {
|
|
212
|
+
if (!(mark.count > 0))
|
|
213
|
+
continue;
|
|
214
|
+
const run = this._resolve(mark);
|
|
215
|
+
if (!run)
|
|
216
|
+
continue;
|
|
217
|
+
let range;
|
|
218
|
+
try {
|
|
219
|
+
range = this.std.range.textSelectionToRange(this.std.selection.create(TextSelection, {
|
|
220
|
+
from: { blockId: mark.targetId, ...run },
|
|
221
|
+
to: null,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// The inline editor has not rendered the latest text yet: the next
|
|
226
|
+
// frame draws it.
|
|
227
|
+
range = null;
|
|
228
|
+
}
|
|
229
|
+
if (!range)
|
|
230
|
+
continue;
|
|
231
|
+
const rects = getRangeRects(range, null);
|
|
232
|
+
if (rects.length > 0)
|
|
233
|
+
drawn.push({ mark, rects });
|
|
234
|
+
}
|
|
235
|
+
return drawn;
|
|
236
|
+
}
|
|
237
|
+
_locate(mark, origin) {
|
|
238
|
+
const edgeless = this._edgeless;
|
|
239
|
+
if (edgeless) {
|
|
240
|
+
const gfx = this.std.get(GfxControllerIdentifier);
|
|
241
|
+
const model = gfx.getElementById(mark.targetId);
|
|
242
|
+
if (model instanceof GfxPrimitiveElementModel ||
|
|
243
|
+
model instanceof GfxBlockElementModel) {
|
|
244
|
+
// What this viewer does not see carries no mark (ADR 0031): the
|
|
245
|
+
// renderers' own predicate for an element, the local hide for a block.
|
|
246
|
+
const painted = model instanceof GfxPrimitiveElementModel
|
|
247
|
+
? isPainted(model, gfx.localVisibility)
|
|
248
|
+
: !gfx.localVisibility.isHidden(model);
|
|
249
|
+
if (!painted)
|
|
250
|
+
return null;
|
|
251
|
+
const bound = model.elementBound;
|
|
252
|
+
const [x, y] = gfx.viewport.toViewCoord(bound.maxX, bound.y);
|
|
253
|
+
return {
|
|
254
|
+
x: x + gfx.viewport.left - origin.left,
|
|
255
|
+
y: y + gfx.viewport.top - origin.top,
|
|
256
|
+
anchor: 'corner',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (mark.targetType !== 'block')
|
|
261
|
+
return null;
|
|
262
|
+
const block = this.std.view.getBlock(mark.targetId);
|
|
263
|
+
if (!block)
|
|
264
|
+
return null;
|
|
265
|
+
const rect = block.getBoundingClientRect();
|
|
266
|
+
if (rect.width === 0 && rect.height === 0)
|
|
267
|
+
return null;
|
|
268
|
+
return {
|
|
269
|
+
x: rect.right - origin.left,
|
|
270
|
+
y: rect.top - origin.top,
|
|
271
|
+
anchor: edgeless ? 'corner' : 'side',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
render() {
|
|
275
|
+
const service = this.std.getOptional(ThreadMarksProvider);
|
|
276
|
+
if (!service)
|
|
277
|
+
return nothing;
|
|
278
|
+
const marks = service.marks$.value;
|
|
279
|
+
if (marks.length === 0)
|
|
280
|
+
return nothing;
|
|
281
|
+
const origin = this.getBoundingClientRect();
|
|
282
|
+
const placed = placeThreadMarks(marks, mark => this._locate(mark, origin));
|
|
283
|
+
const ranges = this._ranges(marks);
|
|
284
|
+
if (placed.length === 0 && ranges.length === 0)
|
|
285
|
+
return nothing;
|
|
286
|
+
const label = translateKey(this.std, ...THREAD_DISCUSS);
|
|
287
|
+
const highlights = ranges.flatMap(({ mark, rects }) => rects.map(rect => html `<div
|
|
288
|
+
class="thread-range"
|
|
289
|
+
data-testid="thread-range"
|
|
290
|
+
data-target-id=${mark.targetId}
|
|
291
|
+
style=${styleMap({
|
|
292
|
+
width: `${rect.width}px`,
|
|
293
|
+
height: `${rect.height}px`,
|
|
294
|
+
transform: `translate(${rect.left - origin.left}px, ${rect.top - origin.top}px)`,
|
|
295
|
+
})}
|
|
296
|
+
></div>`));
|
|
297
|
+
return html `${highlights}${repeat(placed, ({ mark }) => `${mark.targetType}:${mark.targetId}`, ({ mark, x, y, anchor }) => html `<icon-button
|
|
298
|
+
class="thread-mark"
|
|
299
|
+
data-testid="thread-mark"
|
|
300
|
+
data-target-type=${mark.targetType}
|
|
301
|
+
data-target-id=${mark.targetId}
|
|
302
|
+
?data-unread=${mark.unread}
|
|
303
|
+
width="auto"
|
|
304
|
+
height="24px"
|
|
305
|
+
.active=${mark.unread}
|
|
306
|
+
.text=${String(mark.count)}
|
|
307
|
+
aria-label=${label}
|
|
308
|
+
title=${label}
|
|
309
|
+
style=${styleMap({
|
|
310
|
+
transform: anchor === 'side'
|
|
311
|
+
? `translate(${x + 8}px, ${y}px)`
|
|
312
|
+
: `translate(${x}px, ${y}px) translate(-50%, -50%)`,
|
|
313
|
+
})}
|
|
314
|
+
@pointerdown=${(event) => event.stopPropagation()}
|
|
315
|
+
@click=${(event) => {
|
|
316
|
+
event.stopPropagation();
|
|
317
|
+
openThreadOn(this.std, {
|
|
318
|
+
targetType: mark.targetType,
|
|
319
|
+
targetId: mark.targetId,
|
|
320
|
+
});
|
|
321
|
+
}}
|
|
322
|
+
>${CommentIcon({ width: '16px', height: '16px' })}</icon-button
|
|
323
|
+
>`)}`;
|
|
324
|
+
}
|
|
325
|
+
}
|
package/dist/extensions/view.js
CHANGED
|
@@ -54,6 +54,7 @@ import { PageDraggingAreaViewExtension } from '../_pkgs/affine-widget-page-dragg
|
|
|
54
54
|
import { RemoteSelectionViewExtension } from '../_pkgs/affine-widget-remote-selection/view.js';
|
|
55
55
|
import { ScrollAnchoringViewExtension } from '../_pkgs/affine-widget-scroll-anchoring/view.js';
|
|
56
56
|
import { SlashMenuViewExtension } from '../_pkgs/affine-widget-slash-menu/view.js';
|
|
57
|
+
import { ThreadMarksViewExtension } from '../_pkgs/affine-widget-thread-marks/view.js';
|
|
57
58
|
import { ToolbarViewExtension } from '../_pkgs/affine-widget-toolbar/view.js';
|
|
58
59
|
import { ViewportOverlayViewExtension } from '../_pkgs/affine-widget-viewport-overlay/view.js';
|
|
59
60
|
import { isBlockEnabled, isCapabilityEnabled, } from '../flags.js';
|
|
@@ -158,6 +159,7 @@ export function getInternalViewExtensions(flags) {
|
|
|
158
159
|
KeyboardToolbarViewExtension,
|
|
159
160
|
LinkedDocViewExtension,
|
|
160
161
|
RemoteSelectionViewExtension,
|
|
162
|
+
ThreadMarksViewExtension,
|
|
161
163
|
ScrollAnchoringViewExtension,
|
|
162
164
|
SlashMenuViewExtension,
|
|
163
165
|
ToolbarViewExtension,
|
package/dist/host-panels.d.ts
CHANGED
|
@@ -58,6 +58,9 @@ export { type SelectionPaneNode, selectionPaneTree, } from './_pkgs/affine-block
|
|
|
58
58
|
export { frameCommands, type ReorderFramesParams, reorderFramesParams, } from './_pkgs/affine-block-frame/index.js';
|
|
59
59
|
export { formatPropertyValue, isPropertyValueEmpty, } from './_pkgs/affine-fragment-doc-title/index.js';
|
|
60
60
|
export type { PropertiesPanelRequest, PropertiesPanelService, TitleBandNotShownPage, TitleBandProperty, TitleBandService, TitleBandSheetState, } from './_pkgs/affine-shared/services/index.js';
|
|
61
|
+
export { ThreadMarksExtension } from './_pkgs/affine-shared/services/index.js';
|
|
62
|
+
export type { ThreadMark, ThreadMarksService, ThreadMarkTargetType, ThreadOpenRequest, ThreadTextRange, } from './_pkgs/affine-shared/services/index.js';
|
|
63
|
+
export { type ThreadTarget, threadExcerpt, } from './_pkgs/affine-widget-thread-marks/index.js';
|
|
61
64
|
/**
|
|
62
65
|
* The document's frames in presentation order, first slide first — the order
|
|
63
66
|
* `canvas.frame.reorder` writes and the presentation walks. Works in page mode
|
package/dist/host-panels.js
CHANGED
|
@@ -70,6 +70,18 @@ export { frameCommands, reorderFramesParams, } from './_pkgs/affine-block-frame/
|
|
|
70
70
|
* value.
|
|
71
71
|
*/
|
|
72
72
|
export { formatPropertyValue, isPropertyValueEmpty, } from './_pkgs/affine-fragment-doc-title/index.js';
|
|
73
|
+
/*
|
|
74
|
+
* The "has a thread" seam (ADR 0038). The editor marks what the host says
|
|
75
|
+
* has a discussion and asks the host to open one, with an excerpt it
|
|
76
|
+
* computes; the host registers `ThreadMarksExtension({ marks$, openThread })`
|
|
77
|
+
* among its editor extensions. Gathered here with its types so a host's
|
|
78
|
+
* discussion module imports one entry; `threadExcerpt` is the excerpt the
|
|
79
|
+
* editor hands `openThread`, for a host that starts a discussion from its
|
|
80
|
+
* own UI. `ThreadTextRange` (amendment of 2026-10-15) is the opaque text
|
|
81
|
+
* anchor a request may carry and a mark may hand back.
|
|
82
|
+
*/
|
|
83
|
+
export { ThreadMarksExtension } from './_pkgs/affine-shared/services/index.js';
|
|
84
|
+
export { threadExcerpt, } from './_pkgs/affine-widget-thread-marks/index.js';
|
|
73
85
|
/**
|
|
74
86
|
* The document's frames in presentation order, first slide first — the order
|
|
75
87
|
* `canvas.frame.reorder` writes and the presentation walks. Works in page mode
|
package/dist/translations.js
CHANGED
|
@@ -41,6 +41,7 @@ import { EDGELESS_TOOLBAR_WORDINGS } from './_pkgs/affine-widget-edgeless-toolba
|
|
|
41
41
|
import { ZOOM_TOOLBAR_WORDINGS } from './_pkgs/affine-widget-edgeless-zoom-toolbar/translations.js';
|
|
42
42
|
import { LINKED_DOC_WORDINGS } from './_pkgs/affine-widget-linked-doc/translations.js';
|
|
43
43
|
import { REMOTE_SELECTION_WORDINGS } from './_pkgs/affine-widget-remote-selection/translations.js';
|
|
44
|
+
import { THREAD_MARKS_WORDINGS } from './_pkgs/affine-widget-thread-marks/translations.js';
|
|
44
45
|
import { SLASH_MENU_WORDINGS } from './_pkgs/affine-widget-slash-menu/translations.js';
|
|
45
46
|
import { TOOLBAR_WIDGET_WORDINGS } from './_pkgs/affine-widget-toolbar/translations.js';
|
|
46
47
|
import { collectTranslationKeys, commandCategoryTranslationEntries, commandTranslationEntries, mergeTranslationEntries, } from './_pkgs/std/index.js';
|
|
@@ -309,6 +310,7 @@ const PACKAGE_WORDINGS = [
|
|
|
309
310
|
ZOOM_TOOLBAR_WORDINGS,
|
|
310
311
|
LINKED_DOC_WORDINGS,
|
|
311
312
|
REMOTE_SELECTION_WORDINGS,
|
|
313
|
+
THREAD_MARKS_WORDINGS,
|
|
312
314
|
TOOLBAR_WIDGET_WORDINGS,
|
|
313
315
|
COMPONENTS_WORDINGS,
|
|
314
316
|
INLINE_LATEX_WORDINGS,
|