| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { WebSocket } from "ws"; |
| import { HocuspocusProvider } from "@hocuspocus/provider"; |
| import * as Y from "yjs"; |
|
|
| |
| |
| |
| if (!(globalThis as unknown as { WebSocket?: unknown }).WebSocket) { |
| (globalThis as unknown as { WebSocket: unknown }).WebSocket = WebSocket; |
| } |
|
|
| |
| |
| |
|
|
| export interface PersonaUser { |
| name: string; |
| color: string; |
| avatarUrl?: string; |
| } |
|
|
| export interface PersonaHandle { |
| ydoc: Y.Doc; |
| provider: HocuspocusProvider; |
| |
| |
| |
| fragment: Y.XmlFragment; |
| |
| |
| |
| citationsMap: Y.Map<unknown>; |
| |
| |
| |
| settingsMap: Y.Map<unknown>; |
| |
| |
| |
| frontmatter: { |
| scalars: Y.Map<unknown>; |
| authors: Y.Array<Author>; |
| affiliations: Y.Array<Affiliation>; |
| links: Y.Array<unknown>; |
| }; |
| |
| |
| |
| destroy: () => void; |
| } |
|
|
| export interface Author { |
| name: string; |
| url?: string; |
| affiliations: number[]; |
| } |
|
|
| export interface Affiliation { |
| name: string; |
| url?: string; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export async function connectPersona(args: { |
| url: string; |
| docName: string; |
| user: PersonaUser; |
| token?: string; |
| syncTimeoutMs?: number; |
| }): Promise<PersonaHandle> { |
| const ydoc = new Y.Doc(); |
| const provider = new HocuspocusProvider({ |
| url: args.url, |
| name: args.docName, |
| document: ydoc, |
| token: args.token ?? "", |
| }); |
|
|
| |
| |
| |
| |
| |
| provider.awareness?.setLocalStateField("user", args.user); |
|
|
| await new Promise<void>((resolve, reject) => { |
| const timer = setTimeout( |
| () => reject(new Error(`persona "${args.user.name}" sync timeout`)), |
| args.syncTimeoutMs ?? 15_000, |
| ); |
| provider.on("synced", () => { |
| clearTimeout(timer); |
| resolve(); |
| }); |
| }); |
|
|
| const fragment = ydoc.getXmlFragment("default"); |
| const citationsMap = ydoc.getMap("citations"); |
| const settingsMap = ydoc.getMap("settings"); |
| const frontmatter = { |
| scalars: ydoc.getMap("frontmatter"), |
| authors: ydoc.getArray<Author>("frontmatter.authors"), |
| affiliations: ydoc.getArray<Affiliation>("frontmatter.affiliations"), |
| links: ydoc.getArray<unknown>("frontmatter.links"), |
| }; |
|
|
| const destroy = () => { |
| try { |
| provider.destroy(); |
| } catch { |
| |
| } |
| try { |
| ydoc.destroy(); |
| } catch { |
| |
| } |
| }; |
|
|
| return { |
| ydoc, |
| provider, |
| fragment, |
| citationsMap, |
| settingsMap, |
| frontmatter, |
| destroy, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export type InlineItem = |
| | { type: "text"; text: string; marks?: InlineMarks } |
| | { type: "inlineMath"; latex: string } |
| | { type: "citation"; key: string; label: string }; |
|
|
| export interface InlineMarks { |
| bold?: boolean; |
| italic?: boolean; |
| underline?: boolean; |
| code?: boolean; |
| link?: { |
| href: string; |
| target?: string; |
| rel?: string; |
| class?: string; |
| title?: string | null; |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function marksToAttrs( |
| marks: InlineMarks | undefined, |
| ): Record<string, unknown> | undefined { |
| if (!marks) return undefined; |
| const out: Record<string, unknown> = {}; |
| if (marks.bold) out.bold = {}; |
| if (marks.italic) out.italic = {}; |
| if (marks.underline) out.underline = {}; |
| if (marks.code) out.code = {}; |
| if (marks.link) { |
| out.link = { |
| href: marks.link.href, |
| target: marks.link.target ?? "_blank", |
| rel: marks.link.rel ?? "noopener noreferrer nofollow", |
| class: marks.link.class ?? "editor-link", |
| title: marks.link.title ?? null, |
| }; |
| } |
| return Object.keys(out).length ? out : undefined; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function appendInline( |
| paragraph: Y.XmlElement, |
| items: InlineItem[], |
| ): void { |
| for (const item of items) { |
| if (item.type === "text") { |
| const attrs = marksToAttrs(item.marks); |
| |
| |
| |
| |
| const last = paragraph.toArray().at(-1); |
| if (last instanceof Y.XmlText) { |
| last.insert(last.length, item.text, attrs); |
| } else { |
| |
| |
| |
| |
| const t = new Y.XmlText(); |
| paragraph.push([t]); |
| t.insert(0, item.text, attrs); |
| } |
| } else if (item.type === "inlineMath") { |
| const el = new Y.XmlElement("inlineMath"); |
| paragraph.push([el]); |
| el.setAttribute("latex", item.latex); |
| } else if (item.type === "citation") { |
| const el = new Y.XmlElement("citation"); |
| paragraph.push([el]); |
| el.setAttribute("key", item.key); |
| el.setAttribute("label", item.label); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildEmptyParagraph(): Y.XmlElement { |
| return new Y.XmlElement("paragraph"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function pushParagraphInto( |
| parent: Y.XmlFragment | Y.XmlElement, |
| items: InlineItem[] = [], |
| ): Y.XmlElement { |
| const el = new Y.XmlElement("paragraph"); |
| parent.push([el]); |
| if (items.length) appendInline(el, items); |
| return el; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export function findHeadingIndex( |
| frag: Y.XmlFragment, |
| text: string, |
| level: number = 2, |
| ): number { |
| const wanted = text.trim().toLowerCase(); |
| const children = frag.toArray(); |
| for (let i = 0; i < children.length; i++) { |
| const c = children[i]; |
| if (!(c instanceof Y.XmlElement)) continue; |
| if (c.nodeName !== "heading") continue; |
| if (Number(c.getAttribute("level")) !== level) continue; |
| const textContent = c |
| .toArray() |
| .filter((n): n is Y.XmlText => n instanceof Y.XmlText) |
| .map((n) => n.toString()) |
| .join(""); |
| if (textContent.trim().toLowerCase() === wanted) return i; |
| } |
| return -1; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function claimSlotAfterHeading( |
| frag: Y.XmlFragment, |
| headingText: string, |
| slotIndex: number, |
| ): Y.XmlElement | null { |
| const headingIdx = findHeadingIndex(frag, headingText); |
| if (headingIdx === -1) return null; |
|
|
| const children = frag.toArray(); |
| |
| const slots: { el: Y.XmlElement; fragIndex: number }[] = []; |
| for (let i = headingIdx + 1; i < children.length; i++) { |
| const c = children[i]; |
| if (!(c instanceof Y.XmlElement)) continue; |
| if (c.nodeName === "heading") break; |
| slots.push({ el: c, fragIndex: i }); |
| } |
|
|
| if (slots[slotIndex]) return slots[slotIndex].el; |
|
|
| |
| |
| |
| |
| |
| const insertAt = slots.length |
| ? slots[slots.length - 1].fragIndex + 1 |
| : headingIdx + 1; |
| const needed = slotIndex + 1 - slots.length; |
| const fresh: Y.XmlElement[] = []; |
| for (let i = 0; i < needed; i++) { |
| fresh.push(new Y.XmlElement("paragraph")); |
| } |
| frag.insert(insertAt, fresh); |
| return fresh[needed - 1]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function writeBlockMathAtSlot( |
| frag: Y.XmlFragment, |
| headingText: string, |
| slotIndex: number, |
| latex: string, |
| ): Y.XmlElement | null { |
| const headingIdx = findHeadingIndex(frag, headingText); |
| if (headingIdx === -1) return null; |
|
|
| const children = frag.toArray(); |
| |
| const slots: number[] = []; |
| for (let i = headingIdx + 1; i < children.length; i++) { |
| const c = children[i]; |
| if (!(c instanceof Y.XmlElement)) continue; |
| if (c.nodeName === "heading") break; |
| slots.push(i); |
| } |
|
|
| const block = new Y.XmlElement("blockMath"); |
| frag.doc?.transact(() => { |
| if (slots[slotIndex] !== undefined) { |
| frag.delete(slots[slotIndex], 1); |
| frag.insert(slots[slotIndex], [block]); |
| } else { |
| |
| |
| |
| const insertAt = slots.length |
| ? slots[slots.length - 1] + 1 |
| : headingIdx + 1; |
| const pad = slotIndex - slots.length; |
| const batch: Y.XmlElement[] = []; |
| for (let k = 0; k < pad; k++) batch.push(new Y.XmlElement("paragraph")); |
| batch.push(block); |
| frag.insert(insertAt, batch); |
| } |
| block.setAttribute("latex", latex); |
| }); |
| return block; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function waitForCondition( |
| frag: Y.XmlFragment, |
| predicate: () => boolean, |
| timeoutMs: number = 30_000, |
| ): Promise<boolean> { |
| if (predicate()) return true; |
| return new Promise<boolean>((resolve) => { |
| let done = false; |
| const finish = (v: boolean) => { |
| if (done) return; |
| done = true; |
| frag.unobserveDeep(onChange); |
| clearTimeout(timer); |
| resolve(v); |
| }; |
| const onChange = () => { |
| try { |
| if (predicate()) finish(true); |
| } catch { |
| |
| |
| } |
| }; |
| frag.observeDeep(onChange); |
| const timer = setTimeout(() => finish(false), timeoutMs); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| export interface TypingOptions { |
| |
| |
| |
| |
| cps?: number; |
| |
| |
| |
| jitter?: number; |
| |
| |
| |
| |
| |
| chunkSize?: number; |
| } |
|
|
| export function sleep(ms: number): Promise<void> { |
| return new Promise((r) => setTimeout(r, ms)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function setAwarenessCursor( |
| handle: PersonaHandle, |
| node: Y.XmlText, |
| offset: number, |
| ): void { |
| try { |
| const rel = Y.createRelativePositionFromTypeIndex(node, offset, -1); |
| const json = Y.relativePositionToJSON(rel); |
| handle.provider.awareness?.setLocalStateField("cursor", { |
| anchor: json, |
| head: json, |
| }); |
| } catch { |
| |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function clearAwarenessCursor(handle: PersonaHandle): void { |
| handle.provider.awareness?.setLocalStateField("cursor", null); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function pushChunkIntoParagraph( |
| paragraph: Y.XmlElement, |
| chunk: string, |
| attrs: Record<string, unknown> | undefined, |
| ): { node: Y.XmlText; offset: number } { |
| const last = paragraph.toArray().at(-1); |
| if (last instanceof Y.XmlText) { |
| const base = last.length; |
| last.insert(base, chunk, attrs); |
| return { node: last, offset: base + chunk.length }; |
| } |
| |
| |
| |
| |
| const t = new Y.XmlText(); |
| paragraph.push([t]); |
| t.insert(0, chunk, attrs); |
| return { node: t, offset: chunk.length }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function typeIntoParagraph( |
| paragraph: Y.XmlElement, |
| text: string, |
| options: TypingOptions = {}, |
| marks?: InlineMarks, |
| handle?: PersonaHandle, |
| ): Promise<void> { |
| const cps = options.cps ?? 24; |
| const jitter = options.jitter ?? 0.35; |
| const chunkSize = Math.max(1, options.chunkSize ?? 1); |
| const baseDelay = 1000 / cps; |
| const attrs = marksToAttrs(marks); |
|
|
| let i = 0; |
| while (i < text.length) { |
| const take = Math.min(chunkSize, text.length - i); |
| const chunk = text.slice(i, i + take); |
| const landed = pushChunkIntoParagraph(paragraph, chunk, attrs); |
| if (handle) setAwarenessCursor(handle, landed.node, landed.offset); |
| i += take; |
| const jitterMul = 1 + (Math.random() * 2 - 1) * jitter; |
| await sleep(Math.max(5, baseDelay * jitterMul)); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function insertInlineNode( |
| paragraph: Y.XmlElement, |
| item: |
| | { type: "inlineMath"; latex: string } |
| | { type: "citation"; key: string; label: string }, |
| preDelayMs: number = 0, |
| handle?: PersonaHandle, |
| ): Promise<void> { |
| if (preDelayMs > 0) await sleep(preDelayMs); |
| appendInline(paragraph, [item]); |
| if (handle) broadcastCursorAtEnd(handle, paragraph); |
| } |
|
|
| |
| |
| |
| |
| |
| export function broadcastCursorAtEnd( |
| handle: PersonaHandle, |
| paragraph: Y.XmlElement, |
| ): void { |
| const last = paragraph.toArray().at(-1); |
| if (last instanceof Y.XmlText) { |
| setAwarenessCursor(handle, last, last.length); |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export function addAuthorRecord( |
| handle: PersonaHandle, |
| args: { |
| name: string; |
| url?: string; |
| newAffiliation?: { name: string; url?: string }; |
| existingAffiliationIndices?: number[]; |
| }, |
| ): void { |
| const { ydoc, frontmatter } = handle; |
| ydoc.transact(() => { |
| let affIndices = args.existingAffiliationIndices ?? []; |
| if (args.newAffiliation) { |
| frontmatter.affiliations.push([args.newAffiliation]); |
| |
| |
| |
| affIndices = [...affIndices, frontmatter.affiliations.length]; |
| } |
| frontmatter.authors.push([ |
| { |
| name: args.name, |
| url: args.url, |
| affiliations: affIndices, |
| }, |
| ]); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function setAffiliationUrl( |
| handle: PersonaHandle, |
| oneBasedIndex: number, |
| url: string, |
| ): void { |
| const { ydoc, frontmatter } = handle; |
| const idx = oneBasedIndex - 1; |
| const current = frontmatter.affiliations.get(idx); |
| if (!current) return; |
| ydoc.transact(() => { |
| frontmatter.affiliations.delete(idx, 1); |
| frontmatter.affiliations.insert(idx, [{ ...current, url }]); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function waitForAffiliationCount( |
| handle: PersonaHandle, |
| minCount: number, |
| timeoutMs: number = 30_000, |
| ): Promise<boolean> { |
| if (handle.frontmatter.affiliations.length >= minCount) return true; |
| return new Promise<boolean>((resolve) => { |
| let done = false; |
| const onChange = () => { |
| if (done) return; |
| if (handle.frontmatter.affiliations.length >= minCount) { |
| done = true; |
| handle.frontmatter.affiliations.unobserve(onChange); |
| clearTimeout(timer); |
| resolve(true); |
| } |
| }; |
| handle.frontmatter.affiliations.observe(onChange); |
| const timer = setTimeout(() => { |
| if (done) return; |
| done = true; |
| handle.frontmatter.affiliations.unobserve(onChange); |
| resolve(false); |
| }, timeoutMs); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function waitForAuthorCount( |
| handle: PersonaHandle, |
| minCount: number, |
| timeoutMs: number = 30_000, |
| ): Promise<boolean> { |
| if (handle.frontmatter.authors.length >= minCount) return true; |
| return new Promise<boolean>((resolve) => { |
| let done = false; |
| const onChange = () => { |
| if (done) return; |
| if (handle.frontmatter.authors.length >= minCount) { |
| done = true; |
| handle.frontmatter.authors.unobserve(onChange); |
| clearTimeout(timer); |
| resolve(true); |
| } |
| }; |
| handle.frontmatter.authors.observe(onChange); |
| const timer = setTimeout(() => { |
| if (done) return; |
| done = true; |
| handle.frontmatter.authors.unobserve(onChange); |
| resolve(false); |
| }, timeoutMs); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| export interface CitationEntry { |
| id: string; |
| "citation-key": string; |
| type: string; |
| title?: string; |
| author?: Array<{ family: string; given: string }>; |
| issued?: { "date-parts": number[][] }; |
| "container-title"?: string; |
| volume?: string; |
| URL?: string; |
| DOI?: string; |
| [key: string]: unknown; |
| } |
|
|
| |
| |
| |
| |
| |
| export function registerCitation( |
| handle: PersonaHandle, |
| key: string, |
| entry: CitationEntry, |
| ): void { |
| handle.citationsMap.set(key, entry); |
| } |
|
|