fix editor undo

This commit is contained in:
Pete Matsyburka committed 2026-10-05 11:45:16 +03:00
1 parent 25504e8cb1
commit 73339d8cd9
7 files changed
+205 -57

No files matched your search

@@ -272,7 +272,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['t', 'isMobile', 'nativePlatform'],
inject: ['t', 'nativePlatform'],
props: {
template: {
type: Object,
+86 -27
View File
@@ -1007,6 +1007,9 @@ export default {
return {
template: this.template,
save: this.save,
pushUndo: this.pushUndo,
undo: this.undo,
redo: this.redo,
t: this.t,
assignDropAreaSize: this.assignDropAreaSize,
currencies: this.currencies,
@@ -1640,9 +1643,11 @@ export default {
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
this.undoStack = [JSON.stringify(this.template)]
this.undoStack = []
this.redoStack = []
this.undoStack.push(this.buildUndoData())
this.$nextTick(() => {
this.resizeObserver = new ResizeObserver(this.onResize)
this.resizeObserver.observe(this.$el.getRootNode().querySelector('div[data-v-app]'))
@@ -2287,6 +2292,8 @@ export default {
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
this.pushUndo()
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
@@ -2414,35 +2421,76 @@ export default {
this.drawOption = null
},
undo () {
this.selectedAreasRef.value = []
if (this.undoStack.length > 1) {
this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template)
const undoData = this.undoStack[this.undoStack.length - 1]
const currentUndoData = this.buildUndoData()
if (stringData && stringData !== currentStringData) {
this.redoStack.push(currentStringData)
if (!this.isSameUndoData(undoData, currentUndoData)) {
this.redoStack.push(currentUndoData)
Object.assign(this.template, JSON.parse(stringData))
this.save()
this.applyUndoData(undoData, currentUndoData)
}
}
},
redo () {
const stringData = this.redoStack.pop()
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
this.selectedAreasRef.value = []
if (stringData && stringData !== currentStringData) {
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
this.undoStack.push(currentStringData)
const undoData = this.redoStack.pop()
const currentUndoData = this.buildUndoData()
if (undoData && !this.isSameUndoData(undoData, currentUndoData)) {
if (!this.isSameUndoData(this.undoStack[this.undoStack.length - 1], currentUndoData)) {
this.undoStack.push(currentUndoData)
}
Object.assign(this.template, JSON.parse(stringData))
this.undoStack.push(undoData)
this.applyUndoData(undoData, currentUndoData)
}
},
buildUndoData () {
const template = JSON.stringify(toRaw(this.template))
const lastUndoData = this.undoStack[this.undoStack.length - 1]
return { template: template === lastUndoData?.template ? lastUndoData.template : template, sectionDocs: this.buildSectionDocs() }
},
buildSectionDocs () {
return new Map(this.documentRefs.flatMap((ref) => ref.isDynamic ? ref.getSectionDocs() : []))
},
isSameUndoData (undoData, currentUndoData) {
return undoData.template === currentUndoData.template && this.isSameSectionDocs(undoData, currentUndoData)
},
isSameSectionDocs (undoData, currentUndoData) {
return [...currentUndoData.sectionDocs].every(([sectionKey, doc]) => undoData.sectionDocs.get(sectionKey)?.eq(doc))
},
applyUndoData (undoData, currentUndoData) {
const isTemplateChanged = undoData.template !== currentUndoData.template
if (isTemplateChanged) {
Object.assign(this.template, JSON.parse(undoData.template))
}
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) ref.restoreSectionDocs(undoData.sectionDocs)
})
this.buildSectionDocs().forEach((doc, sectionKey) => undoData.sectionDocs.set(sectionKey, doc))
if (isTemplateChanged) {
this.save()
this.$nextTick(this.syncUndoTemplate)
}
},
syncUndoTemplate () {
const lastUndoData = this.undoStack[this.undoStack.length - 1]
const template = JSON.stringify(toRaw(this.template))
if (template !== lastUndoData.template) lastUndoData.template = template
},
onResize () {
const breakpointLg = 1024
const breakpointMd = 768
@@ -2609,15 +2657,11 @@ export default {
event.stopImmediatePropagation()
event.preventDefault()
this.selectedAreasRef.value = []
this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation()
event.preventDefault()
this.selectedAreasRef.value = []
this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) {
@@ -2992,16 +3036,29 @@ export default {
return false
}
},
pushUndo () {
const stringData = JSON.stringify(this.template)
pushUndo ({ amend = false } = {}) {
const undoData = this.buildUndoData()
const lastUndoData = this.undoStack[this.undoStack.length - 1]
const isTemplateChanged = undoData.template !== lastUndoData.template
const isSectionDocsChanged = !this.isSameSectionDocs(lastUndoData, undoData)
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
this.undoStack.push(stringData)
if (this.isUndoBatch || (amend && (!isSectionDocsChanged || (!this.redoStack.length && !isTemplateChanged)))) {
this.undoStack[this.undoStack.length - 1] = undoData
} else if (isTemplateChanged || isSectionDocsChanged) {
this.undoStack.push(undoData)
if (this.lastRedoData !== stringData) {
this.redoStack = []
} else {
return
}
}
this.isUndoBatch = true
this.$nextTick(() => {
this.isUndoBatch = false
this.syncUndoTemplate()
})
},
setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@@ -3971,7 +4028,7 @@ export default {
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
this.$el.closest('template-builder').dataset.template = JSON.stringify(toRaw(this.template))
}
})
@@ -4004,10 +4061,12 @@ export default {
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(toRaw(this.dynamicDocuments))
}
})
this.pushUndo({ amend: true })
this.save()
},
rebuildVariablesSchema ({ disable = true } = {}) {
@@ -191,6 +191,8 @@ export default {
}
},
created () {
this.updatedSectionEditors = new Map()
this.loadListsStyle()
},
mounted () {
@@ -242,6 +244,14 @@ export default {
this.sectionRefs.forEach((ref) => ref.reloadContent())
},
getSectionDocs () {
return this.sectionRefs.map((ref) => [ref.sectionKey, ref.editor.state.doc])
},
restoreSectionDocs (sectionDocs) {
this.sectionRefs.forEach((ref) => {
if (sectionDocs.has(ref.sectionKey)) ref.restoreDoc(sectionDocs.get(ref.sectionKey))
})
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
@@ -267,11 +277,9 @@ export default {
onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => {
await this.updateSectionAndSave(section, editor)
this.updatedSectionEditors.set(section.id, editor)
delete this.saveTimer
}, 1000)
this.saveTimer = setTimeout(() => this.updateSectionsAndSave(), 1000)
},
loadListsStyle () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
@@ -291,12 +299,16 @@ export default {
updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
},
updateSectionAndSave (section, editor) {
const target = this.bodyDom.getElementById(section.id)
updateSectionsAndSave () {
this.updatedSectionEditors.forEach((editor, sectionId) => {
const target = this.bodyDom.getElementById(sectionId)
if (target) {
target.innerHTML = this.getHtmlForSave(editor)
}
})
this.updatedSectionEditors.clear()
this.document.body = this.bodyDom.body.innerHTML
@@ -316,6 +328,8 @@ export default {
delete this.saveTimer
this.updatedSectionEditors.clear()
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
@@ -5,7 +5,6 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Document from '@tiptap/extension-document'
import Text from '@tiptap/extension-text'
import HardBreak from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
import Gapcursor from '@tiptap/extension-gapcursor'
import Dropcursor from '@tiptap/extension-dropcursor'
import { createApp, reactive } from 'vue'
@@ -1475,7 +1474,7 @@ const VariableHighlight = Extension.create({
}
})
export function buildEditor ({ dynamicAreaProps, getAttachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
export function buildEditor ({ dynamicAreaProps, getAttachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, onUndo, onRedo, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
@@ -1598,6 +1597,51 @@ export function buildEditor ({ dynamicAreaProps, getAttachmentsIndex, renderHtml
}
})
const undo = () => {
onUndo()
return true
}
const redo = () => {
onRedo()
return true
}
const BuilderHistory = Extension.create({
name: 'builderHistory',
addKeyboardShortcuts () {
return {
'Mod-z': undo,
'Shift-Mod-z': redo,
'Mod-y': redo,
'Mod-я': undo,
'Shift-Mod-я': redo
}
},
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('builderHistory'),
props: {
handleDOMEvents: {
beforeinput (_view, event) {
const command = { historyUndo: undo, historyRedo: redo }[event.inputType]
if (!command) return false
event.preventDefault()
return command()
}
}
}
})
]
}
})
const RESIZE_HANDLE_PX = 4
const MIN_SIZE_PX = 16
@@ -1818,7 +1862,7 @@ export function buildEditor ({ dynamicAreaProps, getAttachmentsIndex, renderHtml
Document,
Text,
HardBreak,
History,
BuilderHistory,
Gapcursor,
Dropcursor,
CustomParagraph,
@@ -41,6 +41,7 @@
:area="selectedArea"
:field="selectedField"
:editable="editable"
:is-mobile="isMobile"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
@@ -116,7 +117,7 @@
<script>
import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import { Selection, TextSelection } from '@tiptap/pm/state'
import { findTable, CellSelection } from '@tiptap/pm/tables'
import { v4 } from 'uuid'
import { IconPlus, IconX } from '@tabler/icons-vue'
@@ -138,7 +139,7 @@ export default {
IconPlus,
IconX
},
inject: ['template', 'save', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
inject: ['template', 'save', 'pushUndo', 'undo', 'redo', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 'isMobile'],
props: {
section: {
type: Object,
@@ -260,6 +261,9 @@ export default {
editor () {
return this.editorRef.value
},
sectionKey () {
return `${this.attachmentUuid}:${this.section.id}`
},
sectionWidthPx () {
const pt = parseFloat(this.section.style.width)
@@ -310,6 +314,8 @@ export default {
}
},
mounted () {
this.lastUpdateTime = 0
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
@@ -325,6 +331,8 @@ export default {
getAttachmentsIndex: () => this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
onUndo: this.undo,
onRedo: this.redo,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
@@ -333,6 +341,12 @@ export default {
onUpdate: (event) => {
this.$emit('update', event)
if (event.transaction.getMeta('addToHistory') !== false) {
this.pushUndo({ amend: event.transaction.time - this.lastUpdateTime < 500 })
this.lastUpdateTime = event.transaction.time
}
if (this.blockMenuCoords) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
@@ -352,6 +366,8 @@ export default {
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
this.$nextTick(() => this.pushUndo({ amend: true }))
},
beforeUnmount () {
if (this.editor) {
@@ -367,6 +383,19 @@ export default {
reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
},
restoreDoc (doc) {
const { state, view } = this.editor
const { content } = doc.type.schema === state.schema ? doc : state.schema.nodeFromJSON(doc.toJSON())
const diffEnd = content.findDiffEnd(state.doc.content)
if (diffEnd) {
const tr = state.tr.replaceWith(0, state.doc.content.size, content).setMeta('addToHistory', false)
view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(diffEnd.a))).scrollIntoView())
this.lastUpdateTime = 0
}
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -711,6 +740,8 @@ export default {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.stopPropagation()
}
},
onEditorMouseMove (event) {
@@ -826,25 +857,23 @@ export default {
}
}
const newField = {
const newField = payload.parentField || {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
areas: []
}
newField.areas.push(area)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
const oldOptionIndex = field.options.findIndex((opt) => opt.uuid === area.option_uuid)
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
if (!payload.parentField) {
newField.options = field.options.map((opt) => ({ ...opt, uuid: v4() }))
}
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
area.option_uuid = (newField.options[oldOptionIndex] || newField.options[0]).uuid
}
return { field: newField, area }
@@ -882,10 +911,13 @@ export default {
let lastArea = null
const parentFieldsIndex = {}
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
const sourceFieldUuid = fieldNode.getAttribute('uuid')
if (!fieldValue || !areaValue) {
return
@@ -894,11 +926,16 @@ export default {
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
templateId: Number(templateId),
parentField: parentFieldsIndex[sourceFieldUuid]
})
if (!parentFieldsIndex[sourceFieldUuid]) {
parentFieldsIndex[sourceFieldUuid] = field
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
}
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
-1
View File
@@ -20,7 +20,6 @@
"@tiptap/extension-dropcursor": "^3.19.0",
"@tiptap/extension-gapcursor": "^3.19.0",
"@tiptap/extension-hard-break": "^3.19.0",
"@tiptap/extension-history": "^3.19.0",
"@tiptap/extension-italic": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-paragraph": "^3.19.0",
-5
View File
@@ -1796,11 +1796,6 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8"
integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==
"@tiptap/extension-history@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.19.0.tgz#dc3a52f4eb16b7ac077c28284684b558c03adf00"
integrity sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==
"@tiptap/extension-italic@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8"