fix in db and collaborate
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyOperationToDocument,
|
||||
compareOperationKeys,
|
||||
composeOperations,
|
||||
documentAfterPending,
|
||||
identityOperation,
|
||||
normalizeOperation,
|
||||
operationBaseLength,
|
||||
operationFromEdit,
|
||||
operationTargetLength,
|
||||
transformOperations,
|
||||
} from "@rustpad/collaboration";
|
||||
import { parseAuthorship } from "@rustpad/authorship";
|
||||
|
||||
function hasTextEffect(operation) {
|
||||
return normalizeOperation(operation).components.some(component => component.kind === "insert" || component.kind === "delete");
|
||||
}
|
||||
|
||||
function mergeOwnerReplacements(previous = [], next = []) {
|
||||
const replacements = new Map();
|
||||
for (const item of [...previous, ...next]) {
|
||||
const owner = String(item?.owner || "");
|
||||
const replacement = String(item?.replacement || "");
|
||||
if (owner && replacement) replacements.set(owner, { owner, replacement });
|
||||
}
|
||||
return [...replacements.values()];
|
||||
}
|
||||
|
||||
function pendingEnvelope(clientId, updateId, operation, ownerReplacements = []) {
|
||||
return {
|
||||
clientId,
|
||||
updateId,
|
||||
operation: normalizeOperation(operation),
|
||||
ownerReplacements: mergeOwnerReplacements([], ownerReplacements),
|
||||
};
|
||||
}
|
||||
|
||||
function serverEnvelope(message) {
|
||||
return {
|
||||
baseRevisionId: Number(message?.base_revision_id ?? message?.baseRevisionId),
|
||||
revisionId: Number(message?.revision_id ?? message?.revisionId),
|
||||
clientId: String(message?.client_id ?? message?.clientId ?? ""),
|
||||
updateId: Number(message?.update_id ?? message?.updateId ?? 0),
|
||||
operation: normalizeOperation(message?.operation),
|
||||
ownerReplacements: mergeOwnerReplacements([], message?.owner_replacements ?? message?.ownerReplacements ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export class CollaborationRevisionGapError extends Error {
|
||||
constructor(expected, actual) {
|
||||
super(`Collaborative revision gap: expected ${expected}, received ${actual}`);
|
||||
this.name = "CollaborationRevisionGapError";
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
}
|
||||
|
||||
export class CollaborationSession {
|
||||
constructor(clientId) {
|
||||
this.clientId = String(clientId || "");
|
||||
this.ready = false;
|
||||
this.serverContent = "";
|
||||
this.serverOwnerMap = "[]";
|
||||
this.revisionId = 0;
|
||||
this.outstanding = null;
|
||||
this.buffer = null;
|
||||
this.nextUpdateId = 1;
|
||||
}
|
||||
|
||||
initialize(content, ownerMap, revisionId, { clearPending = true } = {}) {
|
||||
this.serverContent = String(content || "");
|
||||
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
|
||||
this.revisionId = Number(revisionId) || 0;
|
||||
if (clearPending) {
|
||||
this.outstanding = null;
|
||||
this.buffer = null;
|
||||
}
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
localDocument() {
|
||||
return documentAfterPending(
|
||||
this.serverContent,
|
||||
this.serverOwnerMap,
|
||||
this.outstanding,
|
||||
this.buffer,
|
||||
);
|
||||
}
|
||||
|
||||
hasPending() {
|
||||
return Boolean(this.outstanding || this.buffer);
|
||||
}
|
||||
|
||||
queue(operation, ownerReplacements = []) {
|
||||
operation = normalizeOperation(operation);
|
||||
const replacements = mergeOwnerReplacements([], ownerReplacements);
|
||||
if (!hasTextEffect(operation) && !replacements.length) return null;
|
||||
|
||||
const localLength = this.localDocument().content.length;
|
||||
if (operationBaseLength(operation) !== localLength) {
|
||||
throw new Error("Local operation base length does not match the collaborative document");
|
||||
}
|
||||
|
||||
if (!this.buffer) {
|
||||
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
|
||||
} else {
|
||||
this.buffer.operation = composeOperations(this.buffer.operation, operation);
|
||||
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
|
||||
}
|
||||
|
||||
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
|
||||
throw new Error("Buffered operation target length does not match the collaborative document");
|
||||
}
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
sendable() {
|
||||
if (!this.ready || this.outstanding || !this.buffer) return null;
|
||||
return {
|
||||
baseRevisionId: this.revisionId,
|
||||
updateId: this.buffer.updateId,
|
||||
operation: this.buffer.operation,
|
||||
ownerReplacements: this.buffer.ownerReplacements,
|
||||
};
|
||||
}
|
||||
|
||||
markSent(updateId) {
|
||||
if (this.outstanding || !this.buffer || this.buffer.updateId !== Number(updateId)) return false;
|
||||
this.outstanding = this.buffer;
|
||||
this.buffer = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
integrate(message) {
|
||||
const remote = serverEnvelope(message);
|
||||
if (!Number.isFinite(remote.baseRevisionId) || !Number.isFinite(remote.revisionId)) {
|
||||
throw new Error("Collaborative update is missing revision metadata");
|
||||
}
|
||||
if (remote.revisionId <= this.revisionId) return { duplicate: true, ownAck: false, remote: false };
|
||||
if (remote.baseRevisionId !== this.revisionId) {
|
||||
throw new CollaborationRevisionGapError(this.revisionId, remote.baseRevisionId);
|
||||
}
|
||||
|
||||
const nextServer = applyOperationToDocument(
|
||||
this.serverContent,
|
||||
this.serverOwnerMap,
|
||||
remote.operation,
|
||||
remote.ownerReplacements,
|
||||
);
|
||||
const ownAck = Boolean(
|
||||
this.outstanding
|
||||
&& remote.clientId === this.clientId
|
||||
&& remote.updateId === this.outstanding.updateId
|
||||
);
|
||||
|
||||
if (ownAck) {
|
||||
this.outstanding = null;
|
||||
} else {
|
||||
let remoteForPending = remote.operation;
|
||||
if (this.outstanding) {
|
||||
const outstandingBeforeRemote = compareOperationKeys(this.outstanding, remote) < 0;
|
||||
const [outstandingPrime, remotePrime] = transformOperations(
|
||||
this.outstanding.operation,
|
||||
remoteForPending,
|
||||
outstandingBeforeRemote,
|
||||
);
|
||||
this.outstanding.operation = outstandingPrime;
|
||||
remoteForPending = remotePrime;
|
||||
}
|
||||
if (this.buffer) {
|
||||
const bufferBeforeRemote = compareOperationKeys(this.buffer, remote) < 0;
|
||||
const [bufferPrime] = transformOperations(
|
||||
this.buffer.operation,
|
||||
remoteForPending,
|
||||
bufferBeforeRemote,
|
||||
);
|
||||
this.buffer.operation = bufferPrime;
|
||||
}
|
||||
}
|
||||
|
||||
this.serverContent = nextServer.content;
|
||||
this.serverOwnerMap = nextServer.ownerMap;
|
||||
this.revisionId = remote.revisionId;
|
||||
return { duplicate: false, ownAck, remote: !ownAck };
|
||||
}
|
||||
|
||||
resynchronize(message) {
|
||||
const canonicalContent = String(message?.content || "");
|
||||
const canonicalOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
|
||||
const canonicalRevisionId = Number(message?.revision_id) || 0;
|
||||
const outstanding = this.outstanding;
|
||||
const buffer = this.buffer;
|
||||
const local = this.localDocument();
|
||||
const acknowledgedIds = (message?.acknowledged_update_ids || [])
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
const acknowledgedThrough = acknowledgedIds.length ? Math.max(...acknowledgedIds) : 0;
|
||||
const messageClientId = String(message?.client_id ?? message?.clientId ?? "");
|
||||
const messageUpdateId = Number(message?.update_id ?? message?.updateId ?? 0);
|
||||
const outstandingAcknowledged = Boolean(
|
||||
outstanding
|
||||
&& (
|
||||
acknowledgedThrough >= outstanding.updateId
|
||||
|| (messageClientId === this.clientId && messageUpdateId === outstanding.updateId)
|
||||
)
|
||||
);
|
||||
const pendingOwnerReplacements = mergeOwnerReplacements(
|
||||
outstanding?.ownerReplacements || [],
|
||||
buffer?.ownerReplacements || [],
|
||||
);
|
||||
let replayOperation = null;
|
||||
let replayOwnerReplacements = [];
|
||||
|
||||
if (this.ready && (outstanding || buffer)) {
|
||||
const canonicalAuthorship = parseAuthorship(canonicalContent, canonicalOwnerMap);
|
||||
if (outstandingAcknowledged) {
|
||||
if (buffer) {
|
||||
const afterOutstanding = applyOperationToDocument(
|
||||
this.serverContent,
|
||||
this.serverOwnerMap,
|
||||
outstanding.operation,
|
||||
outstanding.ownerReplacements,
|
||||
);
|
||||
const missedRemote = operationFromEdit(
|
||||
afterOutstanding.content,
|
||||
canonicalContent,
|
||||
canonicalAuthorship,
|
||||
);
|
||||
[replayOperation] = transformOperations(buffer.operation, missedRemote, false);
|
||||
replayOwnerReplacements = buffer.ownerReplacements;
|
||||
}
|
||||
} else {
|
||||
const localAuthorship = parseAuthorship(local.content, local.ownerMap);
|
||||
const localOperation = operationFromEdit(this.serverContent, local.content, localAuthorship);
|
||||
const missedRemote = operationFromEdit(
|
||||
this.serverContent,
|
||||
canonicalContent,
|
||||
canonicalAuthorship,
|
||||
);
|
||||
[replayOperation] = transformOperations(localOperation, missedRemote, false);
|
||||
replayOwnerReplacements = pendingOwnerReplacements;
|
||||
}
|
||||
}
|
||||
|
||||
this.initialize(canonicalContent, canonicalOwnerMap, canonicalRevisionId);
|
||||
if (replayOperation || replayOwnerReplacements.length) {
|
||||
this.queue(
|
||||
replayOperation || identityOperation(canonicalContent.length),
|
||||
replayOwnerReplacements,
|
||||
);
|
||||
}
|
||||
return {
|
||||
replayed: Boolean(this.buffer),
|
||||
outstandingAcknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
adoptCanonicalSnapshot(content, ownerMap, revisionId) {
|
||||
revisionId = Number(revisionId) || 0;
|
||||
if (revisionId !== this.revisionId) {
|
||||
throw new CollaborationRevisionGapError(this.revisionId, revisionId);
|
||||
}
|
||||
if (String(content || "") !== this.serverContent) {
|
||||
throw new Error("Canonical collaborative content does not match applied operations");
|
||||
}
|
||||
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
|
||||
|
||||
function utf16Length(value) {
|
||||
return String(value || "").length;
|
||||
}
|
||||
|
||||
function normalizeOwnerSpans(spans, length) {
|
||||
const result = [];
|
||||
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
|
||||
for (const source of sorted) {
|
||||
const start = Math.max(0, Math.min(length, Number(source?.start) || 0));
|
||||
const end = Math.max(start, Math.min(length, Number(source?.end) || 0));
|
||||
const owner = String(source?.owner || "");
|
||||
if (!owner || end <= start) continue;
|
||||
const previous = result.at(-1);
|
||||
if (previous && previous.owner === owner && start <= previous.end) {
|
||||
previous.end = Math.max(previous.end, end);
|
||||
continue;
|
||||
}
|
||||
const clippedStart = previous && start < previous.end ? previous.end : start;
|
||||
if (end > clippedStart) result.push({ start: clippedStart, end, owner });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sliceOwnerSpans(spans, start, length) {
|
||||
const end = start + length;
|
||||
return normalizeOwnerSpans((spans || []).flatMap(span => {
|
||||
const overlapStart = Math.max(start, span.start);
|
||||
const overlapEnd = Math.min(end, span.end);
|
||||
return overlapEnd > overlapStart
|
||||
? [{ start: overlapStart - start, end: overlapEnd - start, owner: span.owner }]
|
||||
: [];
|
||||
}), length);
|
||||
}
|
||||
|
||||
function shiftOwnerSpans(spans, offset) {
|
||||
return (spans || []).map(span => ({ start: span.start + offset, end: span.end + offset, owner: span.owner }));
|
||||
}
|
||||
|
||||
function appendComponent(components, component) {
|
||||
if (!component) return;
|
||||
if (component.kind === "retain" || component.kind === "delete") {
|
||||
const count = Number(component.count) || 0;
|
||||
if (count <= 0) return;
|
||||
const previous = components.at(-1);
|
||||
if (previous?.kind === component.kind) previous.count += count;
|
||||
else components.push({ kind: component.kind, count });
|
||||
return;
|
||||
}
|
||||
if (component.kind !== "insert") throw new Error("Unknown operation component");
|
||||
const text = String(component.text || "");
|
||||
if (!text) return;
|
||||
const owners = normalizeOwnerSpans(component.owners, text.length);
|
||||
const previous = components.at(-1);
|
||||
if (previous?.kind === "insert") {
|
||||
const offset = previous.text.length;
|
||||
previous.text += text;
|
||||
previous.owners = normalizeOwnerSpans([
|
||||
...(previous.owners || []),
|
||||
...shiftOwnerSpans(owners, offset),
|
||||
], previous.text.length);
|
||||
} else components.push({ kind: "insert", text, owners });
|
||||
}
|
||||
|
||||
export function normalizeOperation(operation) {
|
||||
const components = [];
|
||||
for (const component of operation?.components || []) appendComponent(components, component);
|
||||
return { components };
|
||||
}
|
||||
|
||||
export function operationBaseLength(operation) {
|
||||
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||
length + (component.kind === "retain" || component.kind === "delete" ? component.count : 0), 0);
|
||||
}
|
||||
|
||||
export function operationTargetLength(operation) {
|
||||
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||
length + (component.kind === "retain" ? component.count : component.kind === "insert" ? component.text.length : 0), 0);
|
||||
}
|
||||
|
||||
export function identityOperation(length) {
|
||||
return normalizeOperation({ components: length > 0 ? [{ kind: "retain", count: length }] : [] });
|
||||
}
|
||||
|
||||
function isUtf16Boundary(value, offset) {
|
||||
if (offset <= 0 || offset >= value.length) return true;
|
||||
const previous = value.charCodeAt(offset - 1);
|
||||
const next = value.charCodeAt(offset);
|
||||
return !(previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff);
|
||||
}
|
||||
|
||||
export function operationFromEdit(previousText, nextText, nextAuthorship) {
|
||||
previousText = String(previousText || "");
|
||||
nextText = String(nextText || "");
|
||||
let prefix = 0;
|
||||
const shared = Math.min(previousText.length, nextText.length);
|
||||
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
|
||||
while (prefix > 0 && (!isUtf16Boundary(previousText, prefix) || !isUtf16Boundary(nextText, prefix))) prefix--;
|
||||
|
||||
let oldSuffix = previousText.length;
|
||||
let newSuffix = nextText.length;
|
||||
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
|
||||
oldSuffix--;
|
||||
newSuffix--;
|
||||
}
|
||||
while (!isUtf16Boundary(previousText, oldSuffix) || !isUtf16Boundary(nextText, newSuffix)) {
|
||||
oldSuffix++;
|
||||
newSuffix++;
|
||||
}
|
||||
|
||||
const components = [];
|
||||
appendComponent(components, { kind: "retain", count: prefix });
|
||||
appendComponent(components, { kind: "delete", count: oldSuffix - prefix });
|
||||
const insertedText = nextText.slice(prefix, newSuffix);
|
||||
appendComponent(components, {
|
||||
kind: "insert",
|
||||
text: insertedText,
|
||||
owners: sliceOwnerSpans(nextAuthorship?.spans || [], prefix, insertedText.length),
|
||||
});
|
||||
appendComponent(components, { kind: "retain", count: previousText.length - oldSuffix });
|
||||
return { components };
|
||||
}
|
||||
|
||||
class OperationCursor {
|
||||
constructor(operation) {
|
||||
this.components = normalizeOperation(operation).components;
|
||||
this.index = 0;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
get current() { return this.components[this.index] || null; }
|
||||
get kind() { return this.current?.kind || null; }
|
||||
get remaining() {
|
||||
const component = this.current;
|
||||
if (!component) return 0;
|
||||
return (component.kind === "insert" ? component.text.length : component.count) - this.offset;
|
||||
}
|
||||
|
||||
take(count = this.remaining) {
|
||||
const component = this.current;
|
||||
if (!component || count <= 0 || count > this.remaining) throw new Error("Invalid operation cursor read");
|
||||
let part;
|
||||
if (component.kind === "insert") {
|
||||
part = {
|
||||
kind: "insert",
|
||||
text: component.text.slice(this.offset, this.offset + count),
|
||||
owners: sliceOwnerSpans(component.owners, this.offset, count),
|
||||
};
|
||||
} else part = { kind: component.kind, count };
|
||||
this.offset += count;
|
||||
if (this.offset === (component.kind === "insert" ? component.text.length : component.count)) {
|
||||
this.index++;
|
||||
this.offset = 0;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
export function composeOperations(first, second) {
|
||||
first = normalizeOperation(first);
|
||||
second = normalizeOperation(second);
|
||||
if (operationTargetLength(first) !== operationBaseLength(second)) throw new Error("Cannot compose operations with different lengths");
|
||||
const left = new OperationCursor(first);
|
||||
const right = new OperationCursor(second);
|
||||
const components = [];
|
||||
|
||||
while (left.current || right.current) {
|
||||
if (right.kind === "insert") {
|
||||
appendComponent(components, right.take());
|
||||
continue;
|
||||
}
|
||||
if (left.kind === "delete") {
|
||||
appendComponent(components, left.take());
|
||||
continue;
|
||||
}
|
||||
if (!left.current || !right.current) throw new Error("Incomplete operation composition");
|
||||
const count = Math.min(left.remaining, right.remaining);
|
||||
const leftKind = left.kind;
|
||||
const rightKind = right.kind;
|
||||
if (leftKind === "retain" && rightKind === "retain") {
|
||||
appendComponent(components, { kind: "retain", count });
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else if (leftKind === "retain" && rightKind === "delete") {
|
||||
appendComponent(components, { kind: "delete", count });
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else if (leftKind === "insert" && rightKind === "retain") {
|
||||
appendComponent(components, left.take(count));
|
||||
right.take(count);
|
||||
} else if (leftKind === "insert" && rightKind === "delete") {
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else throw new Error("Unsupported operation composition");
|
||||
}
|
||||
return { components };
|
||||
}
|
||||
|
||||
export function transformOperations(leftOperation, rightOperation, leftBeforeRight = true) {
|
||||
leftOperation = normalizeOperation(leftOperation);
|
||||
rightOperation = normalizeOperation(rightOperation);
|
||||
if (operationBaseLength(leftOperation) !== operationBaseLength(rightOperation)) throw new Error("Cannot transform operations with different base lengths");
|
||||
const left = new OperationCursor(leftOperation);
|
||||
const right = new OperationCursor(rightOperation);
|
||||
const leftPrime = [];
|
||||
const rightPrime = [];
|
||||
|
||||
while (left.current || right.current) {
|
||||
if (left.kind === "insert" && (right.kind !== "insert" || leftBeforeRight)) {
|
||||
const part = left.take();
|
||||
appendComponent(leftPrime, part);
|
||||
appendComponent(rightPrime, { kind: "retain", count: part.text.length });
|
||||
continue;
|
||||
}
|
||||
if (right.kind === "insert") {
|
||||
const part = right.take();
|
||||
appendComponent(leftPrime, { kind: "retain", count: part.text.length });
|
||||
appendComponent(rightPrime, part);
|
||||
continue;
|
||||
}
|
||||
if (!left.current || !right.current) throw new Error("Incomplete operation transform");
|
||||
const count = Math.min(left.remaining, right.remaining);
|
||||
if (left.kind === "retain" && right.kind === "retain") {
|
||||
appendComponent(leftPrime, { kind: "retain", count });
|
||||
appendComponent(rightPrime, { kind: "retain", count });
|
||||
} else if (left.kind === "delete" && right.kind === "retain") {
|
||||
appendComponent(leftPrime, { kind: "delete", count });
|
||||
} else if (left.kind === "retain" && right.kind === "delete") {
|
||||
appendComponent(rightPrime, { kind: "delete", count });
|
||||
} else if (left.kind !== "delete" || right.kind !== "delete") throw new Error("Unsupported operation transform");
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
}
|
||||
return [{ components: leftPrime }, { components: rightPrime }];
|
||||
}
|
||||
|
||||
export function applyOperation(text, operation) {
|
||||
text = String(text || "");
|
||||
operation = normalizeOperation(operation);
|
||||
if (operationBaseLength(operation) !== text.length) throw new Error("Operation base length does not match document");
|
||||
let cursor = 0;
|
||||
let result = "";
|
||||
for (const component of operation.components) {
|
||||
if (component.kind === "retain") {
|
||||
result += text.slice(cursor, cursor + component.count);
|
||||
cursor += component.count;
|
||||
} else if (component.kind === "delete") cursor += component.count;
|
||||
else result += component.text;
|
||||
}
|
||||
if (cursor !== text.length) throw new Error("Operation did not consume the whole document");
|
||||
return result;
|
||||
}
|
||||
|
||||
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
|
||||
const sourceEnd = sourceStart + length;
|
||||
for (const span of spans || []) {
|
||||
const start = Math.max(sourceStart, span.start);
|
||||
const end = Math.min(sourceEnd, span.end);
|
||||
if (end > start) target.push({ start: outputStart + start - sourceStart, end: outputStart + end - sourceStart, owner: span.owner });
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOperationToDocument(content, ownerMap, operation, ownerReplacements = []) {
|
||||
content = String(content || "");
|
||||
operation = normalizeOperation(operation);
|
||||
if (operationBaseLength(operation) !== content.length) throw new Error("Operation base length does not match document");
|
||||
const sourceModel = parseAuthorship(content, ownerMap);
|
||||
const outputSpans = [];
|
||||
let sourceOffset = 0;
|
||||
let outputOffset = 0;
|
||||
let nextContent = "";
|
||||
for (const component of operation.components) {
|
||||
if (component.kind === "retain") {
|
||||
nextContent += content.slice(sourceOffset, sourceOffset + component.count);
|
||||
copyRetainedSpans(outputSpans, sourceModel.spans, sourceOffset, component.count, outputOffset);
|
||||
sourceOffset += component.count;
|
||||
outputOffset += component.count;
|
||||
} else if (component.kind === "delete") sourceOffset += component.count;
|
||||
else {
|
||||
nextContent += component.text;
|
||||
outputSpans.push(...shiftOwnerSpans(component.owners, outputOffset));
|
||||
outputOffset += component.text.length;
|
||||
}
|
||||
}
|
||||
if (sourceOffset !== content.length) throw new Error("Operation did not consume the whole document");
|
||||
const replacementMap = new Map((ownerReplacements || []).map(item => [String(item?.owner || ""), String(item?.replacement || "")]));
|
||||
const replacedSpans = outputSpans.map(span => {
|
||||
const identity = String(span.owner || "").split("\u001f", 1)[0];
|
||||
const replacement = replacementMap.get(identity);
|
||||
return replacement ? { ...span, owner: replacement } : span;
|
||||
});
|
||||
const model = { version: 2, spans: normalizeOwnerSpans(replacedSpans, nextContent.length) };
|
||||
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
|
||||
}
|
||||
|
||||
export function operationEquals(left, right) {
|
||||
return JSON.stringify(normalizeOperation(left)) === JSON.stringify(normalizeOperation(right));
|
||||
}
|
||||
|
||||
export function compareOperationKeys(left, right) {
|
||||
const leftClient = String(left?.clientId || left?.client_id || "");
|
||||
const rightClient = String(right?.clientId || right?.client_id || "");
|
||||
if (leftClient !== rightClient) return leftClient < rightClient ? -1 : 1;
|
||||
const leftUpdate = Number(left?.updateId ?? left?.update_id ?? 0);
|
||||
const rightUpdate = Number(right?.updateId ?? right?.update_id ?? 0);
|
||||
return leftUpdate === rightUpdate ? 0 : leftUpdate < rightUpdate ? -1 : 1;
|
||||
}
|
||||
|
||||
export function documentAfterPending(serverContent, serverOwnerMap, outstanding, buffer) {
|
||||
let documentState = { content: serverContent, ownerMap: serverOwnerMap };
|
||||
for (const pending of [outstanding, buffer]) {
|
||||
if (!pending) continue;
|
||||
documentState = applyOperationToDocument(
|
||||
documentState.content,
|
||||
documentState.ownerMap,
|
||||
pending.operation,
|
||||
pending.ownerReplacements,
|
||||
);
|
||||
}
|
||||
return documentState;
|
||||
}
|
||||
+197
-20
@@ -11,6 +11,8 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
|
||||
installGlobalDiagnostics();
|
||||
|
||||
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
|
||||
import { identityOperation, operationFromEdit } from "@rustpad/collaboration";
|
||||
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||
@@ -37,7 +39,11 @@ export function startNoteEditor(adapter) {
|
||||
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
|
||||
const shareToken = new URLSearchParams(location.search).get("share");
|
||||
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", lastServerContent = "", lastServerOwnerMap = "[]", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
|
||||
const collaborationClientId = typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID().replaceAll("-", "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
const collaboration = new CollaborationSession(collaborationClientId);
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false;
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
const editHistory = {
|
||||
entries: [],
|
||||
@@ -971,6 +977,53 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
function renderNow() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
|
||||
const render = createRenderQueue(renderNow);
|
||||
function queueCollaborativeOperation(operation, ownerReplacements = []) {
|
||||
if (!collaboration.ready || !canEditDocument()) return false;
|
||||
try {
|
||||
collaboration.queue(operation, ownerReplacements);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to queue collaborative operation", error);
|
||||
saveState.textContent = "Synchronization error";
|
||||
socket?.stop();
|
||||
queueMicrotask(connect);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flushCollaborativeUpdate() {
|
||||
clearTimeout(saveTimer);
|
||||
if (!canEditDocument()) {
|
||||
saveState.textContent = "Read only";
|
||||
return;
|
||||
}
|
||||
const pending = collaboration.sendable();
|
||||
if (!pending) {
|
||||
if (collaboration.outstanding) {
|
||||
flushRequested = Boolean(collaboration.buffer);
|
||||
saveState.textContent = "Saving…";
|
||||
} else if (!collaboration.buffer) {
|
||||
flushRequested = false;
|
||||
saveState.textContent = "Changes are saved automatically";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sent = socket?.update(
|
||||
pending.baseRevisionId,
|
||||
pending.updateId,
|
||||
pending.operation,
|
||||
pending.ownerReplacements,
|
||||
);
|
||||
if (!sent) {
|
||||
flushRequested = true;
|
||||
saveState.textContent = "Waiting for connection…";
|
||||
return;
|
||||
}
|
||||
collaboration.markSent(pending.updateId);
|
||||
flushRequested = false;
|
||||
saveState.textContent = "Saving…";
|
||||
}
|
||||
|
||||
function scheduleDocumentSave() {
|
||||
clearTimeout(saveTimer);
|
||||
if (!canEditDocument()) {
|
||||
@@ -978,10 +1031,19 @@ export function startNoteEditor(adapter) {
|
||||
return;
|
||||
}
|
||||
saveState.textContent = "Saving…";
|
||||
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
|
||||
saveTimer = setTimeout(flushCollaborativeUpdate, 250);
|
||||
}
|
||||
|
||||
function queueOwnerReplacement(owner, replacement) {
|
||||
if (!owner || !replacement) return;
|
||||
if (queueCollaborativeOperation(identityOperation(editor.value.length), [{ owner, replacement }])) {
|
||||
scheduleDocumentSave();
|
||||
}
|
||||
}
|
||||
|
||||
function restoreHistorySnapshot(snapshot) {
|
||||
if (!snapshot || !canEditDocument()) return;
|
||||
const previous = editor.value;
|
||||
const maxOffset = snapshot.content.length;
|
||||
const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset);
|
||||
const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset);
|
||||
@@ -996,6 +1058,7 @@ export function startNoteEditor(adapter) {
|
||||
syncEditorLayers();
|
||||
applyingHistory = false;
|
||||
editor.focus({ preventScroll: true });
|
||||
queueCollaborativeOperation(operationFromEdit(previous, snapshot.content, authorship));
|
||||
scheduleDocumentSave();
|
||||
}
|
||||
function activeView() {
|
||||
@@ -1044,9 +1107,7 @@ export function startNoteEditor(adapter) {
|
||||
if (snapshot.raw) editRawPreviewLine(target);
|
||||
}
|
||||
|
||||
function applyRemote(content, ownerMap) {
|
||||
lastServerContent = content;
|
||||
if (ownerMap != null) lastServerOwnerMap = ownerMap;
|
||||
function applyEditorDocument(content, ownerMap, { resetHistory = true } = {}) {
|
||||
if (content === editor.value) {
|
||||
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
|
||||
previousContent = content;
|
||||
@@ -1066,7 +1127,8 @@ export function startNoteEditor(adapter) {
|
||||
editor.scrollTop = scrollTop;
|
||||
editor.scrollLeft = scrollLeft;
|
||||
applyingRemote = false;
|
||||
editHistory.reset();
|
||||
if (resetHistory) editHistory.reset();
|
||||
else editHistory.syncCurrent();
|
||||
render();
|
||||
editor.scrollTop = scrollTop;
|
||||
editor.scrollLeft = scrollLeft;
|
||||
@@ -1075,6 +1137,108 @@ export function startNoteEditor(adapter) {
|
||||
requestAnimationFrame(revealLinkedLine);
|
||||
}
|
||||
|
||||
function applyCollaborativeView({ resetHistory = false } = {}) {
|
||||
const local = collaboration.localDocument();
|
||||
applyEditorDocument(local.content, local.ownerMap, { resetHistory });
|
||||
}
|
||||
|
||||
function recoverCollaborativeSnapshot(message, reason = "resync") {
|
||||
try {
|
||||
const result = collaboration.resynchronize(message);
|
||||
if (result.replayed) {
|
||||
flushRequested = true;
|
||||
toast(reason === "resync" ? "Connection state was resynchronized; pending edits were merged." : "A missed update was merged with your local edits.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to transform pending changes during resynchronization", error);
|
||||
const localContent = editor.value;
|
||||
const serverContent = String(message?.content || "");
|
||||
const serverOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
|
||||
const revisionId = Number(message?.revision_id) || 0;
|
||||
const separator = serverContent && !serverContent.endsWith("\n") ? "\n\n" : "";
|
||||
const recoveryContent = `${serverContent}${separator}<!-- Rustpad local recovery -->\n${localContent}`;
|
||||
collaboration.initialize(serverContent, serverOwnerMap, revisionId);
|
||||
if (new TextEncoder().encode(recoveryContent).length <= 2_000_000) {
|
||||
collaboration.queue(
|
||||
operationFromEdit(serverContent, recoveryContent, parseAuthorship(recoveryContent, "[]")),
|
||||
);
|
||||
flushRequested = true;
|
||||
toast("A synchronization conflict was preserved as a local recovery block.");
|
||||
} else {
|
||||
toast("Synchronization failed because the recoverable document exceeds the size limit.");
|
||||
}
|
||||
}
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
if (collaboration.buffer) scheduleDocumentSave();
|
||||
}
|
||||
|
||||
|
||||
function integrateCollaborativeEnvelope(message, { renderView = true } = {}) {
|
||||
const result = collaboration.integrate(message);
|
||||
if (result.duplicate) return result;
|
||||
if (message.content != null && String(message.content) !== collaboration.serverContent) {
|
||||
throw new Error("Server snapshot does not match the collaborative operation");
|
||||
}
|
||||
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map);
|
||||
if (renderView) applyCollaborativeView({ resetHistory: result.remote });
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleCollaborativeAuthentication(message) {
|
||||
const revisionId = Number(message.revision_id) || 0;
|
||||
if (!collaboration.ready) {
|
||||
collaboration.initialize(message.content, message.owner_map, revisionId);
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
return;
|
||||
}
|
||||
if (message.resync_required) {
|
||||
recoverCollaborativeSnapshot(message, "resync");
|
||||
return;
|
||||
}
|
||||
let resetHistory = false;
|
||||
try {
|
||||
for (const operation of message.catchup_operations || []) {
|
||||
const result = integrateCollaborativeEnvelope(operation, { renderView: false });
|
||||
resetHistory ||= result.remote;
|
||||
}
|
||||
collaboration.adoptCanonicalSnapshot(message.content, message.owner_map, revisionId);
|
||||
applyCollaborativeView({ resetHistory });
|
||||
} catch (error) {
|
||||
console.error("Failed to apply collaborative catch-up", error);
|
||||
recoverCollaborativeSnapshot(message, error instanceof CollaborationRevisionGapError ? "gap" : "resync");
|
||||
}
|
||||
}
|
||||
|
||||
function resendOutstandingUpdate() {
|
||||
const pending = collaboration.outstanding;
|
||||
if (!pending || !canEditDocument()) return;
|
||||
const sent = socket?.update(
|
||||
collaboration.revisionId,
|
||||
pending.updateId,
|
||||
pending.operation,
|
||||
pending.ownerReplacements,
|
||||
);
|
||||
if (sent) saveState.textContent = "Saving…";
|
||||
else flushRequested = true;
|
||||
}
|
||||
|
||||
function handleCollaborativeDocument(message) {
|
||||
try {
|
||||
const result = integrateCollaborativeEnvelope(message);
|
||||
if (result.duplicate) return;
|
||||
const timestamp = new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
if (collaboration.hasPending()) saveState.textContent = "Saving…";
|
||||
else saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${timestamp}`;
|
||||
if (result.ownAck && collaboration.buffer && flushRequested) flushCollaborativeUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to integrate collaborative update", error);
|
||||
saveState.textContent = "Resynchronizing…";
|
||||
socket?.stop();
|
||||
queueMicrotask(connect);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { loadFiles } = bindNoteFiles({
|
||||
editor, toast, getAccessToken: () => accessToken,
|
||||
canDelete: () => Boolean(info?.can_delete_files),
|
||||
@@ -1139,6 +1303,8 @@ export function startNoteEditor(adapter) {
|
||||
color: currentUserColor() || null,
|
||||
sessionToken: null,
|
||||
guestId: getGuestId(),
|
||||
clientId: collaborationClientId,
|
||||
getKnownRevision: () => collaboration.ready ? collaboration.revisionId : null,
|
||||
onStatus: handleSocketStatus,
|
||||
onAuthenticated: message => {
|
||||
resourceUnlocked = true;
|
||||
@@ -1146,14 +1312,21 @@ export function startNoteEditor(adapter) {
|
||||
const readOnly = message.access_level === "read_only";
|
||||
setDocumentReadOnly(readOnly);
|
||||
accessLevel.textContent = readOnly ? "Access: read only" : "Access: full";
|
||||
applyRemote(message.content, message.owner_map);
|
||||
saveState.textContent = readOnly ? "Read only" : "Changes are saved automatically";
|
||||
if (!readOnly) editor.focus();
|
||||
},
|
||||
onDocument: message => {
|
||||
applyRemote(message.content, message.owner_map);
|
||||
saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
|
||||
if (readOnly) {
|
||||
collaboration.initialize(message.content, message.owner_map, message.revision_id);
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
flushRequested = false;
|
||||
saveState.textContent = "Read only";
|
||||
return;
|
||||
}
|
||||
handleCollaborativeAuthentication(message);
|
||||
if (collaboration.outstanding) resendOutstandingUpdate();
|
||||
else if (collaboration.buffer) flushCollaborativeUpdate();
|
||||
else saveState.textContent = "Changes are saved automatically";
|
||||
editor.focus();
|
||||
},
|
||||
onDocument: handleCollaborativeDocument,
|
||||
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
|
||||
onPresence: updatePresence,
|
||||
onLatency: updateLatency,
|
||||
onDiagnostics: renderConnectionDiagnostics,
|
||||
@@ -1166,7 +1339,9 @@ export function startNoteEditor(adapter) {
|
||||
toast(friendly);
|
||||
accessLevel.textContent = "Access: read only";
|
||||
setDocumentReadOnly(true, "Read only — changes not saved");
|
||||
applyRemote(lastServerContent, lastServerOwnerMap);
|
||||
collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId);
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
flushRequested = false;
|
||||
queueMicrotask(connect);
|
||||
return;
|
||||
}
|
||||
@@ -1725,7 +1900,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(noteColor);
|
||||
if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
|
||||
}
|
||||
userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value));
|
||||
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
|
||||
@@ -1739,7 +1914,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(currentUserColor() || null);
|
||||
if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||
if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement);
|
||||
toast("Global profile color restored");
|
||||
});
|
||||
editor.addEventListener("keydown", continueIndentation);
|
||||
@@ -1749,17 +1924,19 @@ export function startNoteEditor(adapter) {
|
||||
});
|
||||
editor.addEventListener("input", event => {
|
||||
if (!canEditDocument() && !applyingRemote) {
|
||||
applyRemote(lastServerContent, lastServerOwnerMap);
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
saveState.textContent = "Read only";
|
||||
return;
|
||||
}
|
||||
const previous = previousContent;
|
||||
const nextContent = editor.value;
|
||||
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
|
||||
authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner());
|
||||
authorship = adoptCurrentOwnerAliases(authorship, previous.length);
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
|
||||
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
|
||||
previousContent = nextContent;
|
||||
render();
|
||||
if (applyingRemote || applyingHistory) return;
|
||||
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
|
||||
editHistory.record(event.inputType || "");
|
||||
scheduleDocumentSave();
|
||||
});
|
||||
|
||||
+12
-1
@@ -97,6 +97,8 @@ class RoomSocket {
|
||||
guest_id: this.guestId || null,
|
||||
color: this.color || null,
|
||||
diagnostics: this.clientDiagnostics(),
|
||||
client_id: this.clientId || null,
|
||||
known_revision_id: this.getKnownRevision?.() ?? null,
|
||||
});
|
||||
this.emitDiagnostics();
|
||||
});
|
||||
@@ -130,6 +132,7 @@ class RoomSocket {
|
||||
return;
|
||||
}
|
||||
if (message.type === "document") this.onDocument?.(message);
|
||||
if (message.type === "resync") this.onResync?.(message);
|
||||
if (message.type === "presence") this.onPresence?.(message.users || []);
|
||||
if (message.type === "chat") this.onChat?.(message);
|
||||
if (message.type === "pong") {
|
||||
@@ -302,7 +305,15 @@ class RoomSocket {
|
||||
return true;
|
||||
}
|
||||
|
||||
update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
|
||||
update(baseRevisionId, updateId, operation, ownerReplacements = []) {
|
||||
return this.send({
|
||||
type: "update",
|
||||
base_revision_id: baseRevisionId,
|
||||
update_id: updateId,
|
||||
operation,
|
||||
owner_replacements: ownerReplacements,
|
||||
});
|
||||
}
|
||||
chat(text) { this.send({ type: "chat", text }); }
|
||||
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user