UBERF-7495: Global editor kit extensions (#6057)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev
2024-07-12 15:42:13 +07:00
committed by GitHub
parent d2706ffed8
commit 0fc0115c99
270 changed files with 1275 additions and 1154 deletions
@@ -0,0 +1,103 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type MarkupNode } from '@hcengineering/text'
import { type Editor } from '@tiptap/core'
import { ChangeSet } from '@tiptap/pm/changeset'
import { type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { deepEqual } from 'fast-equals'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import { type Doc as Ydoc } from 'yjs'
import { recreateTransform } from './recreate'
/**
* @public
*/
export function createYdocDocument (schema: Schema, ydoc: Ydoc, field?: string): ProseMirrorNode {
try {
const body = yDocToProsemirrorJSON(ydoc, field)
return schema.nodeFromJSON(body)
} catch (err: any) {
console.error(err)
return schema.node(schema.topNodeType)
}
}
/**
* @public
*/
export function calculateDecorations (
editor?: Editor,
oldContent?: MarkupNode,
comparedDoc?: ProseMirrorNode
):
| {
decorations: DecorationSet
oldContent: MarkupNode
}
| undefined {
try {
if (editor?.schema === undefined) {
return
}
if (comparedDoc === undefined) {
return
}
const docNew = editor.state.doc
const c = editor.getJSON() as MarkupNode
if (deepEqual(c, oldContent)) {
return
}
const tr = recreateTransform(comparedDoc, docNew)
const changeSet = ChangeSet.create(comparedDoc).addSteps(tr.doc, tr.mapping.maps, undefined)
const changes = changeSet.changes
const decorations: Decoration[] = []
function lintIcon (color: string): any {
const icon = document.createElement('div')
icon.className = `text-editor-lint-icon ${color}`
return icon
}
function deleted (prob: any): any {
const icon = document.createElement('span')
icon.className = 'text-editor-highlighted-node-delete'
icon.innerText = prob
return icon
}
changes.forEach((change) => {
if (change.inserted.length > 0) {
decorations.push(Decoration.inline(change.fromB, change.toB, { class: 'text-editor-highlighted-node-add' }, {}))
decorations.push(Decoration.widget(change.fromB, lintIcon('add')))
}
if (change.deleted.length > 0) {
const cont = comparedDoc.textBetween(change.fromA, change.toA)
decorations.push(Decoration.widget(change.fromB, deleted(cont)))
decorations.push(Decoration.widget(change.fromB, lintIcon('delete')))
}
})
if (decorations.length > 0) {
return { decorations: DecorationSet.empty.add(docNew, decorations), oldContent: c }
}
return { decorations: DecorationSet.empty, oldContent: c }
} catch (error: any) {
console.error(error)
}
}
@@ -0,0 +1,163 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type Diff, diffAny, type Operation } from 'rfc6902/diff'
import { type Pointer } from 'rfc6902/pointer'
interface ArrayOperation {
op: 'add' | 'remove' | 'replace'
index: number
value?: any
original?: number
}
interface MemoValue {
operations: ArrayOperation[]
cost: number
}
/**
* @public
* Modification of {diffArray} from rfc6902/diff, with respect to prosemirror model.
*/
export function diffArraysPM (input: any, output: any, ptr: Pointer, diff: Diff = diffAny): any {
// set up cost matrix (very simple initialization: just a map)
const memo: Record<string, MemoValue> = {
'0,0': { operations: [], cost: 0 }
}
/**
Calculate the cheapest sequence of operations required to get from
input.slice(0, i) to output.slice(0, j).
There may be other valid sequences with the same cost, but none cheaper.
@param i The row in the layout above
@param j The column in the layout above
@returns An object containing a list of operations, along with the total cost
of applying them (+1 for each add/remove/replace operation)
*/
function dist (i: number, j: number): MemoValue {
// memoized
const memoKey = `${i},${j}`
let memoized = memo[memoKey]
if (memoized === undefined) {
// TODO: this !diff(...).length usage could/should be lazy
if (i > 0 && j > 0 && diff(input[i - 1], output[j - 1], ptr.add(String(i - 1))).length === 0) {
// equal (no operations => no cost)
memoized = dist(i - 1, j - 1)
} else {
const alternatives: MemoValue[] = []
if (i > 0) {
// NOT topmost row
const removeBase = dist(i - 1, j)
const removeOperation: ArrayOperation = {
op: 'remove',
index: i - 1
}
alternatives.push(appendArrayOperation(removeBase, removeOperation))
}
if (j > 0) {
// NOT leftmost column
const addBase: MemoValue = dist(i, j - 1)
const addOperation: ArrayOperation = {
op: 'add',
index: i - 1,
value: output[j - 1]
}
alternatives.push(appendArrayOperation(addBase, addOperation))
}
if (i > 0 && j > 0) {
// TABLE MIDDLE
// supposing we replaced it, compute the rest of the costs:
const replaceBase = dist(i - 1, j - 1)
// okay, the general plan is to replace it, but we can be smarter,
// recursing into the structure and replacing only part of it if
// possible, but to do so we'll need the original value
const replaceOperation: ArrayOperation = {
op: 'replace',
index: i - 1,
original: input[i - 1],
value: output[j - 1]
}
// Replace only if simple or object's with type equal
const io = input[i - 1]
const jo = output[j - 1]
if (
(typeof io !== 'object' && typeof jo !== 'object') ||
(typeof io === 'object' &&
typeof jo === 'object' &&
io.type === jo.type &&
Array.isArray(io.content) &&
Array.isArray(jo.content))
) {
alternatives.push(appendArrayOperation(replaceBase, replaceOperation))
}
}
// the only other case, i === 0 && j === 0, has already been memoized
// the meat of the algorithm:
// sort by cost to find the lowest one (might be several ties for lowest)
// [4, 6, 7, 1, 2].sort((a, b) => a - b) -> [ 1, 2, 4, 6, 7 ]
const best = alternatives.sort(function (a, b) {
return a.cost - b.cost
})[0]
memoized = best
}
memo[memoKey] = memoized
}
return memoized
}
// handle weird objects masquerading as Arrays that don't have proper length
// properties by using 0 for everything but positive numbers
const inputLength: number = isNaN(input.length) || input.length <= 0 ? 0 : input.length
const outputLength: number = isNaN(output.length) || output.length <= 0 ? 0 : output.length
const arrayOperations = dist(inputLength, outputLength).operations
const paddedOperations = arrayOperations.reduce(
function (_a: any, arrayOperation: ArrayOperation) {
const operations: Operation[] = _a[0]
const padding: number = _a[1]
if (arrayOperation.op === 'add') {
const paddedIndex = arrayOperation.index + 1 + padding
const indexToken = paddedIndex < inputLength + padding ? String(paddedIndex) : '-'
const operation: Operation = {
op: arrayOperation.op,
path: ptr.add(indexToken).toString(),
value: arrayOperation.value
}
// padding++ // maybe only if array_operation.index > -1 ?
return [operations.concat(operation), padding + 1]
} else if (arrayOperation.op === 'remove') {
const operation: Operation = {
op: arrayOperation.op,
path: ptr.add(String(arrayOperation.index + padding)).toString()
}
// padding--
return [operations.concat(operation), padding - 1]
} else {
// replace
const replacePtr = ptr.add(String(arrayOperation.index + padding))
const replaceOperations = diff(arrayOperation.original, arrayOperation.value, replacePtr)
return [operations.concat.apply(operations, replaceOperations), padding]
}
},
[[], 0]
)[0]
return paddedOperations
}
function appendArrayOperation (base: MemoValue, operation: ArrayOperation): MemoValue {
return {
// the new operation must be pushed on the end
operations: base.operations.concat(operation),
cost: base.cost + 1
}
}
@@ -0,0 +1,334 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Parts of source code is taken from https://github.com/sueddeutsche/prosemirror-recreate-transform
// based on Apache License 2.0
//
import { type Change, diffWordsWithSpace } from 'diff'
import { type Node, type Schema } from '@tiptap/pm/model'
import { ReplaceStep, type Step, Transform } from '@tiptap/pm/transform'
import { applyPatch, createPatch, type Operation } from 'rfc6902'
import { type Pointer } from 'rfc6902/pointer'
import { diffArraysPM } from './diff'
/**
* @public
*/
export function getReplaceStep (fromDoc: Node, toDoc: Node): ReplaceStep | undefined {
const start = toDoc.content.findDiffStart(fromDoc.content)
if (start !== null) {
const pos = toDoc.content.findDiffEnd(fromDoc.content)
if (pos != null) {
return getReplaceStepOverlap(pos, start, fromDoc, toDoc)
}
}
}
function getReplaceStepOverlap (pos: { a: number, b: number }, start: number, fromDoc: Node, toDoc: Node): ReplaceStep {
let { a: endA, b: endB } = pos
const overlap = start - Math.min(endA, endB)
if (overlap > 0) {
if (fromDoc.resolve(start - overlap).depth < toDoc.resolve(endA + overlap).depth) {
start -= overlap
} else {
endA += overlap
endB += overlap
}
}
return new ReplaceStep(start, endB, toDoc.slice(start, endA))
}
export function simplifyTransform (tr: Transform): Transform | undefined {
if (tr.steps.length === 0) {
return undefined
}
const newTr = new Transform(tr.docs[0])
const oldSteps = tr.steps.slice()
while (oldSteps.length > 0) {
let step = oldSteps.shift()
while (oldSteps.length > 0 && step?.merge(oldSteps[0]) != null) {
step = simplifyStep(step, oldSteps.shift(), newTr)
}
if (step === undefined) {
return undefined
}
newTr.step(step)
}
return newTr
}
function simplifyStep (step: Step | undefined, addedStep: Step | undefined, newTr: Transform): Step | undefined {
if (step != null && step instanceof ReplaceStep && addedStep != null && addedStep instanceof ReplaceStep) {
const stepA = step.apply(newTr.doc)
if (stepA.doc != null) {
const stepB = addedStep.apply(stepA.doc)
if (stepB.doc !== null) {
step = getReplaceStep(newTr.doc, stepB.doc)
} else {
step = undefined
}
} else {
step = undefined
}
} else if (addedStep !== undefined) {
step = step?.merge(addedStep) ?? undefined
}
return step
}
function getFromPath (obj: any, path: string): any {
const pathParts = path.split('/')
pathParts.shift() // remove root
while (pathParts.length > 0) {
const property = pathParts.shift()
obj = obj[property ?? '']
}
return obj
}
function removeMarks (doc: Node): Node {
const tr = new Transform(doc)
tr.removeMark(0, doc.nodeSize - 2)
return tr.doc
}
function clone (obj: any): any {
if (typeof obj === 'function') {
return obj
}
const result: any = Array.isArray(obj) ? [] : {}
for (const key in obj) {
// include prototype properties
const value = obj[key]
const type = {}.toString.call(value).slice(8, -1)
if (type === 'Array') {
result[key] = clone(value)
} else if (type === 'Object') {
result[key] = clone(value)
} else if (type === 'Date') {
result[key] = new Date(value.getTime())
} else {
result[key] = value
}
}
return result
}
export class StepTransform {
schema: Schema
tr: Transform
currentDoc: any
finalDoc: any
ops: Operation[] = []
constructor (
readonly fromDoc: Node,
readonly toDoc: Node
) {
this.schema = fromDoc.type.schema
this.tr = new Transform(fromDoc)
}
init (): Transform {
this.currentDoc = removeMarks(this.fromDoc).toJSON()
this.finalDoc = removeMarks(this.toDoc).toJSON()
this.ops = createPatch(this.currentDoc, this.finalDoc, (input: any, output: any, ptr: Pointer) => {
if (Array.isArray(input) && Array.isArray(output)) {
return diffArraysPM(input, output, ptr)
}
})
this.recreateChangeContentSteps()
this.recreateChangeMarkSteps()
this.tr = simplifyTransform(this.tr) ?? this.tr
return this.tr
}
recreateChangeContentSteps (): void {
// First step: find content changing steps.
let ops: Operation[] = []
while (this.ops.length > 0) {
// get next
let op = this.ops.shift() as Operation
ops.push(op)
let toDoc
const afterStepJSON = clone(this.currentDoc) // working document receiving patches
const pathParts = op.path.split('/')
// collect operations until we receive a valid document:
// apply ops-patches until a valid prosemirror document is retrieved,
// then try to create a transformation step or retry with next operation
while (toDoc == null) {
applyPatch(afterStepJSON, [op])
try {
toDoc = this.schema.nodeFromJSON(afterStepJSON)
toDoc.check()
} catch (error: any) {
toDoc = null
if (this.ops.length > 0) {
op = this.ops.shift() as Operation
ops.push(op)
} else {
throw new Error(`No valid diff possible applying ${op.path} ${JSON.stringify(error, undefined, 2)}`)
}
}
}
// apply operation (ignoring afterStepJSON)
if (ops.length === 1 && (pathParts.includes('attrs') || pathParts.includes('type'))) {
// Node markup is changing
this.addSetNodeMarkup() // a lost update is ignored
ops = []
} else if (ops.length === 1 && op.op === 'replace' && pathParts[pathParts.length - 1] === 'text') {
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
this.addReplaceTextSteps(op, afterStepJSON)
ops = []
} else if (this.addReplaceStep(toDoc, afterStepJSON)) {
// operations have been applied
ops = []
}
}
}
addSetNodeMarkup (): boolean {
const fromDoc = this.schema.nodeFromJSON(this.currentDoc)
const toDoc = this.schema.nodeFromJSON(this.finalDoc)
const start = toDoc.content.findDiffStart(fromDoc.content)
// @note start is the same (first) position for current and target document
const fromNode = fromDoc.nodeAt(start ?? 0)
const toNode = toDoc.nodeAt(start ?? 0)
if (start != null) {
// @note this completly updates all attributes in one step, by completely replacing node
const nodeType = fromNode?.type === toNode?.type ? null : toNode?.type
try {
this.tr.setNodeMarkup(start, nodeType, toNode?.attrs, toNode?.marks)
} catch (e: any) {
if (nodeType != null && (e.message as string).includes('Invalid content')) {
// @todo add test-case for this scenario
if (fromNode != null && toNode != null) {
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode)
}
} else {
throw e
}
}
this.currentDoc = removeMarks(this.tr.doc).toJSON()
// setting the node markup may have invalidated the following ops, so we calculate them again.
this.ops = createPatch(this.currentDoc, this.finalDoc)
return true
}
return false
}
recreateChangeMarkSteps (): void {
// Now the documents should be the same, except their marks, so everything should map 1:1.
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
this.toDoc.descendants((tNode, tPos) => {
if (!tNode.isInline) {
return true
}
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
if (!fNode.isInline) {
return true
}
const from = Math.max(tPos, fPos)
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize)
fNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(tNode.marks)) {
this.tr.removeMark(from, to, nodeMark)
}
})
tNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(fNode.marks)) {
this.tr.addMark(from, to, nodeMark)
}
})
})
})
}
addReplaceStep (toDoc: Node, afterStepJSON: any): boolean {
const fromDoc = this.schema.nodeFromJSON(this.currentDoc)
const step = getReplaceStep(fromDoc, toDoc)
if (step == null) {
return false
} else if (this.tr.maybeStep(step).failed === null) {
this.currentDoc = afterStepJSON
return true // @change previously null
}
throw new Error('No valid step found.')
}
addReplaceTextSteps (op: any, afterStepJSON: any): void {
// We find the position number of the first character in the string
const op1 = { ...op, value: 'xx' }
const op2 = { ...op, value: 'yy' }
const afterOP1JSON = clone(this.currentDoc)
const afterOP2JSON = clone(this.currentDoc)
applyPatch(afterOP1JSON, [op1])
applyPatch(afterOP2JSON, [op2])
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON)
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON)
// get text diffs
const finalText = op.value
const currentText = getFromPath(this.currentDoc, op.path)
const textDiffs = diffWordsWithSpace(currentText, finalText)
let offset = op1Doc.content.findDiffStart(op2Doc.content) as number
const marks = op1Doc.resolve(offset + 1).marks()
while (textDiffs.length > 0) {
const diff = textDiffs.shift() as Change
if (diff.added === true) {
const textNode = this.schema.nodeFromJSON({ type: 'text', text: diff.value }).mark(marks)
if (textDiffs.length > 0 && textDiffs[0].removed === true) {
const nextDiff = textDiffs.shift() as Change
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode)
} else {
this.tr.insert(offset, textNode)
}
offset += diff.value.length
} else if (diff.removed === true) {
if (textDiffs.length > 0 && textDiffs[0].added === true) {
const nextDiff = textDiffs.shift() as Change
const textNode = this.schema.nodeFromJSON({ type: 'text', text: nextDiff.value }).mark(marks)
this.tr.replaceWith(offset, offset + diff.value.length, textNode)
offset += nextDiff.value.length
} else {
this.tr.delete(offset, offset + diff.value.length)
}
} else {
offset += diff.value.length
}
}
this.currentDoc = afterStepJSON
}
}
export function recreateTransform (fromDoc: Node, toDoc: Node): Transform {
const recreator = new StepTransform(fromDoc, toDoc)
return recreator.init()
}