UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

489 lines (488 loc) 19.1 kB
import { Anchor } from '../../../../json-crdt-extensions/peritext/rga/constants'; import { placeCursor } from './annals'; import { Cursor } from '../../../../json-crdt-extensions/peritext/editor/Cursor'; import { CursorAnchor } from '../../../../json-crdt-extensions/peritext'; import { PersistedSlice } from '../../../../json-crdt-extensions/peritext/slice/PersistedSlice'; const toText = (buf) => new TextDecoder().decode(buf); const getEdge = (start, end, anchor, edge) => edge === 'start' ? start : edge === 'end' ? end : edge === 'focus' ? anchor === CursorAnchor.Start ? end : start : anchor === CursorAnchor.Start ? start : end; /** * Implementation of default handlers for Peritext events, such as "insert", * "delete", "cursor", etc. These implementations are used by the * {@link PeritextEventTarget} to provide default behavior for each event type. * If `event.preventDefault()` is called on a Peritext event, the default handler * will not be executed. */ export class PeritextEventDefaults { txt; et; opts; undo; ui; editorUi = { eol: (point, steps) => { const ui = this.ui; if (!ui) return; const res = ui.getLineEnd(point, steps > 0); return res ? res[0] : void 0; }, vert: (point, steps) => { const ui = this.ui; if (!ui) return; const pos = ui.pointX(point); if (!pos) return; const currLine = ui.getLineInfo(point); if (!currLine) return; const x = pos[0]; const iterations = Math.abs(steps); let nextPoint = point; for (let i = 0; i < iterations; i++) { const nextLine = steps > 0 ? ui.getNextLineInfo(currLine) : ui.getNextLineInfo(currLine, -1); if (!nextLine) break; nextPoint = ui.findPointAtX(x, nextLine); if (!nextPoint) break; if (point.anchor === Anchor.Before) nextPoint.refBefore(); else nextPoint.refAfter(); } return nextPoint; }, }; constructor(txt, et, opts = {}) { this.txt = txt; this.et = et; this.opts = opts; } getSelSet({ at }) { const { editor } = this.txt; return at ? [editor.sel2range(at)[0]] : [...editor.cursors()]; } moveRange(start, end, anchor, move) { if (!move) return [start, end, anchor]; const { txt, editorUi } = this; const start0 = start; for (const [edge, to, len, collapse] of move) { const point = getEdge(start, end, anchor, edge); const point2 = typeof to === 'string' ? len ? txt.editor.skip(point, len, to ?? 'char', editorUi) : point.clone() : txt.editor.pos2point(to); if (point === start) start = point2; else end = point2; if (collapse) { if (to !== 'point') point2.refAfter(); if (point === start0) end = point2.clone(); else start = point2.clone(); } } if (start.cmpSpatial(end) > 0) { const tmp = start; start = end; end = tmp; anchor = anchor === CursorAnchor.Start ? CursorAnchor.End : CursorAnchor.Start; } return [start, end, anchor]; } moveSelSet(set, { move }) { if (!move) return; for (const selection of set) { const [start, end, anchor] = this.moveRange(selection.start, selection.end, selection instanceof Cursor ? selection.anchorSide : CursorAnchor.End, move); if (selection instanceof Cursor) selection.set(start, end, anchor); else selection.set(start, end); } } change = (event) => { }; insert = ({ detail }) => { const { move, text } = detail; const set = [...this.getSelSet(detail)]; if (move) this.moveSelSet(set, detail); this.txt.editor.insert(text, set); this.undo?.capture(); }; delete = ({ detail }) => { const { move, add, at } = detail; const set = [...this.getSelSet(detail)]; const editor = this.txt.editor; let deleted = false; for (const range of set) { if (range.length()) { deleted = true; editor.delRange(range); const start = range.start; start.refAfter(); range.set(start); } } if (deleted) { this.undo?.capture(); return; } if (move) this.moveSelSet(set, detail); for (const range of set) { editor.delRange(range); range.collapseToStart(); const start = range.start; start.refAfter(); range.set(start); } if (add && at) editor.cursor.setRange(set[0]); this.undo?.capture(); }; cursor = ({ detail }) => { const { at, move, add, flip } = detail; if (at === void 0) { const selection = this.getSelSet(detail); this.moveSelSet(selection, detail); // Collapse cursors if there are no visible characters between edges. // (Only for relative focus edge moves.) if (move && move.length === 1 && move[0][0] === 'focus') for (const range of selection) if (range.length() === 0) range.collapseToStart(); // Swap anchor and focus edges. if (flip) for (const range of selection) if (range instanceof Cursor) range.anchorSide = range.anchorSide === CursorAnchor.Start ? CursorAnchor.End : CursorAnchor.Start; } else { const { txt } = this; const { editor } = txt; const [range, anchor0] = editor.sel2range(at); const [start, end, anchor] = this.moveRange(range.start, range.end, anchor0, move); if (add) editor.addCursor(txt.range(start, end), anchor); else editor.cursor.set(start, end, anchor); } }; format = ({ detail }) => { const selection = [...this.getSelSet(detail)]; this.moveSelSet(selection, detail); const { action, type: tag, store = 'saved' } = detail; const editor = this.txt.editor; const slices = store === 'saved' ? editor.saved : store === 'extra' ? editor.extra : editor.local; switch (action) { case 'ins': case 'tog': { const { stack = 'one', data } = detail; if (tag === undefined) throw new Error('TYPE_REQUIRED'); switch (stack) { case 'many': { slices.insStack(tag, data, selection); break; } case 'one': { if (action === 'ins') slices.insOne(tag, data, selection); else editor.toggleExclFmt(tag, data, slices, selection); break; } case 'erase': { slices.insOne(tag, data, selection); break; } } break; } case 'del': { const { at } = detail; if (!tag && at && at instanceof PersistedSlice) { at.del(); } else { editor.clearFormatting(slices, selection); } break; } case 'erase': { if (tag === undefined) editor.eraseFormatting(slices, selection); else slices.insErase(tag, detail.data, selection); break; } } this.undo?.capture(); }; marker = ({ detail }) => { const selection = [...this.getSelSet(detail)]; this.moveSelSet(selection, detail); const { action, type, data } = detail; const editor = this.txt.editor; switch (action) { case 'ins': { editor.split(type, data, selection); break; } case 'tog': { if (type === undefined) throw new Error('TYPE_REQUIRED'); editor.tglMarker(type, data, selection); break; } case 'upd': { if (type === undefined) throw new Error('TYPE_REQUIRED'); editor.updMarker(type, data, selection); break; } case 'del': { editor.delMarker(selection); break; } } this.undo?.capture(); }; buffer = async ({ detail }) => { const selection = [...this.getSelSet(detail)]; this.moveSelSet(selection, detail); const opts = this.opts; const clipboard = opts.clipboard; if (!clipboard) return; const { action, format } = detail; const txt = this.txt; const editor = txt.editor; const range = selection[0] ?? txt.rangeAll(); if (!range) return; switch (action) { case 'cut': case 'copy': { const copyStyle = () => { if (!range) return; if (range.length() < 1) { range.end.step(1); if (range.length() < 1) range.start.step(-1); } const data = opts.transfer?.toFormat?.(range); clipboard.write(data)?.catch((err) => console.error(err)); }; switch (format) { case 'text': { const text = range.text(); clipboard.writeText(text)?.catch((err) => console.error(err)); if (action === 'cut') editor.collapseCursor(range); break; } case 'style': { copyStyle(); break; } case 'html': case 'hast': case 'json': case 'jsonml': case 'mdast': case 'md': case 'fragment': { const transfer = opts.transfer; if (!transfer) return; let text = ''; switch (format) { case 'html': text = transfer.toHtml(range); break; case 'hast': text = JSON.stringify(transfer.toHast(range), null, 2); break; case 'jsonml': text = JSON.stringify(transfer.toJson(range), null, 2); break; case 'json': text = JSON.stringify(transfer.toView(range), null, 2); break; case 'mdast': text = JSON.stringify(transfer.toMdast(range), null, 2); break; case 'md': text = transfer.toMarkdown(range); break; case 'fragment': text = transfer.toFragment(range) + ''; break; } clipboard.writeText(text)?.catch((err) => console.error(err)); if (action === 'cut') editor.collapseCursor(range); break; } default: { // `auto' const transfer = opts.transfer; if (!transfer) return; if (range.length() < 1) { copyStyle(); } else { const data = transfer.toClipboard(range); clipboard.write(data)?.catch((err) => console.error(err)); if (action === 'cut') editor.collapseCursor(range); } } } break; } case 'paste': { switch (format) { case 'text': { const data = await clipboard.read(['text/plain', 'text/html']); let buffer; if ((buffer = data['text/plain'])) { const text = toText(buffer); this.et.insert(text); } else if ((buffer = data['text/html'])) { const html = toText(buffer); const text = opts.transfer?.textFromHtml?.(html) ?? html; this.et.insert(text); } break; } case 'style': { const transfer = opts.transfer; if (transfer) { const { html } = detail.data || (await clipboard.readData()); if (html) { transfer.fromStyle(range, html); this.et.change(); } } break; } case 'html': case 'hast': case 'json': case 'jsonml': case 'mdast': case 'md': { const data = detail.data; const transfer = opts.transfer; if (!transfer) return; let text = data?.text || ''; if (!text) { const clipboardData = await clipboard.read(['text/plain']); const buffer = clipboardData['text/plain']; if (buffer) text = toText(buffer); } if (!range.isCollapsed()) editor.delRange(range); range.collapseToStart(); const start = range.start; const pos = start.viewPos(); let inserted = 0; switch (format) { case 'html': { inserted = transfer.fromHtml(pos, text); break; } case 'hast': { const json = JSON.parse(text); inserted = transfer.fromHast(pos, json); break; } case 'jsonml': { const json = JSON.parse(text); inserted = transfer.fromJson(pos, json); break; } case 'json': { const json = JSON.parse(text); inserted = transfer.fromView(pos, json); break; } case 'mdast': { const json = JSON.parse(text); inserted = transfer.fromMdast(pos, json); break; } case 'md': { inserted = transfer.fromMarkdown(pos, text); break; } } // if (inserted) this.et.move(inserted, 'char'); this.et.change(); break; } default: { // 'auto' let data = detail.data; const transfer = opts.transfer; if (!transfer) { let text = data?.text || ''; if (!text) { const clipboardData = await clipboard.read(['text/plain']); const buffer = clipboardData['text/plain']; if (buffer) text = toText(buffer); } if (text) this.et.insert(text); return; } if (!data) data = await clipboard.readData(); const inserted = transfer.fromClipboard(range, data); if (inserted && editor.cursorCard() === 1) this.et.move([ ['start', 'char', inserted], ['end', 'char', inserted], ]); this.et.change(); } } break; } } this.undo?.capture(); }; annals = (event) => { const { batch } = event.detail; this.txt.model.applyBatch(batch); const txt = this.txt; const cursor = placeCursor(txt, batch); if (cursor) txt.editor.cursor.setRange(cursor); }; }