@formicoidea/labre-core 0.52.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.
@@ -6,6 +6,21 @@ import type { ReadonlySignal } from '@preact/signals-core';
6
6
  * the one the library already gives it: a block id, an element id.
7
7
  */
8
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
+ }
9
24
  /**
10
25
  * One marked target of the mounted document, as the host computes it. Ids and
11
26
  * counters only: the library never learns what the host attaches to a target,
@@ -19,6 +34,14 @@ export interface ThreadMark {
19
34
  targetId: string;
20
35
  count: number;
21
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;
22
45
  }
23
46
  /**
24
47
  * What the library hands the host when someone clicks a mark or "Discuss"
@@ -26,7 +49,8 @@ export interface ThreadMark {
26
49
  *
27
50
  * `excerpt` is computed here because the library knows the content: the
28
51
  * block's text (its descendants' for a container such as a note), a canvas
29
- * element's label as the selection pane reads it, else the kind of target.
52
+ * element's label as the selection pane reads it, else the kind of target;
53
+ * for a text range, the text it covers.
30
54
  * Whitespace is collapsed and it is cut to at most 280 characters, an
31
55
  * ellipsis included (`THREAD_EXCERPT_MAX`). It is a snapshot for the host to
32
56
  * store beside its anchor; the library keeps no copy.
@@ -36,6 +60,13 @@ export interface ThreadOpenRequest {
36
60
  targetType: ThreadMarkTargetType;
37
61
  targetId: string;
38
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;
39
70
  }
40
71
  /** The longest excerpt `openThread` is ever handed, in code points. */
41
72
  export declare const THREAD_EXCERPT_MAX = 280;
@@ -1,9 +1,13 @@
1
- import { type ThreadMarkTargetType } from '../affine-shared/services/index.js';
1
+ import { type ThreadMarkTargetType, type ThreadTextRange } from '../affine-shared/services/index.js';
2
2
  import type { BlockStdScope } from '../std/index.js';
3
- /** A block or a canvas element, by the id the library gave it. */
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
+ */
4
7
  export interface ThreadTarget {
5
8
  targetType: ThreadMarkTargetType;
6
9
  targetId: string;
10
+ range?: ThreadTextRange;
7
11
  }
8
12
  /**
9
13
  * Collapse whitespace and cut to {@link THREAD_EXCERPT_MAX} code points, the
@@ -2,6 +2,7 @@ import { getSurfaceBlock } from '../affine-block-surface/index.js';
2
2
  import { THREAD_EXCERPT_MAX, } from '../affine-shared/services/index.js';
3
3
  import { selectionPaneRowLabel, selectionPaneTypeLabel, } from '../affine-widget-edgeless-toolbar/index.js';
4
4
  import { GfxBlockElementModel } from '../std/gfx/index.js';
5
+ import { resolveThreadRange } from './text-range.js';
5
6
  const ELLIPSIS = '…';
6
7
  /**
7
8
  * Collapse whitespace and cut to {@link THREAD_EXCERPT_MAX} code points, the
@@ -45,6 +46,13 @@ function rawExcerpt(std, target) {
45
46
  const model = std.store.getModelById(target.targetId);
46
47
  if (!model)
47
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
+ }
48
56
  const text = blockText(model);
49
57
  if (text.trim())
50
58
  return text;
@@ -1,4 +1,5 @@
1
1
  export * from './excerpt.js';
2
+ export * from './text-range.js';
2
3
  export * from './toolbar.js';
3
4
  export * from './translations.js';
4
5
  export * from './widget.js';
@@ -2,6 +2,7 @@ import { WidgetViewExtension } from '../std/index.js';
2
2
  import { literal, unsafeStatic } from 'lit/static-html.js';
3
3
  import { AFFINE_THREAD_MARKS_WIDGET } from './widget.js';
4
4
  export * from './excerpt.js';
5
+ export * from './text-range.js';
5
6
  export * from './toolbar.js';
6
7
  export * from './translations.js';
7
8
  export * from './widget.js';
@@ -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
+ }
@@ -9,15 +9,16 @@ import { type ThreadTarget } from './excerpt.js';
9
9
  * - On the canvas, exactly one selected model: a canvas element (shape,
10
10
  * connector, group, framework board) is an `element`, a canvas block (note,
11
11
  * frame, image, embed) a `block`.
12
- * - A text selection within one block: that block (the grain is the block,
13
- * never a range of text).
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
14
  * - One selected block, or the block a hover toolbar is drawn for.
15
15
  */
16
16
  export declare function threadTargetOf(ctx: ToolbarContext): ThreadTarget | null;
17
17
  /**
18
18
  * Ask the host to open the discussion of `target`, with the excerpt the
19
19
  * library computes. Nothing is written: no prop, no id in the document.
20
- * Without the seam, nothing happens.
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.
21
22
  */
22
23
  export declare function openThreadOn(std: BlockStdScope, target: ThreadTarget): void;
23
24
  export declare const THREAD_DISCUSS_ACTION_ID = "g.thread-discuss";
@@ -3,6 +3,7 @@ import { ToolbarModuleExtension, toolbarModuleKey, ThreadMarksProvider, } from '
3
3
  import { BlockFlavourIdentifier, BlockSelection, TextSelection, } from '../std/index.js';
4
4
  import { GfxPrimitiveElementModel } from '../std/gfx/index.js';
5
5
  import { threadExcerpt } from './excerpt.js';
6
+ import { encodeThreadRange } from './text-range.js';
6
7
  import { THREAD_DISCUSS } from './translations.js';
7
8
  /** Containers a discussion never anchors to: the page and the canvas itself. */
8
9
  const NOT_A_TARGET = new Set(['affine:page', 'affine:surface']);
@@ -14,6 +15,22 @@ function blockTarget(id, ctx) {
14
15
  return null;
15
16
  return { targetType: 'block', targetId: model.id };
16
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
+ }
17
34
  /**
18
35
  * The ONE target a toolbar row speaks for, or `null` when it speaks for
19
36
  * several (a discussion anchors to one block or one element, labre ADR-0048).
@@ -21,8 +38,8 @@ function blockTarget(id, ctx) {
21
38
  * - On the canvas, exactly one selected model: a canvas element (shape,
22
39
  * connector, group, framework board) is an `element`, a canvas block (note,
23
40
  * frame, image, embed) a `block`.
24
- * - A text selection within one block: that block (the grain is the block,
25
- * never a range of text).
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).
26
43
  * - One selected block, or the block a hover toolbar is drawn for.
27
44
  */
28
45
  export function threadTargetOf(ctx) {
@@ -38,11 +55,8 @@ export function threadTargetOf(ctx) {
38
55
  : blockTarget(model.id, ctx);
39
56
  }
40
57
  const text = ctx.selection.find(TextSelection);
41
- if (text) {
42
- const from = text.from.blockId;
43
- const to = text.to?.blockId ?? from;
44
- return from === to ? blockTarget(from, ctx) : null;
45
- }
58
+ if (text)
59
+ return textTarget(text, ctx);
46
60
  if (ctx.selection.filter(BlockSelection).length > 1)
47
61
  return null;
48
62
  return blockTarget(ctx.getCurrentModelBy(BlockSelection)?.id, ctx);
@@ -50,7 +64,8 @@ export function threadTargetOf(ctx) {
50
64
  /**
51
65
  * Ask the host to open the discussion of `target`, with the excerpt the
52
66
  * library computes. Nothing is written: no prop, no id in the document.
53
- * Without the seam, nothing happens.
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.
54
69
  */
55
70
  export function openThreadOn(std, target) {
56
71
  const service = std.getOptional(ThreadMarksProvider);
@@ -61,6 +76,7 @@ export function openThreadOn(std, target) {
61
76
  targetType: target.targetType,
62
77
  targetId: target.targetId,
63
78
  excerpt: threadExcerpt(std, target),
79
+ ...(target.range ? { range: target.range } : {}),
64
80
  });
65
81
  }
66
82
  export const THREAD_DISCUSS_ACTION_ID = 'g.thread-discuss';
@@ -1,5 +1,7 @@
1
+ import { type SelectionRect } from '../affine-shared/commands/index.js';
1
2
  import { type ThreadMark } from '../affine-shared/services/index.js';
2
3
  import { WidgetComponent } from '../std/index.js';
4
+ import { nothing } from 'lit';
3
5
  export declare const AFFINE_THREAD_MARKS_WIDGET = "affine-thread-marks-widget";
4
6
  /**
5
7
  * Where a mark sits, relative to the widget's own origin: beside a block of
@@ -15,6 +17,8 @@ export interface ThreadMarkPlace {
15
17
  export interface PlacedThreadMark extends ThreadMarkPlace {
16
18
  mark: ThreadMark;
17
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;
18
22
  /**
19
23
  * The marks to draw, from what the host says and where the editor finds each
20
24
  * target. Pure, so the "ignore what is absent" rule is held by a test:
@@ -31,6 +35,15 @@ export declare function placeThreadMarks(marks: readonly ThreadMark[], locate: (
31
35
  * on the canvas on the corner of each marked element or block. A click asks
32
36
  * the host to open the discussion, exactly as "Discuss" does.
33
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
+ *
34
47
  * It reads the seam and the layout, and writes nothing. Positions are
35
48
  * client rects taken relative to the widget's own box and recomputed, once a
36
49
  * frame at most, when the marks, the document, the canvas or the window
@@ -41,8 +54,24 @@ export declare class ThreadMarksWidget extends WidgetComponent {
41
54
  private readonly _refresh;
42
55
  private get _edgeless();
43
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;
44
73
  private _locate;
45
- render(): unknown;
74
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
46
75
  }
47
76
  declare global {
48
77
  interface HTMLElementTagNameMap {
@@ -1,17 +1,26 @@
1
1
  import { CommentIcon } from '@blocksuite/icons/lit';
2
2
  import { isPainted } from '../affine-block-surface/index.js';
3
+ import { getRangeRects, } from '../affine-shared/commands/index.js';
3
4
  import { ThreadMarksProvider, translateKey, } from '../affine-shared/services/index.js';
4
5
  import { isInsideEdgelessEditor, requestThrottledConnectedFrame, } from '../affine-shared/utils/index.js';
5
- import { WidgetComponent } from '../std/index.js';
6
+ import { TextSelection, WidgetComponent } from '../std/index.js';
6
7
  import { GfxBlockElementModel, GfxControllerIdentifier, GfxPrimitiveElementModel, } from '../std/gfx/index.js';
7
8
  import { effect } from '@preact/signals-core';
8
9
  import { cssVarV2 } from '@toeverything/theme/v2';
9
10
  import { css, html, nothing, unsafeCSS } from 'lit';
10
11
  import { repeat } from 'lit/directives/repeat.js';
11
12
  import { styleMap } from 'lit/directives/style-map.js';
13
+ import { resolveThreadRange } from './text-range.js';
12
14
  import { openThreadOn } from './toolbar.js';
13
15
  import { THREAD_DISCUSS } from './translations.js';
14
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
+ }
15
24
  /**
16
25
  * The marks to draw, from what the host says and where the editor finds each
17
26
  * target. Pure, so the "ignore what is absent" rule is held by a test:
@@ -50,6 +59,15 @@ export function placeThreadMarks(marks, locate) {
50
59
  * on the canvas on the corner of each marked element or block. A click asks
51
60
  * the host to open the discussion, exactly as "Discuss" does.
52
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
+ *
53
71
  * It reads the seam and the layout, and writes nothing. Positions are
54
72
  * client rects taken relative to the widget's own box and recomputed, once a
55
73
  * frame at most, when the marks, the document, the canvas or the window
@@ -61,6 +79,16 @@ export class ThreadMarksWidget extends WidgetComponent {
61
79
  this._refresh = requestThrottledConnectedFrame(() => {
62
80
  this.requestUpdate();
63
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
+ };
64
92
  }
65
93
  static { this.styles = css `
66
94
  :host {
@@ -88,6 +116,16 @@ export class ThreadMarksWidget extends WidgetComponent {
88
116
  pointer-events: auto;
89
117
  }
90
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
+
91
129
  .thread-mark[data-unread] {
92
130
  color: ${unsafeCSS(cssVarV2('text/emphasis'))};
93
131
  }
@@ -116,6 +154,12 @@ export class ThreadMarksWidget extends WidgetComponent {
116
154
  _disposables.addFromEvent(window, 'resize', this._refresh);
117
155
  // Capture: the page view scrolls in a container, not the window.
118
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);
119
163
  const gfx = std.getOptional(GfxControllerIdentifier);
120
164
  if (gfx) {
121
165
  _disposables.add(gfx.viewport.viewportUpdated.subscribe(this._refresh));
@@ -134,6 +178,62 @@ export class ThreadMarksWidget extends WidgetComponent {
134
178
  }));
135
179
  }
136
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
+ }
137
237
  _locate(mark, origin) {
138
238
  const edgeless = this._edgeless;
139
239
  if (edgeless) {
@@ -180,10 +280,21 @@ export class ThreadMarksWidget extends WidgetComponent {
180
280
  return nothing;
181
281
  const origin = this.getBoundingClientRect();
182
282
  const placed = placeThreadMarks(marks, mark => this._locate(mark, origin));
183
- if (placed.length === 0)
283
+ const ranges = this._ranges(marks);
284
+ if (placed.length === 0 && ranges.length === 0)
184
285
  return nothing;
185
286
  const label = translateKey(this.std, ...THREAD_DISCUSS);
186
- return repeat(placed, ({ mark }) => `${mark.targetType}:${mark.targetId}`, ({ mark, x, y, anchor }) => html `<icon-button
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
187
298
  class="thread-mark"
188
299
  data-testid="thread-mark"
189
300
  data-target-type=${mark.targetType}
@@ -209,6 +320,6 @@ export class ThreadMarksWidget extends WidgetComponent {
209
320
  });
210
321
  }}
211
322
  >${CommentIcon({ width: '16px', height: '16px' })}</icon-button
212
- >`);
323
+ >`)}`;
213
324
  }
214
325
  }
@@ -59,7 +59,7 @@ export { frameCommands, type ReorderFramesParams, reorderFramesParams, } from '.
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
61
  export { ThreadMarksExtension } from './_pkgs/affine-shared/services/index.js';
62
- export type { ThreadMark, ThreadMarksService, ThreadMarkTargetType, ThreadOpenRequest, } from './_pkgs/affine-shared/services/index.js';
62
+ export type { ThreadMark, ThreadMarksService, ThreadMarkTargetType, ThreadOpenRequest, ThreadTextRange, } from './_pkgs/affine-shared/services/index.js';
63
63
  export { type ThreadTarget, threadExcerpt, } from './_pkgs/affine-widget-thread-marks/index.js';
64
64
  /**
65
65
  * The document's frames in presentation order, first slide first — the order
@@ -77,7 +77,8 @@ export { formatPropertyValue, isPropertyValueEmpty, } from './_pkgs/affine-fragm
77
77
  * among its editor extensions. Gathered here with its types so a host's
78
78
  * discussion module imports one entry; `threadExcerpt` is the excerpt the
79
79
  * editor hands `openThread`, for a host that starts a discussion from its
80
- * own UI.
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.
81
82
  */
82
83
  export { ThreadMarksExtension } from './_pkgs/affine-shared/services/index.js';
83
84
  export { threadExcerpt, } from './_pkgs/affine-widget-thread-marks/index.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@formicoidea/labre-core",
3
3
  "description": "Labre editor — the full BlockSuite-derived editor as one package.",
4
- "version": "0.52.0",
4
+ "version": "0.53.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "author": "lajola",