diff --git a/app/javascript/template_builder/area_title.vue b/app/javascript/template_builder/area_title.vue index 9b039b39f..dde2091e7 100644 --- a/app/javascript/template_builder/area_title.vue +++ b/app/javascript/template_builder/area_title.vue @@ -272,7 +272,7 @@ export default { FieldSubmitter, IconX }, - inject: ['t', 'isMobile', 'nativePlatform'], + inject: ['t', 'nativePlatform'], props: { template: { type: Object, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 0fa719ba7..26a62bcd5 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -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.save() + 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 = [] - } + 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 } = {}) { diff --git a/app/javascript/template_builder/dynamic_document.vue b/app/javascript/template_builder/dynamic_document.vue index 4408d2d84..3391e8223 100644 --- a/app/javascript/template_builder/dynamic_document.vue +++ b/app/javascript/template_builder/dynamic_document.vue @@ -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) - } + 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) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index 35287c619..85c9acd94 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -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, diff --git a/app/javascript/template_builder/dynamic_section.vue b/app/javascript/template_builder/dynamic_section.vue index 05aa949bc..260a1b768 100644 --- a/app/javascript/template_builder/dynamic_section.vue +++ b/app/javascript/template_builder/dynamic_section.vue @@ -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 @@