refactor(editor): ♻️ Undo / Redo buttons + structure refacto
Yet another huge refacto... While implementing undo and redo features I understood that I updated the stored typebot too many times (i.e. on each key input) so I had to rethink it entirely. I also moved around some files.
This commit is contained in:
2
apps/builder/services/utils/index.ts
Normal file
2
apps/builder/services/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './utils'
|
||||
export * from './useUndo'
|
127
apps/builder/services/utils/useUndo.ts
Normal file
127
apps/builder/services/utils/useUndo.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { isDefined } from '@udecode/plate-core'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
// import { diff } from 'deep-object-diff'
|
||||
import { useReducer, useCallback, useRef } from 'react'
|
||||
import { isNotDefined } from 'utils'
|
||||
|
||||
enum ActionType {
|
||||
Undo = 'UNDO',
|
||||
Redo = 'REDO',
|
||||
Set = 'SET',
|
||||
}
|
||||
|
||||
export interface Actions<T> {
|
||||
set: (newPresent: T) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
presentRef: React.MutableRefObject<T>
|
||||
}
|
||||
|
||||
interface Action<T> {
|
||||
type: ActionType
|
||||
newPresent?: T
|
||||
}
|
||||
|
||||
export interface State<T> {
|
||||
past: T[]
|
||||
present: T
|
||||
future: T[]
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
past: [],
|
||||
present: null,
|
||||
future: [],
|
||||
}
|
||||
|
||||
const reducer = <T>(state: State<T>, action: Action<T>) => {
|
||||
const { past, present, future } = state
|
||||
|
||||
switch (action.type) {
|
||||
case ActionType.Undo: {
|
||||
if (past.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const previous = past[past.length - 1]
|
||||
const newPast = past.slice(0, past.length - 1)
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: previous,
|
||||
future: [present, ...future],
|
||||
}
|
||||
}
|
||||
|
||||
case ActionType.Redo: {
|
||||
if (future.length === 0) {
|
||||
return state
|
||||
}
|
||||
const next = future[0]
|
||||
const newFuture = future.slice(1)
|
||||
|
||||
return {
|
||||
past: [...past, present],
|
||||
present: next,
|
||||
future: newFuture,
|
||||
}
|
||||
}
|
||||
|
||||
case ActionType.Set: {
|
||||
const { newPresent } = action
|
||||
if (
|
||||
isNotDefined(newPresent) ||
|
||||
(present &&
|
||||
deepEqual(
|
||||
JSON.parse(JSON.stringify(newPresent)),
|
||||
JSON.parse(JSON.stringify(present))
|
||||
))
|
||||
) {
|
||||
return state
|
||||
}
|
||||
// Uncomment to debug history ⬇️
|
||||
// console.log(
|
||||
// diff(
|
||||
// JSON.parse(JSON.stringify(newPresent)),
|
||||
// JSON.parse(JSON.stringify(present))
|
||||
// )
|
||||
// )
|
||||
return {
|
||||
past: [...past, present].filter(isDefined),
|
||||
present: newPresent,
|
||||
future: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useUndo = <T>(initialPresent: T): [State<T>, Actions<T>] => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
...initialState,
|
||||
present: initialPresent,
|
||||
}) as [State<T>, React.Dispatch<Action<T>>]
|
||||
const presentRef = useRef<T>(initialPresent)
|
||||
|
||||
const canUndo = state.past.length !== 0
|
||||
const canRedo = state.future.length !== 0
|
||||
const undo = useCallback(() => {
|
||||
if (canUndo) {
|
||||
dispatch({ type: ActionType.Undo })
|
||||
}
|
||||
}, [canUndo])
|
||||
const redo = useCallback(() => {
|
||||
if (canRedo) {
|
||||
dispatch({ type: ActionType.Redo })
|
||||
}
|
||||
}, [canRedo])
|
||||
const set = useCallback((newPresent: T) => {
|
||||
presentRef.current = newPresent
|
||||
dispatch({ type: ActionType.Set, newPresent })
|
||||
}, [])
|
||||
|
||||
return [state, { set, undo, redo, canUndo, canRedo, presentRef }]
|
||||
}
|
||||
|
||||
export default useUndo
|
117
apps/builder/services/utils/utils.ts
Normal file
117
apps/builder/services/utils/utils.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { Parser } from 'htmlparser2'
|
||||
import { Step, Typebot } from 'models'
|
||||
|
||||
export const fetcher = async (input: RequestInfo, init?: RequestInit) => {
|
||||
const res = await fetch(input, init)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const isMobile =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('only screen and (max-width: 760px)').matches
|
||||
|
||||
export const preventUserFromRefreshing = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
|
||||
export const parseHtmlStringToPlainText = (html: string): string => {
|
||||
let label = ''
|
||||
const parser = new Parser({
|
||||
ontext(text) {
|
||||
label += `${text}`
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return label
|
||||
}
|
||||
|
||||
export const toKebabCase = (value: string) => {
|
||||
const matched = value.match(
|
||||
/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g
|
||||
)
|
||||
if (!matched) return ''
|
||||
return matched.map((x) => x.toLowerCase()).join('-')
|
||||
}
|
||||
|
||||
interface Omit {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
<T extends object, K extends [...(keyof T)[]]>(obj: T, ...keys: K): {
|
||||
[K2 in Exclude<keyof T, K[number]>]: T[K2]
|
||||
}
|
||||
}
|
||||
|
||||
export const omit: Omit = (obj, ...keys) => {
|
||||
const ret = {} as {
|
||||
[K in keyof typeof obj]: typeof obj[K]
|
||||
}
|
||||
let key: keyof typeof obj
|
||||
for (key in obj) {
|
||||
if (!keys.includes(key)) {
|
||||
ret[key] = obj[key]
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
export const uploadFile = async (file: File, key: string) => {
|
||||
const res = await fetch(
|
||||
`/api/storage/upload-url?key=${encodeURIComponent(
|
||||
key
|
||||
)}&fileType=${encodeURIComponent(file.type)}`
|
||||
)
|
||||
const { url, fields } = await res.json()
|
||||
const formData = new FormData()
|
||||
|
||||
Object.entries({ ...fields, file }).forEach(([key, value]) => {
|
||||
formData.append(key, value as string | Blob)
|
||||
})
|
||||
|
||||
const upload = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return {
|
||||
url: upload.ok ? `${url}/${key}` : null,
|
||||
}
|
||||
}
|
||||
|
||||
export const compressFile = async (file: File) => {
|
||||
const options = {
|
||||
maxSizeMB: 0.5,
|
||||
maxWidthOrHeight: 1600,
|
||||
}
|
||||
return ['image/jpg', 'image/jpeg', 'image/png'].includes(file.type)
|
||||
? imageCompression(file, options)
|
||||
: file
|
||||
}
|
||||
|
||||
export const removeUndefinedFields = <T>(obj: T): T =>
|
||||
Object.keys(obj).reduce(
|
||||
(acc, key) =>
|
||||
obj[key as keyof T] === undefined
|
||||
? { ...acc }
|
||||
: { ...acc, [key]: obj[key as keyof T] },
|
||||
{} as T
|
||||
)
|
||||
|
||||
export const stepHasOptions = (step: Step) => 'options' in step
|
||||
|
||||
export const parseVariableHighlight = (content: string, typebot?: Typebot) => {
|
||||
if (!typebot) return content
|
||||
const varNames = typebot.variables.allIds.map(
|
||||
(varId) => typebot.variables.byId[varId].name
|
||||
)
|
||||
return content.replace(/\{\{(.*?)\}\}/g, (fullMatch, foundVar) => {
|
||||
if (varNames.some((val) => foundVar.includes(val))) {
|
||||
return `<span style="background-color:#ff8b1a; color:#ffffff; padding: 0.125rem 0.25rem; border-radius: 0.35rem">${fullMatch.replace(
|
||||
/{{|}}/g,
|
||||
''
|
||||
)}</span>`
|
||||
}
|
||||
return fullMatch
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user