Undo/redo form editor (#452)
* Undo/redo form editor * Fix undo/redo * Fix history with version check * Increase default history size --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
f40b95977d
commit
0334f7c883
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<UButtonGroup
|
||||
size="sm"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<UButton
|
||||
:disabled="!canUndo"
|
||||
color="white"
|
||||
icon="i-material-symbols-undo"
|
||||
class="disabled:text-gray-500"
|
||||
@click="undo"
|
||||
/>
|
||||
<UButton
|
||||
:disabled="!canRedo"
|
||||
icon="i-material-symbols-redo"
|
||||
color="white"
|
||||
class="disabled:text-gray-500"
|
||||
@click="redo"
|
||||
/>
|
||||
</UButtonGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
|
||||
const { undo, redo, clearHistory } = workingFormStore
|
||||
const { canUndo, canRedo } = storeToRefs(workingFormStore)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => { clearHistory() }, 500)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -28,6 +28,9 @@
|
|||
</svg>
|
||||
Go back
|
||||
</a>
|
||||
|
||||
<UndoRedo />
|
||||
|
||||
<div class="hidden md:flex items-center ml-3">
|
||||
<h3 class="font-semibold text-lg max-w-[14rem] truncate text-gray-500">
|
||||
{{ form.title }}
|
||||
|
|
@ -118,6 +121,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import UndoRedo from "../../editors/UndoRedo.vue"
|
||||
import FormEditorSidebar from "./form-components/FormEditorSidebar.vue"
|
||||
import FormErrorModal from "./form-components/FormErrorModal.vue"
|
||||
import FormInformation from "./form-components/FormInformation.vue"
|
||||
|
|
@ -136,6 +140,7 @@ import { captureException } from "@sentry/core"
|
|||
export default {
|
||||
name: "FormEditor",
|
||||
components: {
|
||||
UndoRedo,
|
||||
FormEditorSidebar,
|
||||
FormEditorPreview,
|
||||
FormAboutSubmission,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ export default defineNuxtConfig({
|
|||
fallback: 'light',
|
||||
classPrefix: '',
|
||||
},
|
||||
ui: {
|
||||
icons: ['heroicons','material-symbols'],
|
||||
},
|
||||
sitemap,
|
||||
runtimeConfig,
|
||||
gtm
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@iconify-json/material-symbols": "^1.1.82",
|
||||
"@nuxt/ui": "^2.15.0",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"fuse.js": "^6.4.6",
|
||||
"js-sha256": "^0.10.0",
|
||||
"libphonenumber-js": "^1.10.44",
|
||||
"lzutf8": "^0.6.3",
|
||||
"object-to-formdata": "^4.5.1",
|
||||
"pinia": "^2.1.7",
|
||||
"prismjs": "^1.24.1",
|
||||
|
|
@ -1671,6 +1673,14 @@
|
|||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/material-symbols": {
|
||||
"version": "1.1.82",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.1.82.tgz",
|
||||
"integrity": "sha512-E67LgMFiAbEVF7rE38ulZU6NeXcPvayFF4hUUqt3g33tWrLsDNqEFTSsPt03l34rH5uGGtHIakTqtBlZ+/hRkw==",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/ri": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.20.tgz",
|
||||
|
|
@ -9550,6 +9560,14 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lzutf8": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/lzutf8/-/lzutf8-0.6.3.tgz",
|
||||
"integrity": "sha512-CAkF9HKrM+XpB0f3DepQ2to2iUEo0zrbh+XgBqgNBc1+k8HMM3u/YSfHI3Dr4GmoTIez2Pr/If1XFl3rU26AwA==",
|
||||
"dependencies": {
|
||||
"readable-stream": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.8",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@iconify-json/material-symbols": "^1.1.82",
|
||||
"@nuxt/ui": "^2.15.0",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
|
@ -50,6 +51,7 @@
|
|||
"fuse.js": "^6.4.6",
|
||||
"js-sha256": "^0.10.0",
|
||||
"libphonenumber-js": "^1.10.44",
|
||||
"lzutf8": "^0.6.3",
|
||||
"object-to-formdata": "^4.5.1",
|
||||
"pinia": "^2.1.7",
|
||||
"prismjs": "^1.24.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import {computed, reactive} from 'vue'
|
||||
import {compress, decompress} from 'lzutf8'
|
||||
import debounce from 'debounce'
|
||||
import {hash} from "~/lib/utils.js"
|
||||
|
||||
/**
|
||||
* Merges the user-provided options with the default plugin options.
|
||||
* @param {boolean|object} options - The user's configuration options.
|
||||
* @returns {object} The merged configuration options.
|
||||
*/
|
||||
function mergeOptions(options) {
|
||||
const defaults = {
|
||||
max: 30,
|
||||
persistent: false,
|
||||
persistentStrategy: {
|
||||
get: function (store, type) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const key = `pinia-history-${store.$id}-${type}`
|
||||
const value = localStorage.getItem(key)
|
||||
return value ? decompress(value, {inputEncoding: 'Base64'}).split(',') : undefined
|
||||
}
|
||||
},
|
||||
set: function (store, type, value) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const key = `pinia-history-${store.$id}-${type}`
|
||||
const string = compress(value.join(','), {outputEncoding: 'Base64'})
|
||||
localStorage.setItem(key, string)
|
||||
}
|
||||
},
|
||||
remove: function (store, type) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const key = `pinia-history-${store.$id}-${type}`
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
debounceWait: 300
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...(typeof options === 'boolean' ? {} : options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds undo/redo functionality to a Pinia store.
|
||||
* @param {PiniaPluginContext} context - The context provided by Pinia.
|
||||
*/
|
||||
const PiniaHistory = (context) => {
|
||||
const {store, options} = context
|
||||
const {history} = options
|
||||
|
||||
if (!history) {
|
||||
return
|
||||
}
|
||||
const mergedOptions = mergeOptions(history)
|
||||
const {max, persistent, persistentStrategy} = mergedOptions
|
||||
|
||||
const $history = reactive({
|
||||
max,
|
||||
persistent,
|
||||
persistentStrategy,
|
||||
done: [],
|
||||
undone: [],
|
||||
current: JSON.stringify(store.$state),
|
||||
trigger: true,
|
||||
})
|
||||
|
||||
const debouncedStoreUpdate = debounce((state) => {
|
||||
if (hash($history.current) === hash(JSON.stringify(state))) { // Not a real change here
|
||||
return
|
||||
}
|
||||
if ($history.done.length >= max) $history.done.shift() // Remove oldest state if needed
|
||||
|
||||
$history.done.push($history.current)
|
||||
$history.undone = [] // Clear redo history on new action
|
||||
$history.current = JSON.stringify(state)
|
||||
|
||||
if (persistent) {
|
||||
persistentStrategy.set(store, 'undo', $history.done)
|
||||
persistentStrategy.set(store, 'redo', $history.undone)
|
||||
}
|
||||
}, mergedOptions.debounceWait)
|
||||
|
||||
store.canUndo = computed(() => $history.done.length > 0)
|
||||
store.canRedo = computed(() => $history.undone.length > 0)
|
||||
|
||||
store.undo = () => {
|
||||
if (!store.canUndo) {
|
||||
return
|
||||
}
|
||||
|
||||
debouncedStoreUpdate.clear()
|
||||
const state = $history.done.pop()
|
||||
if (state === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
$history.undone.push($history.current) // Save current state for redo
|
||||
$history.trigger = false
|
||||
store.$patch(JSON.parse(state))
|
||||
nextTick(() => {
|
||||
$history.current = state
|
||||
$history.trigger = true
|
||||
if (persistent) {
|
||||
persistentStrategy.set(store, 'undo', $history.done)
|
||||
persistentStrategy.set(store, 'redo', $history.undone)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
store.redo = () => {
|
||||
if (!store.canRedo) {
|
||||
return
|
||||
}
|
||||
debouncedStoreUpdate.clear()
|
||||
const state = $history.undone.pop()
|
||||
if (state === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
$history.done.push($history.current) // Save current state for undo
|
||||
$history.trigger = false
|
||||
store.$patch(JSON.parse(state))
|
||||
nextTick(() => {
|
||||
$history.current = state
|
||||
$history.trigger = true
|
||||
if (persistent) {
|
||||
persistentStrategy.set(store, 'undo', $history.done)
|
||||
persistentStrategy.set(store, 'redo', $history.undone)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
store.clearHistory = () => {
|
||||
$history.done = []
|
||||
$history.undone = []
|
||||
if (persistent) {
|
||||
persistentStrategy.set(store, 'undo', $history.done)
|
||||
persistentStrategy.set(store, 'redo', $history.undone)
|
||||
}
|
||||
}
|
||||
|
||||
store.$subscribe((mutation, state) => {
|
||||
if ($history.trigger) {
|
||||
debouncedStoreUpdate(state)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
nuxtApp.$pinia.use(PiniaHistory)
|
||||
})
|
||||
|
|
@ -153,4 +153,5 @@ export const useWorkingFormStore = defineStore("working_form", {
|
|||
this.content.properties = newFields
|
||||
}
|
||||
},
|
||||
history: {}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue