mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 20:57:45 +02:00
UBERF-7495: Global editor kit extensions (#6057)
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user