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:
Chirag Chhatrala 2024-06-25 19:18:20 +05:30 committed by GitHub
parent f40b95977d
commit 0334f7c883
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 217 additions and 0 deletions

View File

@ -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>

View File

@ -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,

View File

@ -69,6 +69,9 @@ export default defineNuxtConfig({
fallback: 'light',
classPrefix: '',
},
ui: {
icons: ['heroicons','material-symbols'],
},
sitemap,
runtimeConfig,
gtm

View File

@ -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",

View File

@ -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",

156
client/plugins/pinia-history.js vendored Normal file
View File

@ -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)
})

View File

@ -153,4 +153,5 @@ export const useWorkingFormStore = defineStore("working_form", {
this.content.properties = newFields
}
},
history: {}
})