2023-09-22 06:43:25 +03:00
|
|
|
import * as decoding from 'lib0/decoding'
|
|
|
|
import * as encoding from 'lib0/encoding'
|
2023-09-26 11:14:56 +03:00
|
|
|
import * as random from 'lib0/random.js'
|
2023-09-22 06:43:25 +03:00
|
|
|
import * as Y from 'yjs'
|
|
|
|
|
|
|
|
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
|
|
|
|
declare const brandExprId: unique symbol
|
|
|
|
export type ExprId = Uuid & { [brandExprId]: never }
|
|
|
|
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
|
|
|
|
|
|
|
|
export interface NodeMetadata {
|
|
|
|
x: number
|
|
|
|
y: number
|
2023-10-02 15:01:03 +03:00
|
|
|
vis?: unknown
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
export class DistributedProject {
|
|
|
|
doc: Y.Doc
|
|
|
|
name: Y.Text
|
|
|
|
modules: Y.Map<Y.Doc>
|
|
|
|
|
|
|
|
constructor(doc: Y.Doc) {
|
|
|
|
this.doc = doc
|
|
|
|
this.name = this.doc.getText('name')
|
|
|
|
this.modules = this.doc.getMap('modules')
|
|
|
|
}
|
|
|
|
|
|
|
|
moduleNames(): string[] {
|
|
|
|
return Array.from(this.modules.keys())
|
|
|
|
}
|
|
|
|
|
|
|
|
findModuleByDocId(id: string): string | null {
|
|
|
|
for (const [name, doc] of this.modules.entries()) {
|
|
|
|
if (doc.guid === id) return name
|
|
|
|
}
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
async openModule(name: string): Promise<DistributedModule | null> {
|
|
|
|
const doc = this.modules.get(name)
|
|
|
|
if (doc == null) return null
|
|
|
|
return await DistributedModule.load(doc)
|
|
|
|
}
|
|
|
|
|
|
|
|
openUnloadedModule(name: string): DistributedModule | null {
|
|
|
|
const doc = this.modules.get(name)
|
|
|
|
if (doc == null) return null
|
|
|
|
return new DistributedModule(doc)
|
|
|
|
}
|
|
|
|
|
|
|
|
createUnloadedModule(name: string, doc: Y.Doc): DistributedModule {
|
|
|
|
this.modules.set(name, doc)
|
|
|
|
return new DistributedModule(doc)
|
|
|
|
}
|
|
|
|
|
|
|
|
async createNewModule(name: string): Promise<DistributedModule> {
|
|
|
|
return this.createUnloadedModule(name, new Y.Doc())
|
|
|
|
}
|
|
|
|
|
|
|
|
async openOrCreateModule(name: string): Promise<DistributedModule> {
|
|
|
|
return (await this.openModule(name)) ?? (await this.createNewModule(name))
|
|
|
|
}
|
|
|
|
|
|
|
|
deleteModule(name: string): void {
|
|
|
|
this.modules.delete(name)
|
|
|
|
}
|
|
|
|
|
|
|
|
dispose(): void {
|
|
|
|
this.doc.destroy()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
export class ModuleDoc {
|
|
|
|
ydoc: Y.Doc
|
2023-09-22 06:43:25 +03:00
|
|
|
contents: Y.Text
|
|
|
|
idMap: Y.Map<Uint8Array>
|
|
|
|
metadata: Y.Map<NodeMetadata>
|
2023-10-02 15:01:03 +03:00
|
|
|
constructor(ydoc: Y.Doc) {
|
|
|
|
this.ydoc = ydoc
|
|
|
|
this.contents = ydoc.getText('contents')
|
|
|
|
this.idMap = ydoc.getMap('idMap')
|
|
|
|
this.metadata = ydoc.getMap('metadata')
|
|
|
|
}
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
export class DistributedModule {
|
|
|
|
doc: ModuleDoc
|
|
|
|
|
|
|
|
static async load(ydoc: Y.Doc): Promise<DistributedModule> {
|
|
|
|
ydoc.load()
|
|
|
|
await ydoc.whenLoaded
|
|
|
|
return new DistributedModule(ydoc)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
constructor(ydoc: Y.Doc) {
|
|
|
|
this.doc = new ModuleDoc(ydoc)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
|
|
|
|
const range = [offset, offset + content.length]
|
2023-09-26 11:14:56 +03:00
|
|
|
const newId = random.uuidv4() as ExprId
|
2023-10-02 15:01:03 +03:00
|
|
|
this.transact(() => {
|
|
|
|
this.doc.contents.insert(offset, content + '\n')
|
|
|
|
const start = Y.createRelativePositionFromTypeIndex(this.doc.contents, range[0]!)
|
|
|
|
const end = Y.createRelativePositionFromTypeIndex(this.doc.contents, range[1]!)
|
|
|
|
this.doc.idMap.set(newId, encodeRange([start, end]))
|
|
|
|
this.doc.metadata.set(newId, meta)
|
2023-09-22 06:43:25 +03:00
|
|
|
})
|
|
|
|
return newId
|
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
deleteNode(id: ExprId): void {
|
|
|
|
const rangeBuffer = this.doc.idMap.get(id)
|
|
|
|
if (rangeBuffer == null) return
|
|
|
|
const [relStart, relEnd] = decodeRange(rangeBuffer)
|
|
|
|
const start = Y.createAbsolutePositionFromRelativePosition(relStart, this.doc.ydoc)?.index
|
|
|
|
const end = Y.createAbsolutePositionFromRelativePosition(relEnd, this.doc.ydoc)?.index
|
|
|
|
if (start == null || end == null) return
|
|
|
|
this.transact(() => {
|
|
|
|
this.doc.idMap.delete(id)
|
|
|
|
this.doc.metadata.delete(id)
|
|
|
|
this.doc.contents.delete(start, end - start)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-09-22 06:43:25 +03:00
|
|
|
replaceExpressionContent(id: ExprId, content: string, range?: ContentRange): void {
|
2023-10-02 15:01:03 +03:00
|
|
|
const rangeBuffer = this.doc.idMap.get(id)
|
2023-09-22 06:43:25 +03:00
|
|
|
if (rangeBuffer == null) return
|
|
|
|
const [relStart, relEnd] = decodeRange(rangeBuffer)
|
2023-10-02 15:01:03 +03:00
|
|
|
const exprStart = Y.createAbsolutePositionFromRelativePosition(relStart, this.doc.ydoc)?.index
|
|
|
|
const exprEnd = Y.createAbsolutePositionFromRelativePosition(relEnd, this.doc.ydoc)?.index
|
2023-09-22 06:43:25 +03:00
|
|
|
if (exprStart == null || exprEnd == null) return
|
|
|
|
const start = range == null ? exprStart : exprStart + range[0]
|
|
|
|
const end = range == null ? exprEnd : exprStart + range[1]
|
|
|
|
if (start > end) throw new Error('Invalid range')
|
|
|
|
if (start < exprStart || end > exprEnd) throw new Error('Range out of bounds')
|
2023-10-02 15:01:03 +03:00
|
|
|
this.transact(() => {
|
2023-09-22 06:43:25 +03:00
|
|
|
if (content.length > 0) {
|
2023-10-02 15:01:03 +03:00
|
|
|
this.doc.contents.insert(start, content)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
if (start !== end) {
|
2023-10-02 15:01:03 +03:00
|
|
|
this.doc.contents.delete(start + content.length, end - start)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
transact<T>(fn: () => T): T {
|
|
|
|
return this.doc.ydoc.transact(fn)
|
|
|
|
}
|
|
|
|
|
2023-09-22 06:43:25 +03:00
|
|
|
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
|
2023-10-02 15:01:03 +03:00
|
|
|
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0 }
|
|
|
|
this.doc.metadata.set(id, { ...existing, ...meta })
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
getIdMap(): IdMap {
|
2023-10-02 15:01:03 +03:00
|
|
|
return new IdMap(this.doc.idMap, this.doc.contents)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
dispose(): void {
|
2023-10-02 15:01:03 +03:00
|
|
|
this.doc.ydoc.destroy()
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export type RelativeRange = [Y.RelativePosition, Y.RelativePosition]
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Accessor for the ID map stored in shared yjs map as relative ranges. Synchronizes the ranges
|
|
|
|
* that were accessed during parsing, throws away stale ones. The text contents is used to translate
|
|
|
|
* the relative ranges to absolute ranges, but it is not modified.
|
|
|
|
*/
|
|
|
|
export class IdMap {
|
|
|
|
private contents: Y.Text
|
|
|
|
private doc: Y.Doc
|
|
|
|
private yMap: Y.Map<Uint8Array>
|
|
|
|
private rangeToExpr: Map<string, ExprId>
|
|
|
|
private accessed: Set<ExprId>
|
|
|
|
private finished: boolean
|
|
|
|
|
|
|
|
constructor(yMap: Y.Map<Uint8Array>, contents: Y.Text) {
|
|
|
|
if (yMap.doc == null) {
|
|
|
|
throw new Error('IdMap must be associated with a document')
|
|
|
|
}
|
|
|
|
this.doc = yMap.doc
|
|
|
|
this.contents = contents
|
|
|
|
this.yMap = yMap
|
|
|
|
this.rangeToExpr = new Map()
|
|
|
|
this.accessed = new Set()
|
|
|
|
|
|
|
|
yMap.forEach((rangeBuffer, expr) => {
|
|
|
|
if (!(isUuid(expr) && rangeBuffer instanceof Uint8Array)) return
|
|
|
|
const indices = this.modelToIndices(rangeBuffer)
|
|
|
|
if (indices == null) return
|
|
|
|
this.rangeToExpr.set(IdMap.keyForRange(indices), expr as ExprId)
|
|
|
|
})
|
|
|
|
|
|
|
|
this.finished = false
|
|
|
|
}
|
|
|
|
|
|
|
|
private static keyForRange(range: [number, number]): string {
|
|
|
|
return `${range[0].toString(16)}:${range[1].toString(16)}`
|
|
|
|
}
|
|
|
|
|
|
|
|
private static rangeForKey(key: string): [number, number] {
|
|
|
|
return key.split(':').map((x) => parseInt(x, 16)) as [number, number]
|
|
|
|
}
|
|
|
|
|
|
|
|
private modelToIndices(rangeBuffer: Uint8Array): [number, number] | null {
|
|
|
|
const [relStart, relEnd] = decodeRange(rangeBuffer)
|
|
|
|
const start = Y.createAbsolutePositionFromRelativePosition(relStart, this.doc)
|
|
|
|
const end = Y.createAbsolutePositionFromRelativePosition(relEnd, this.doc)
|
|
|
|
if (start == null || end == null) return null
|
|
|
|
return [start.index, end.index]
|
|
|
|
}
|
|
|
|
|
|
|
|
insertKnownId(range: [number, number], id: ExprId) {
|
|
|
|
if (this.finished) {
|
|
|
|
throw new Error('IdMap already finished')
|
|
|
|
}
|
|
|
|
|
|
|
|
const key = IdMap.keyForRange(range)
|
|
|
|
this.rangeToExpr.set(key, id)
|
|
|
|
this.accessed.add(id)
|
|
|
|
}
|
|
|
|
|
|
|
|
getOrInsertUniqueId(range: [number, number]): ExprId {
|
|
|
|
if (this.finished) {
|
|
|
|
throw new Error('IdMap already finished')
|
|
|
|
}
|
|
|
|
|
|
|
|
const key = IdMap.keyForRange(range)
|
|
|
|
const val = this.rangeToExpr.get(key)
|
|
|
|
if (val !== undefined) {
|
|
|
|
this.accessed.add(val)
|
|
|
|
return val
|
|
|
|
} else {
|
2023-09-26 11:14:56 +03:00
|
|
|
const newId = random.uuidv4() as ExprId
|
2023-09-22 06:43:25 +03:00
|
|
|
this.rangeToExpr.set(key, newId)
|
|
|
|
this.accessed.add(newId)
|
|
|
|
return newId
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
accessedSoFar(): ReadonlySet<ExprId> {
|
|
|
|
return this.accessed
|
|
|
|
}
|
|
|
|
|
|
|
|
toRawRanges(): Record<string, [number, number]> {
|
|
|
|
const ranges: Record<string, [number, number]> = {}
|
|
|
|
for (const [key, expr] of this.rangeToExpr.entries()) {
|
|
|
|
ranges[expr] = IdMap.rangeForKey(key)
|
|
|
|
}
|
|
|
|
return ranges
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Finish accessing or modifying ID map. Synchronizes the accessed keys back to the shared map,
|
|
|
|
* removes keys that were present previously, but were not accessed.
|
|
|
|
*
|
|
|
|
* Can be called at most once. After calling this method, the ID map is no longer usable.
|
|
|
|
*/
|
|
|
|
finishAndSynchronize(): void {
|
|
|
|
if (this.finished) {
|
|
|
|
throw new Error('IdMap already finished')
|
|
|
|
}
|
|
|
|
this.finished = true
|
|
|
|
|
|
|
|
const doc = this.doc
|
|
|
|
|
|
|
|
doc.transact(() => {
|
|
|
|
this.yMap.forEach((_, expr) => {
|
|
|
|
// Expressions that were accessed and present in the map are guaranteed to match. There is
|
|
|
|
// no mechanism for modifying them, so we don't need to check for equality. We only need to
|
|
|
|
// delete the expressions ones that are not used anymore.
|
|
|
|
if (!this.accessed.delete(expr as ExprId)) {
|
|
|
|
this.yMap.delete(expr)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
this.rangeToExpr.forEach((expr, key) => {
|
|
|
|
// For all remaining expressions, we need to write them into the map.
|
|
|
|
if (!this.accessed.has(expr)) return
|
|
|
|
const range = IdMap.rangeForKey(key)
|
|
|
|
const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0])
|
|
|
|
const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1])
|
|
|
|
const encoded = encodeRange([start, end])
|
|
|
|
this.yMap.set(expr, encoded)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function encodeRange(range: RelativeRange): Uint8Array {
|
|
|
|
const encoder = encoding.createEncoder()
|
|
|
|
const start = Y.encodeRelativePosition(range[0])
|
|
|
|
const end = Y.encodeRelativePosition(range[1])
|
|
|
|
encoding.writeUint8(encoder, start.length)
|
|
|
|
encoding.writeUint8Array(encoder, start)
|
|
|
|
encoding.writeUint8(encoder, end.length)
|
|
|
|
encoding.writeUint8Array(encoder, end)
|
|
|
|
return encoding.toUint8Array(encoder)
|
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
export function decodeRange(buffer: Uint8Array): RelativeRange {
|
2023-09-22 06:43:25 +03:00
|
|
|
const decoder = decoding.createDecoder(buffer)
|
|
|
|
const startLen = decoding.readUint8(decoder)
|
|
|
|
const start = decoding.readUint8Array(decoder, startLen)
|
|
|
|
const endLen = decoding.readUint8(decoder)
|
|
|
|
const end = decoding.readUint8Array(decoder, endLen)
|
|
|
|
return [Y.decodeRelativePosition(start), Y.decodeRelativePosition(end)]
|
|
|
|
}
|
|
|
|
|
|
|
|
const uuidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/
|
2023-10-02 12:03:47 +03:00
|
|
|
export function isUuid(x: unknown): x is Uuid {
|
2023-09-22 06:43:25 +03:00
|
|
|
return typeof x === 'string' && x.length === 36 && uuidRegex.test(x)
|
|
|
|
}
|
|
|
|
|
|
|
|
export type ContentRange = [number, number]
|
|
|
|
|
|
|
|
export function rangeEncloses(a: ContentRange, b: ContentRange): boolean {
|
|
|
|
return a[0] <= b[0] && a[1] >= b[1]
|
|
|
|
}
|
|
|
|
|
|
|
|
export function rangeIntersects(a: ContentRange, b: ContentRange): boolean {
|
|
|
|
return a[0] <= b[1] && a[1] >= b[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
if (import.meta.vitest) {
|
|
|
|
const { test, expect } = import.meta.vitest
|
|
|
|
type RangeTest = { a: ContentRange; b: ContentRange }
|
|
|
|
|
|
|
|
const equalRanges: RangeTest[] = [
|
|
|
|
{ a: [0, 0], b: [0, 0] },
|
|
|
|
{ a: [0, 1], b: [0, 1] },
|
|
|
|
{ a: [-5, 5], b: [-5, 5] },
|
|
|
|
]
|
|
|
|
|
|
|
|
const totalOverlap: RangeTest[] = [
|
|
|
|
{ a: [0, 1], b: [0, 0] },
|
|
|
|
{ a: [0, 2], b: [2, 2] },
|
|
|
|
{ a: [-1, 1], b: [1, 1] },
|
|
|
|
{ a: [0, 2], b: [0, 1] },
|
|
|
|
{ a: [-10, 10], b: [-3, 7] },
|
|
|
|
{ a: [0, 5], b: [1, 2] },
|
|
|
|
{ a: [3, 5], b: [3, 4] },
|
|
|
|
]
|
|
|
|
|
|
|
|
const reverseTotalOverlap: RangeTest[] = totalOverlap.map(({ a, b }) => ({ a: b, b: a }))
|
|
|
|
|
|
|
|
const noOverlap: RangeTest[] = [
|
|
|
|
{ a: [0, 1], b: [2, 3] },
|
|
|
|
{ a: [0, 1], b: [-1, -1] },
|
|
|
|
{ a: [5, 6], b: [2, 3] },
|
|
|
|
{ a: [0, 2], b: [-2, -1] },
|
|
|
|
{ a: [-5, -3], b: [9, 10] },
|
|
|
|
{ a: [-3, 2], b: [3, 4] },
|
|
|
|
]
|
|
|
|
|
|
|
|
const partialOverlap: RangeTest[] = [
|
|
|
|
{ a: [0, 3], b: [-1, 1] },
|
|
|
|
{ a: [0, 1], b: [-1, 0] },
|
|
|
|
{ a: [0, 0], b: [-1, 0] },
|
|
|
|
{ a: [0, 2], b: [1, 4] },
|
|
|
|
{ a: [-8, 0], b: [0, 10] },
|
|
|
|
]
|
|
|
|
|
|
|
|
test.each([...equalRanges, ...totalOverlap])('Range $a should enclose $b', ({ a, b }) =>
|
|
|
|
expect(rangeEncloses(a, b)).toBe(true),
|
|
|
|
)
|
|
|
|
test.each([...noOverlap, ...partialOverlap, ...reverseTotalOverlap])(
|
|
|
|
'Range $a should not enclose $b',
|
|
|
|
({ a, b }) => expect(rangeEncloses(a, b)).toBe(false),
|
|
|
|
)
|
|
|
|
test.each([...equalRanges, ...totalOverlap, ...reverseTotalOverlap, ...partialOverlap])(
|
|
|
|
'Range $a should intersect $b',
|
|
|
|
({ a, b }) => expect(rangeIntersects(a, b)).toBe(true),
|
|
|
|
)
|
|
|
|
test.each([...noOverlap])('Range $a should not intersect $b', ({ a, b }) =>
|
|
|
|
expect(rangeIntersects(a, b)).toBe(false),
|
|
|
|
)
|
|
|
|
}
|