|
@@ -6,8 +6,13 @@ import type { WebsocketProvider } from 'y-websocket';
|
|
|
import * as Y from 'yjs';
|
|
import * as Y from 'yjs';
|
|
|
|
|
|
|
|
import type { EditingClient } from '../../../../interfaces';
|
|
import type { EditingClient } from '../../../../interfaces';
|
|
|
-import { createOffScreenIndicator } from './off-screen-indicator';
|
|
|
|
|
-import { RichCaretWidget } from './widget';
|
|
|
|
|
|
|
+import { ActivityTracker } from './activity-tracker';
|
|
|
|
|
+import { createOffScreenIndicator, RichCaretWidget } from './dom';
|
|
|
|
|
+import { broadcastLocalCursor } from './local-cursor';
|
|
|
|
|
+import {
|
|
|
|
|
+ classifyCursorPosition,
|
|
|
|
|
+ createViewportContext,
|
|
|
|
|
+} from './viewport-classification';
|
|
|
|
|
|
|
|
type Awareness = WebsocketProvider['awareness'];
|
|
type Awareness = WebsocketProvider['awareness'];
|
|
|
|
|
|
|
@@ -19,6 +24,8 @@ type AwarenessState = {
|
|
|
};
|
|
};
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+type IndicatorEntry = { el: HTMLElement; headIndex: number };
|
|
|
|
|
+
|
|
|
/** Mutable ref container for the scroll-to-remote-cursor function. */
|
|
/** Mutable ref container for the scroll-to-remote-cursor function. */
|
|
|
export type ScrollCallbackRef = {
|
|
export type ScrollCallbackRef = {
|
|
|
current: ((clientId: number) => void) | null;
|
|
current: ((clientId: number) => void) | null;
|
|
@@ -30,15 +37,12 @@ export class YRichCursorsPluginValue {
|
|
|
decorations: DecorationSet;
|
|
decorations: DecorationSet;
|
|
|
private readonly awareness: Awareness;
|
|
private readonly awareness: Awareness;
|
|
|
private readonly scrollCallbackRef: ScrollCallbackRef | undefined;
|
|
private readonly scrollCallbackRef: ScrollCallbackRef | undefined;
|
|
|
|
|
+ private readonly activityTracker = new ActivityTracker();
|
|
|
private readonly changeListener: (update: {
|
|
private readonly changeListener: (update: {
|
|
|
added: number[];
|
|
added: number[];
|
|
|
updated: number[];
|
|
updated: number[];
|
|
|
removed: number[];
|
|
removed: number[];
|
|
|
}) => void;
|
|
}) => void;
|
|
|
-
|
|
|
|
|
- private readonly lastActivityMap: Map<number, number> = new Map();
|
|
|
|
|
- private readonly activeTimers: Map<number, ReturnType<typeof setTimeout>> =
|
|
|
|
|
- new Map();
|
|
|
|
|
private readonly topContainer: HTMLElement;
|
|
private readonly topContainer: HTMLElement;
|
|
|
private readonly bottomContainer: HTMLElement;
|
|
private readonly bottomContainer: HTMLElement;
|
|
|
|
|
|
|
@@ -64,52 +68,31 @@ export class YRichCursorsPluginValue {
|
|
|
const remoteClients = clients.filter(
|
|
const remoteClients = clients.filter(
|
|
|
(id) => id !== awareness.doc.clientID,
|
|
(id) => id !== awareness.doc.clientID,
|
|
|
);
|
|
);
|
|
|
- if (remoteClients.length > 0) {
|
|
|
|
|
- // Update activity timestamps for remote clients
|
|
|
|
|
- const now = Date.now();
|
|
|
|
|
- for (const clientId of remoteClients) {
|
|
|
|
|
- // Only track activity for added/updated (not removed)
|
|
|
|
|
- if (!removed.includes(clientId)) {
|
|
|
|
|
- this.lastActivityMap.set(clientId, now);
|
|
|
|
|
-
|
|
|
|
|
- // Reset the inactivity timer
|
|
|
|
|
- const existing = this.activeTimers.get(clientId);
|
|
|
|
|
- if (existing != null) clearTimeout(existing);
|
|
|
|
|
-
|
|
|
|
|
- this.activeTimers.set(
|
|
|
|
|
- clientId,
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- view.dispatch({
|
|
|
|
|
- annotations: [yRichCursorsAnnotation.of([])],
|
|
|
|
|
- });
|
|
|
|
|
- }, 3000),
|
|
|
|
|
- );
|
|
|
|
|
- } else {
|
|
|
|
|
- // Clean up removed clients
|
|
|
|
|
- this.lastActivityMap.delete(clientId);
|
|
|
|
|
- const timer = this.activeTimers.get(clientId);
|
|
|
|
|
- if (timer != null) clearTimeout(timer);
|
|
|
|
|
- this.activeTimers.delete(clientId);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (remoteClients.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ for (const clientId of remoteClients) {
|
|
|
|
|
+ if (!removed.includes(clientId)) {
|
|
|
|
|
+ this.activityTracker.recordActivity(clientId, now, () => {
|
|
|
|
|
+ view.dispatch({
|
|
|
|
|
+ annotations: [yRichCursorsAnnotation.of([])],
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.activityTracker.removeClient(clientId);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- view.dispatch({
|
|
|
|
|
- annotations: [yRichCursorsAnnotation.of([])],
|
|
|
|
|
- });
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ view.dispatch({
|
|
|
|
|
+ annotations: [yRichCursorsAnnotation.of([])],
|
|
|
|
|
+ });
|
|
|
};
|
|
};
|
|
|
this.awareness.on('change', this.changeListener);
|
|
this.awareness.on('change', this.changeListener);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
destroy(): void {
|
|
destroy(): void {
|
|
|
this.awareness.off('change', this.changeListener);
|
|
this.awareness.off('change', this.changeListener);
|
|
|
- // Clear all timers
|
|
|
|
|
- for (const timer of this.activeTimers.values()) {
|
|
|
|
|
- clearTimeout(timer);
|
|
|
|
|
- }
|
|
|
|
|
- this.activeTimers.clear();
|
|
|
|
|
- this.lastActivityMap.clear();
|
|
|
|
|
- // Remove off-screen containers
|
|
|
|
|
|
|
+ this.activityTracker.destroy();
|
|
|
this.topContainer.remove();
|
|
this.topContainer.remove();
|
|
|
this.bottomContainer.remove();
|
|
this.bottomContainer.remove();
|
|
|
}
|
|
}
|
|
@@ -120,36 +103,8 @@ export class YRichCursorsPluginValue {
|
|
|
const ydoc = ytext?.doc as Y.Doc | undefined;
|
|
const ydoc = ytext?.doc as Y.Doc | undefined;
|
|
|
|
|
|
|
|
// Broadcast local cursor position
|
|
// Broadcast local cursor position
|
|
|
- const localState = this.awareness.getLocalState() as AwarenessState | null;
|
|
|
|
|
- if (localState != null && ytext != null) {
|
|
|
|
|
- const hasFocus =
|
|
|
|
|
- viewUpdate.view.hasFocus &&
|
|
|
|
|
- viewUpdate.view.dom.ownerDocument.hasFocus();
|
|
|
|
|
- const sel = hasFocus ? viewUpdate.state.selection.main : null;
|
|
|
|
|
-
|
|
|
|
|
- if (sel != null) {
|
|
|
|
|
- const anchor = Y.createRelativePositionFromTypeIndex(ytext, sel.anchor);
|
|
|
|
|
- const head = Y.createRelativePositionFromTypeIndex(ytext, sel.head);
|
|
|
|
|
-
|
|
|
|
|
- const currentAnchor =
|
|
|
|
|
- localState.cursor?.anchor != null
|
|
|
|
|
- ? Y.createRelativePositionFromJSON(localState.cursor.anchor)
|
|
|
|
|
- : null;
|
|
|
|
|
- const currentHead =
|
|
|
|
|
- localState.cursor?.head != null
|
|
|
|
|
- ? Y.createRelativePositionFromJSON(localState.cursor.head)
|
|
|
|
|
- : null;
|
|
|
|
|
-
|
|
|
|
|
- if (
|
|
|
|
|
- localState.cursor == null ||
|
|
|
|
|
- !Y.compareRelativePositions(currentAnchor, anchor) ||
|
|
|
|
|
- !Y.compareRelativePositions(currentHead, head)
|
|
|
|
|
- ) {
|
|
|
|
|
- this.awareness.setLocalStateField('cursor', { anchor, head });
|
|
|
|
|
- }
|
|
|
|
|
- } else if (localState.cursor != null && hasFocus) {
|
|
|
|
|
- this.awareness.setLocalStateField('cursor', null);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (ytext != null) {
|
|
|
|
|
+ broadcastLocalCursor(viewUpdate, this.awareness, ytext);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Rebuild remote cursor decorations
|
|
// Rebuild remote cursor decorations
|
|
@@ -160,65 +115,44 @@ export class YRichCursorsPluginValue {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const { decorations, aboveIndicators, belowIndicators } =
|
|
|
|
|
+ this.buildRemoteCursors(viewUpdate, ytext, ydoc);
|
|
|
|
|
+
|
|
|
|
|
+ this.decorations = Decoration.set(decorations, true);
|
|
|
|
|
+ this.topContainer.replaceChildren(...aboveIndicators.map(({ el }) => el));
|
|
|
|
|
+ this.bottomContainer.replaceChildren(
|
|
|
|
|
+ ...belowIndicators.map(({ el }) => el),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ this.positionIndicatorsHorizontally(viewUpdate, [
|
|
|
|
|
+ ...aboveIndicators,
|
|
|
|
|
+ ...belowIndicators,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Iterates remote awareness states and builds decorations / off-screen indicators. */
|
|
|
|
|
+ private buildRemoteCursors(
|
|
|
|
|
+ viewUpdate: ViewUpdate,
|
|
|
|
|
+ ytext: Y.Text,
|
|
|
|
|
+ ydoc: Y.Doc,
|
|
|
|
|
+ ): {
|
|
|
|
|
+ decorations: { from: number; to: number; value: Decoration }[];
|
|
|
|
|
+ aboveIndicators: IndicatorEntry[];
|
|
|
|
|
+ belowIndicators: IndicatorEntry[];
|
|
|
|
|
+ } {
|
|
|
const decorations: { from: number; to: number; value: Decoration }[] = [];
|
|
const decorations: { from: number; to: number; value: Decoration }[] = [];
|
|
|
- type IndicatorEntry = { el: HTMLElement; headIndex: number };
|
|
|
|
|
const aboveIndicators: IndicatorEntry[] = [];
|
|
const aboveIndicators: IndicatorEntry[] = [];
|
|
|
const belowIndicators: IndicatorEntry[] = [];
|
|
const belowIndicators: IndicatorEntry[] = [];
|
|
|
const localClientId = this.awareness.doc.clientID;
|
|
const localClientId = this.awareness.doc.clientID;
|
|
|
-
|
|
|
|
|
- const visibleRanges = viewUpdate.view.visibleRanges;
|
|
|
|
|
- const { from: viewportFrom, to: viewportTo } = viewUpdate.view.viewport;
|
|
|
|
|
-
|
|
|
|
|
- // Three classification strategies (chosen once per update call):
|
|
|
|
|
- //
|
|
|
|
|
- // "ranged": visibleRanges is a true sub-range of viewport — CodeMirror's own
|
|
|
|
|
- // scroller is active (e.g. fixed-height editor in tests). Use character-
|
|
|
|
|
- // position bounds derived from visibleRanges.
|
|
|
|
|
- //
|
|
|
|
|
- // "coords": visibleRanges == viewport — the editor expands to full content
|
|
|
|
|
- // height and the *page* handles scrolling (GROWI's production setup). Use
|
|
|
|
|
- // the cursor's actual screen coordinates vs the editor's visible rect
|
|
|
|
|
- // (scrollDOM.getBoundingClientRect clamped to window.innerHeight).
|
|
|
|
|
- //
|
|
|
|
|
- // "none" (degenerate — jsdom with 0-height container): scrollRect.height == 0
|
|
|
|
|
- // so screen coordinates are unreliable. Skip all off-screen classification
|
|
|
|
|
- // and give every cursor a widget decoration, matching pre-task-12 behaviour.
|
|
|
|
|
- const hasVisibleRanges = visibleRanges.length > 0;
|
|
|
|
|
- // rangedMode: visibleRanges is a meaningful sub-range of viewport.
|
|
|
|
|
- // Requires the visible area to be non-empty (to > from) so that a 0-height
|
|
|
|
|
- // editor (jsdom degenerate) doesn't accidentally classify every cursor as
|
|
|
|
|
- // off-screen via a vpTo of 0.
|
|
|
|
|
- const rangedMode =
|
|
|
|
|
- hasVisibleRanges &&
|
|
|
|
|
- visibleRanges[visibleRanges.length - 1].to > visibleRanges[0].from &&
|
|
|
|
|
- (visibleRanges[0].from > viewportFrom ||
|
|
|
|
|
- visibleRanges[visibleRanges.length - 1].to < viewportTo);
|
|
|
|
|
-
|
|
|
|
|
- // For coords mode: compute visible band once before the per-cursor loop.
|
|
|
|
|
- // getBoundingClientRect() is a raw DOM call (not a CodeMirror layout read)
|
|
|
|
|
- // so it is allowed during update(). lineBlockAt() uses the stored height map
|
|
|
|
|
- // and is also safe during update().
|
|
|
|
|
- // When scrollDOMRect.height == 0 (jsdom), screenVisibleBottom == 0 so the
|
|
|
|
|
- // below/above checks never fire and every cursor falls through to a widget.
|
|
|
|
|
- let scrollDOMTop = 0;
|
|
|
|
|
- let scrollDOMBottom = 0;
|
|
|
|
|
- let scrollTop = 0;
|
|
|
|
|
- if (!rangedMode) {
|
|
|
|
|
- const scrollDOMRect = viewUpdate.view.scrollDOM.getBoundingClientRect();
|
|
|
|
|
- scrollDOMTop = scrollDOMRect.top;
|
|
|
|
|
- scrollDOMBottom = scrollDOMRect.bottom;
|
|
|
|
|
- scrollTop = viewUpdate.view.scrollDOM.scrollTop;
|
|
|
|
|
- }
|
|
|
|
|
- const screenVisibleTop = Math.max(scrollDOMTop, 0);
|
|
|
|
|
- const screenVisibleBottom = Math.min(scrollDOMBottom, window.innerHeight);
|
|
|
|
|
-
|
|
|
|
|
- const vpFrom = hasVisibleRanges ? visibleRanges[0].from : viewportFrom;
|
|
|
|
|
- const vpTo = hasVisibleRanges
|
|
|
|
|
- ? visibleRanges[visibleRanges.length - 1].to
|
|
|
|
|
- : viewportTo;
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const ctx = createViewportContext(viewUpdate.view);
|
|
|
const now = Date.now();
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
+ // Build the click handler once (reads ref.current lazily at call time)
|
|
|
|
|
+ const onClickIndicator =
|
|
|
|
|
+ this.scrollCallbackRef != null
|
|
|
|
|
+ ? (id: number) => this.scrollCallbackRef?.current?.(id)
|
|
|
|
|
+ : undefined;
|
|
|
|
|
+
|
|
|
this.awareness.getStates().forEach((rawState, clientId) => {
|
|
this.awareness.getStates().forEach((rawState, clientId) => {
|
|
|
if (clientId === localClientId) return;
|
|
if (clientId === localClientId) return;
|
|
|
|
|
|
|
@@ -248,95 +182,35 @@ export class YRichCursorsPluginValue {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const isActive = now - (this.lastActivityMap.get(clientId) ?? 0) < 3000;
|
|
|
|
|
const headIndex = head.index;
|
|
const headIndex = head.index;
|
|
|
|
|
+ const isActive = this.activityTracker.isActive(clientId, now);
|
|
|
|
|
+ const classification = classifyCursorPosition(
|
|
|
|
|
+ ctx,
|
|
|
|
|
+ viewUpdate.view,
|
|
|
|
|
+ headIndex,
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- // Classify: off-screen (above/below) or in-viewport
|
|
|
|
|
- // Build a click handler from the mutable ref (captured once per indicator build).
|
|
|
|
|
- // Using a closure that reads ref.current at call-time so no extension recreation
|
|
|
|
|
- // is needed when the scroll function changes.
|
|
|
|
|
- const onClickIndicator =
|
|
|
|
|
- this.scrollCallbackRef != null
|
|
|
|
|
- ? (id: number) => this.scrollCallbackRef?.current?.(id)
|
|
|
|
|
- : undefined;
|
|
|
|
|
-
|
|
|
|
|
- if (rangedMode) {
|
|
|
|
|
- if (headIndex < vpFrom) {
|
|
|
|
|
- aboveIndicators.push({
|
|
|
|
|
- el: createOffScreenIndicator({
|
|
|
|
|
- direction: 'above',
|
|
|
|
|
- clientId,
|
|
|
|
|
- color: editors.color,
|
|
|
|
|
- name: editors.name,
|
|
|
|
|
- imageUrlCached: editors.imageUrlCached,
|
|
|
|
|
- isActive,
|
|
|
|
|
- onClick: onClickIndicator,
|
|
|
|
|
- }),
|
|
|
|
|
- headIndex,
|
|
|
|
|
- });
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if (headIndex > vpTo) {
|
|
|
|
|
- belowIndicators.push({
|
|
|
|
|
- el: createOffScreenIndicator({
|
|
|
|
|
- direction: 'below',
|
|
|
|
|
- clientId,
|
|
|
|
|
- color: editors.color,
|
|
|
|
|
- name: editors.name,
|
|
|
|
|
- imageUrlCached: editors.imageUrlCached,
|
|
|
|
|
- isActive,
|
|
|
|
|
- onClick: onClickIndicator,
|
|
|
|
|
- }),
|
|
|
|
|
- headIndex,
|
|
|
|
|
- });
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- // coords mode: compare screen Y of cursor against the editor's visible rect.
|
|
|
|
|
- // Used when visibleRanges == viewport (page-scroll editor, e.g. GROWI).
|
|
|
|
|
- //
|
|
|
|
|
- // lineBlockAt() reads stored heights (safe during update).
|
|
|
|
|
- // When scrollDOMRect.height == 0 (jsdom) both checks below are false
|
|
|
|
|
- // so every cursor falls through to a widget decoration.
|
|
|
|
|
- const lineBlock = viewUpdate.view.lineBlockAt(headIndex);
|
|
|
|
|
- const cursorTop = scrollDOMTop + lineBlock.top - scrollTop;
|
|
|
|
|
- const cursorBottom = scrollDOMTop + lineBlock.bottom - scrollTop;
|
|
|
|
|
-
|
|
|
|
|
- if (cursorBottom < screenVisibleTop) {
|
|
|
|
|
- aboveIndicators.push({
|
|
|
|
|
- el: createOffScreenIndicator({
|
|
|
|
|
- direction: 'above',
|
|
|
|
|
- clientId,
|
|
|
|
|
- color: editors.color,
|
|
|
|
|
- name: editors.name,
|
|
|
|
|
- imageUrlCached: editors.imageUrlCached,
|
|
|
|
|
- isActive,
|
|
|
|
|
- onClick: onClickIndicator,
|
|
|
|
|
- }),
|
|
|
|
|
- headIndex,
|
|
|
|
|
- });
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if (cursorTop > screenVisibleBottom) {
|
|
|
|
|
- belowIndicators.push({
|
|
|
|
|
- el: createOffScreenIndicator({
|
|
|
|
|
- direction: 'below',
|
|
|
|
|
- clientId,
|
|
|
|
|
- color: editors.color,
|
|
|
|
|
- name: editors.name,
|
|
|
|
|
- imageUrlCached: editors.imageUrlCached,
|
|
|
|
|
- isActive,
|
|
|
|
|
- onClick: onClickIndicator,
|
|
|
|
|
- }),
|
|
|
|
|
- headIndex,
|
|
|
|
|
- });
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (classification !== 'in-viewport') {
|
|
|
|
|
+ const target =
|
|
|
|
|
+ classification === 'above' ? aboveIndicators : belowIndicators;
|
|
|
|
|
+ target.push({
|
|
|
|
|
+ el: createOffScreenIndicator({
|
|
|
|
|
+ direction: classification,
|
|
|
|
|
+ clientId,
|
|
|
|
|
+ color: editors.color,
|
|
|
|
|
+ name: editors.name,
|
|
|
|
|
+ imageUrlCached: editors.imageUrlCached,
|
|
|
|
|
+ isActive,
|
|
|
|
|
+ onClick: onClickIndicator,
|
|
|
|
|
+ }),
|
|
|
|
|
+ headIndex,
|
|
|
|
|
+ });
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// In-viewport: render decorations
|
|
// In-viewport: render decorations
|
|
|
- const start = Math.min(anchor.index, head.index);
|
|
|
|
|
- const end = Math.max(anchor.index, head.index);
|
|
|
|
|
|
|
+ const start = Math.min(anchor.index, headIndex);
|
|
|
|
|
+ const end = Math.max(anchor.index, headIndex);
|
|
|
|
|
|
|
|
if (start !== end) {
|
|
if (start !== end) {
|
|
|
decorations.push({
|
|
decorations.push({
|
|
@@ -365,39 +239,38 @@ export class YRichCursorsPluginValue {
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- this.decorations = Decoration.set(decorations, true);
|
|
|
|
|
- this.topContainer.replaceChildren(...aboveIndicators.map(({ el }) => el));
|
|
|
|
|
- this.bottomContainer.replaceChildren(
|
|
|
|
|
- ...belowIndicators.map(({ el }) => el),
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ return { decorations, aboveIndicators, belowIndicators };
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // Position each indicator horizontally at the remote cursor's column.
|
|
|
|
|
- // coordsAtPos reads layout so it must be deferred to the measure phase.
|
|
|
|
|
- const allIndicators = [...aboveIndicators, ...belowIndicators];
|
|
|
|
|
- if (allIndicators.length > 0) {
|
|
|
|
|
- viewUpdate.view.requestMeasure({
|
|
|
|
|
- read: (view) => {
|
|
|
|
|
- const editorLeft = view.dom.getBoundingClientRect().left;
|
|
|
|
|
- return allIndicators.map(({ headIndex: hi }) => {
|
|
|
|
|
- const coords = view.coordsAtPos(hi, 1);
|
|
|
|
|
- if (coords != null) {
|
|
|
|
|
- return coords.left - editorLeft;
|
|
|
|
|
- }
|
|
|
|
|
- // Fallback for virtualised positions (outside CodeMirror's viewport)
|
|
|
|
|
- const line = view.state.doc.lineAt(hi);
|
|
|
|
|
- const col = hi - line.from;
|
|
|
|
|
- const contentLeft =
|
|
|
|
|
- view.contentDOM.getBoundingClientRect().left - editorLeft;
|
|
|
|
|
- return contentLeft + col * view.defaultCharacterWidth;
|
|
|
|
|
- });
|
|
|
|
|
- },
|
|
|
|
|
- write: (positions) => {
|
|
|
|
|
- allIndicators.forEach(({ el }, i) => {
|
|
|
|
|
- el.style.left = `${positions[i]}px`;
|
|
|
|
|
- el.style.transform = 'translateX(-50%)';
|
|
|
|
|
- });
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ /** Defers horizontal positioning to CodeMirror's measure phase. */
|
|
|
|
|
+ private positionIndicatorsHorizontally(
|
|
|
|
|
+ viewUpdate: ViewUpdate,
|
|
|
|
|
+ indicators: IndicatorEntry[],
|
|
|
|
|
+ ): void {
|
|
|
|
|
+ if (indicators.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ viewUpdate.view.requestMeasure({
|
|
|
|
|
+ read: (view) => {
|
|
|
|
|
+ const editorLeft = view.dom.getBoundingClientRect().left;
|
|
|
|
|
+ return indicators.map(({ headIndex }) => {
|
|
|
|
|
+ const coords = view.coordsAtPos(headIndex, 1);
|
|
|
|
|
+ if (coords != null) {
|
|
|
|
|
+ return coords.left - editorLeft;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Fallback for virtualised positions (outside CodeMirror's viewport)
|
|
|
|
|
+ const line = view.state.doc.lineAt(headIndex);
|
|
|
|
|
+ const col = headIndex - line.from;
|
|
|
|
|
+ const contentLeft =
|
|
|
|
|
+ view.contentDOM.getBoundingClientRect().left - editorLeft;
|
|
|
|
|
+ return contentLeft + col * view.defaultCharacterWidth;
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ write: (positions) => {
|
|
|
|
|
+ indicators.forEach(({ el }, i) => {
|
|
|
|
|
+ el.style.left = `${positions[i]}px`;
|
|
|
|
|
+ el.style.transform = 'translateX(-50%)';
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|