0.10.0: Archify Sync, optional live collaboration on a model
Several people edit one model at once through a server you run yourself (archify-vscode-ext-sync); the files stay ordinary files on each disk. Sign-in through the browser, offline-first with later merge, teammates' cursors in the Source view and text editors, and a Shared section on the model page with people, versions, Compare and Restore. Without a server nothing changes: the sync code is a separate bundle that loads only when a model is shared, after a sign-in, or when a sync command or invite link is used.
This commit is contained in:
1 parent
a9021312b0
commit
c99f3e5fe8
27 files changed
+2899
-14
No files matched your search
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { repoRootFor } from '../archify/runtime';
|
||||
import { log } from '../util/log';
|
||||
import type { LiveAction } from '../sync/liveBridge';
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'hash'; hash: string }
|
||||
@@ -14,6 +15,8 @@ export type WebviewMessage =
|
||||
| { type: 'openDecision'; file: string }
|
||||
| { type: 'showGraph'; query?: string }
|
||||
| { type: 'showModel' }
|
||||
| { type: 'liveReady' }
|
||||
| { type: 'liveAction'; action: LiveAction }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -100,6 +103,8 @@ export async function handleWebviewMessage(
|
||||
case 'openDiagram':
|
||||
case 'showGraph':
|
||||
case 'showModel':
|
||||
case 'liveReady':
|
||||
case 'liveAction':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -618,6 +618,11 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
||||
|
||||
var body = el('section', 'archify-model archify-model-body');
|
||||
// Live: filled by the host when the model is shared (Archify Sync); hidden otherwise.
|
||||
var live = el('div', 'archify-live');
|
||||
live.id = 'archify-live';
|
||||
live.hidden = true;
|
||||
body.appendChild(live);
|
||||
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
||||
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
||||
body.appendChild(diagramsPanel);
|
||||
@@ -635,6 +640,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
var button = el('button', 'archify-model-open');
|
||||
button.type = 'button';
|
||||
button.title = diagram.path;
|
||||
button.setAttribute('data-path', diagram.path);
|
||||
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
||||
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
||||
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
||||
@@ -677,6 +683,99 @@ function bridgeScript(options: PageOptions): string {
|
||||
decisionsPanel.appendChild(decisionList);
|
||||
}
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
post({ type: 'liveReady' });
|
||||
}
|
||||
|
||||
var LIVE_STATE = {
|
||||
online: 'Live',
|
||||
connecting: 'Connecting\u2026',
|
||||
offline: 'Offline \u00b7 edits are kept here and sent when the server is back',
|
||||
'signed-out': 'Signed out \u00b7 edits are kept here until you sign in',
|
||||
forbidden: 'No access \u00b7 you are no longer a member of this model',
|
||||
outdated: 'The server needs a newer extension',
|
||||
};
|
||||
var KIND_LABEL = { snapshot: 'snapshot', publish: 'published', restore: 'restored' };
|
||||
|
||||
function liveButton(label, title, action) {
|
||||
var button = el('button', 'archify-model-used-link archify-live-action', label);
|
||||
button.type = 'button';
|
||||
button.title = title;
|
||||
button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'liveAction', action: action }); });
|
||||
return button;
|
||||
}
|
||||
|
||||
function when(iso) {
|
||||
var date = new Date(iso);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
var seconds = (Date.now() - date.getTime()) / 1000;
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + ' h ago';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// The Live section of a shared model: who is here, the state, and versions.
|
||||
function renderLive(info) {
|
||||
var holder = document.getElementById('archify-live');
|
||||
if (!holder) return;
|
||||
holder.textContent = '';
|
||||
document.querySelectorAll('.archify-live-here').forEach(function (n) { n.remove(); });
|
||||
if (!info) { holder.hidden = true; return; }
|
||||
holder.hidden = false;
|
||||
var others = info.people.filter(function (p) { return !p.me; });
|
||||
var livePanel = panel('live', 'archify-model-live is-' + info.state, 'Shared \u00b7 ' + info.name, others.length ? others.length + 1 + ' here' : info.host, true);
|
||||
var status = el('div', 'archify-live-status');
|
||||
status.appendChild(el('span', 'archify-live-dot'));
|
||||
status.appendChild(el('span', 'archify-live-state', (LIVE_STATE[info.state] || info.state) + (info.state === 'online' ? ' on ' + info.host : '')));
|
||||
if (info.state === 'signed-out') status.appendChild(liveButton('Sign in', 'Sign in to ' + info.host, { kind: 'signIn' }));
|
||||
var actions = el('span', 'archify-live-actions');
|
||||
actions.appendChild(liveButton('Publish version', 'Name the current state as a version', { kind: 'publish' }));
|
||||
actions.appendChild(liveButton('Copy invite link', 'Copy a link that lets someone join this model', { kind: 'invite' }));
|
||||
status.appendChild(actions);
|
||||
livePanel.appendChild(status);
|
||||
|
||||
var people = el('ul', 'archify-live-people');
|
||||
info.people.forEach(function (person) {
|
||||
var item = el('li', 'archify-live-person' + (person.me ? ' is-me' : ''));
|
||||
item.appendChild(el('strong', '', person.me ? person.name + ' (you)' : person.name));
|
||||
if (person.file) item.appendChild(el('small', '', person.file));
|
||||
people.appendChild(item);
|
||||
});
|
||||
livePanel.appendChild(people);
|
||||
|
||||
// Who is in which diagram, on the diagram rows.
|
||||
others.forEach(function (person) {
|
||||
if (!person.file) return;
|
||||
document.querySelectorAll('.archify-model-open[data-path]').forEach(function (button) {
|
||||
if (button.getAttribute('data-path') !== person.file) return;
|
||||
var meta = button.querySelector('.archify-live-here');
|
||||
if (!meta) { meta = el('span', 'archify-live-here', ''); button.appendChild(meta); }
|
||||
meta.textContent = (meta.textContent ? meta.textContent.replace(/ here$/, '') + ', ' : '') + person.name + ' here';
|
||||
});
|
||||
});
|
||||
|
||||
var versions = info.versions || [];
|
||||
var head = el('div', 'archify-live-versions-head');
|
||||
head.appendChild(el('span', 'archify-model-summary-title', 'Versions'));
|
||||
if (versions.length > 8) head.appendChild(liveButton('All ' + versions.length + '\u2026', 'Pick from every version', { kind: 'versions' }));
|
||||
livePanel.appendChild(head);
|
||||
if (info.versionsProblem) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', info.versionsProblem));
|
||||
else if (!versions.length) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', 'No versions yet. One is taken on its own after a pause in editing.'));
|
||||
var list = el('ul', 'archify-live-versions');
|
||||
versions.slice(0, 8).forEach(function (v) {
|
||||
var item = el('li', 'archify-live-version is-' + v.kind);
|
||||
item.appendChild(el('strong', 'archify-live-version-label', v.label));
|
||||
item.appendChild(el('span', 'archify-decision-status archify-live-kind', KIND_LABEL[v.kind] + (v.restoredFrom ? ' ' + v.restoredFrom : '')));
|
||||
if (v.name) item.appendChild(el('span', 'archify-live-version-name', v.name));
|
||||
item.appendChild(el('small', 'archify-live-version-when', when(v.at)));
|
||||
var buttons = el('span', 'archify-live-actions');
|
||||
buttons.appendChild(liveButton('Compare', 'Compare ' + v.label + ' with the files here', { kind: 'compare', version: v.id }));
|
||||
buttons.appendChild(liveButton('Restore', 'Bring back ' + v.label + ' for everyone, as a new version', { kind: 'restore', version: v.id }));
|
||||
item.appendChild(buttons);
|
||||
list.appendChild(item);
|
||||
});
|
||||
livePanel.appendChild(list);
|
||||
holder.appendChild(livePanel);
|
||||
}
|
||||
|
||||
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
||||
@@ -775,6 +874,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
||||
if (message.type === 'setView') setSource(message.view === 'source');
|
||||
if (message.type === 'live') renderLive(message.live);
|
||||
});
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
@@ -854,6 +954,26 @@ const MODEL_STYLE = `
|
||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
/* Live: a shared model's state, people and versions. */
|
||||
.archify-live-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||
.archify-live-dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--m-muted); flex: none; }
|
||||
.archify-model-live.is-online .archify-live-dot { background: var(--vscode-testing-iconPassed, #3fb950); }
|
||||
.archify-model-live.is-signed-out .archify-live-dot, .archify-model-live.is-forbidden .archify-live-dot, .archify-model-live.is-outdated .archify-live-dot { background: var(--vscode-editorWarning-foreground, #d29922); }
|
||||
.archify-live-actions { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
|
||||
.archify-live .archify-live-action { font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||
.archify-live-people { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0.9rem 0; padding: 0; }
|
||||
.archify-live-person { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.2rem 0.6rem; border: 1px solid var(--m-border); border-radius: 999px; font-size: 0.8125rem; }
|
||||
.archify-live-person small { color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; font-size: 0.6875rem; }
|
||||
.archify-live-person.is-me { opacity: 0.75; }
|
||||
.archify-live-here { font-size: 0.6875rem; color: var(--m-accent); font-weight: 600; }
|
||||
.archify-live-versions-head { display: flex; align-items: center; gap: 0.5rem; margin: 0.9rem 0.9rem 0.35rem; font-size: 0.8125rem; font-weight: 600; }
|
||||
.archify-live-note { margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||
.archify-live-versions { list-style: none; margin: 0 0.9rem; padding: 0; display: grid; gap: 0.3rem; }
|
||||
.archify-live-version { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.4rem 0.7rem; border: 1px solid var(--m-border); border-radius: 0.5rem; font-size: 0.8125rem; }
|
||||
.archify-live-version-label { font-family: ui-monospace, Menlo, monospace; }
|
||||
.archify-live-version.is-publish .archify-live-version-label { color: var(--m-accent); }
|
||||
.archify-live-version-name { font-weight: 600; }
|
||||
.archify-live-version-when { color: var(--m-muted); }
|
||||
/* Decisions: rows, chips and status badges. */
|
||||
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
||||
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
||||
|
||||
@@ -3,9 +3,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
||||
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
|
||||
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
|
||||
import { Annotation, EditorState, Transaction } from '@codemirror/state';
|
||||
import { Annotation, EditorState, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
@@ -30,6 +33,72 @@ export interface SourceDiagnostic {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** A teammate's cursor (Archify Sync), as offsets into the editor's text. */
|
||||
export interface RemoteCursor {
|
||||
name: string;
|
||||
color: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
const setRemoteCursors = StateEffect.define<RemoteCursor[]>();
|
||||
|
||||
class RemoteCaret extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly color: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: RemoteCaret): boolean {
|
||||
return other.name === this.name && other.color === this.color;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'cm-remote-caret';
|
||||
caret.style.borderLeftColor = this.color;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-remote-label';
|
||||
label.textContent = this.name;
|
||||
label.style.backgroundColor = this.color;
|
||||
caret.appendChild(label);
|
||||
return caret;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet {
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
const clamp = (n: number) => Math.max(0, Math.min(n, length));
|
||||
for (const c of cursors) {
|
||||
const anchor = clamp(c.anchor);
|
||||
const head = clamp(c.head);
|
||||
if (anchor !== head) {
|
||||
ranges.push(
|
||||
Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)),
|
||||
);
|
||||
}
|
||||
ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head));
|
||||
}
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
/** Teammates' carets and selections; they move with the text as anyone types. */
|
||||
const remoteCursors = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(decorations, tr) {
|
||||
decorations = decorations.map(tr.changes);
|
||||
for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length);
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
/** Marks transactions that carry text from the host, so they are not sent back. */
|
||||
const fromHost = Annotation.define<boolean>();
|
||||
|
||||
@@ -56,6 +125,22 @@ const theme = EditorView.theme({
|
||||
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
|
||||
'.cm-diagnostic': { padding: '4px 8px' },
|
||||
'.cm-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' },
|
||||
'.cm-remote-label': {
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '-2px',
|
||||
padding: '0 4px',
|
||||
borderRadius: '3px 3px 3px 0',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: '5',
|
||||
opacity: '0.9',
|
||||
},
|
||||
'.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' },
|
||||
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
|
||||
});
|
||||
|
||||
@@ -71,7 +156,13 @@ export class SourceEditor {
|
||||
readonly view: EditorView;
|
||||
private schemaKey: string | undefined;
|
||||
|
||||
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
onEdit: (changes: TextChange[], text: string) => void,
|
||||
/** The main selection moved (or the editor got focus); offsets into the text. */
|
||||
onSelection: (anchor: number, head: number) => void = () => undefined,
|
||||
) {
|
||||
this.view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
@@ -105,7 +196,12 @@ export class SourceEditor {
|
||||
stateExtensions(undefined as never),
|
||||
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
|
||||
hoverTooltip(yamlSchemaHover()),
|
||||
remoteCursors,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) {
|
||||
const main = update.state.selection.main;
|
||||
onSelection(main.anchor, main.head);
|
||||
}
|
||||
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
|
||||
const changes: TextChange[] = [];
|
||||
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
|
||||
@@ -125,6 +221,11 @@ export class SourceEditor {
|
||||
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
|
||||
}
|
||||
|
||||
/** Teammates' cursors, from the host; they replace the ones shown. */
|
||||
setRemoteCursors(cursors: RemoteCursor[]): void {
|
||||
this.view.dispatch({ effects: setRemoteCursors.of(cursors) });
|
||||
}
|
||||
|
||||
/** The schema that completion and hover read; `undefined` turns them off. */
|
||||
setSchema(key: string | undefined, schema: object | undefined): void {
|
||||
if (key === this.schemaKey) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ArchifyHost } from '../host';
|
||||
import { TextChange, minimalChange } from '../../util/textDiff';
|
||||
import { SourceDiagnostic, SourceEditor } from './editor';
|
||||
import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor';
|
||||
|
||||
/**
|
||||
* The Source view of an Archify page: the file's YAML in an editor, in the
|
||||
@@ -15,7 +15,8 @@ type HostMessage =
|
||||
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
|
||||
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
|
||||
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
|
||||
| { type: 'sourceDirty'; dirty: boolean };
|
||||
| { type: 'sourceDirty'; dirty: boolean }
|
||||
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
|
||||
|
||||
const STYLE = `
|
||||
html.archify-source-on .container > :not(.header):not(#archify-source),
|
||||
@@ -54,7 +55,10 @@ class SourcePane {
|
||||
/** The text the host last knew the page to have; edits are sent against it. */
|
||||
private text = '';
|
||||
private diagnostics: SourceDiagnostic[] = [];
|
||||
private cursors: RemoteCursor[] = [];
|
||||
private shown = false;
|
||||
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private selection: { anchor: number; head: number } | undefined;
|
||||
|
||||
constructor(private readonly host: ArchifyHost) {
|
||||
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
|
||||
@@ -139,8 +143,14 @@ class SourcePane {
|
||||
this.build();
|
||||
if (!this.editor) {
|
||||
this.text = message.text;
|
||||
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
|
||||
this.editor = new SourceEditor(
|
||||
this.editorHost,
|
||||
message.text,
|
||||
(changes, text) => this.onEdit(changes, text),
|
||||
(anchor, head) => this.onSelection(anchor, head),
|
||||
);
|
||||
this.editor.setDiagnostics(this.diagnostics);
|
||||
this.editor.setRemoteCursors(this.cursors);
|
||||
this.fit();
|
||||
if (this.shown) this.editor.focus();
|
||||
} else {
|
||||
@@ -162,9 +172,23 @@ class SourcePane {
|
||||
case 'sourceDirty':
|
||||
this.renderState(message.dirty);
|
||||
return;
|
||||
case 'sourceCursors':
|
||||
this.cursors = message.cursors;
|
||||
this.editor?.setRemoteCursors(message.cursors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
|
||||
private onSelection(anchor: number, head: number): void {
|
||||
this.selection = { anchor, head };
|
||||
if (this.selectionTimer) return;
|
||||
this.selectionTimer = setTimeout(() => {
|
||||
this.selectionTimer = undefined;
|
||||
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private onEdit(changes: TextChange[], text: string): void {
|
||||
const base = this.text;
|
||||
this.text = text;
|
||||
|
||||
Reference in new issue
Block a user