2
0

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:
Baptiste Arnaud
2022-02-02 08:05:02 +01:00
parent fc1d654772
commit 8a350eee6c
153 changed files with 1512 additions and 1352 deletions

View File

@ -0,0 +1,2 @@
export * from './utils'
export * from './useUndo'

View 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

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