From 66c4e2c676a767a428e6a406b291721387c2a9cc Mon Sep 17 00:00:00 2001 From: Edward Kim <65283190+bepyan@users.noreply.github.com> Date: Mon, 12 Aug 2024 00:19:48 +0900 Subject: [PATCH] refactor: editor action (#35) * chore: remove funnelPageId * chore: remove LOAD_DATA * refactor: editor action * feat: UPDATE_ELEMENT_STYLE * fix: EditorElement type safe * style: sort attribute * refactor: rename InferEditorElement * fix: typo * fix: typo --- src/components/editor/action.ts | 526 ++++++++---------- src/components/editor/constant.ts | 11 +- src/components/editor/elements/container.tsx | 32 +- src/components/editor/elements/map-share.tsx | 4 +- src/components/editor/elements/text.tsx | 4 +- .../advance-setting.tsx | 15 +- .../map-setting.tsx | 4 +- src/components/editor/type.ts | 44 +- 8 files changed, 274 insertions(+), 366 deletions(-) diff --git a/src/components/editor/action.ts b/src/components/editor/action.ts index 1266796..b788bfa 100644 --- a/src/components/editor/action.ts +++ b/src/components/editor/action.ts @@ -1,8 +1,4 @@ -import { - emptyElement, - initialEditor, - initialEditorState, -} from "~/components/editor/constant"; +import { emptyElement, initialEditor } from "~/components/editor/constant"; import type { DeviceType, Editor, @@ -11,335 +7,247 @@ import type { } from "~/components/editor/type"; import { isValidSelectEditorElement } from "~/components/editor/util"; -export type EditorAction = - | { - type: "ADD_ELEMENT"; - payload: { - containerId: string; - elementDetails: EditorElement; - }; - } - | { - type: "UPDATE_ELEMENT"; - payload: { - elementDetails: EditorElement; - }; - } - | { - type: "DELETE_ELEMENT"; - payload: { - elementDetails: EditorElement; - }; - } - | { - type: "CHANGE_CLICKED_ELEMENT"; - payload: { - elementDetails?: EditorElement; - }; - } - | { - type: "CHANGE_CURRENT_TAB_VALUE"; - payload: { - value: EditorTabTypeValue; - }; - } - | { - type: "CHANGE_DEVICE"; - payload: { - device: DeviceType; - }; - } - | { - type: "TOGGLE_PREVIEW_MODE"; - } - | { type: "REDO" } - | { type: "UNDO" } - | { - type: "LOAD_DATA"; - payload: { - elements: EditorElement[]; - }; - } - | { - type: "SET_FUNNELPAGE_ID"; - payload: { - funnelPageId: string; - }; - }; - -const addAnElement = ( - editorArray: EditorElement[], - action: EditorAction, -): EditorElement[] => { - if (action.type !== "ADD_ELEMENT") - throw Error( - "You sent the wrong action type to the Add Element editor State", - ); - return editorArray.map((item) => { - if (item.id === action.payload.containerId && Array.isArray(item.content)) { - return { - ...item, - content: [...item.content, action.payload.elementDetails], - }; - } else if (item.content && Array.isArray(item.content)) { - return { - ...item, - content: addAnElement(item.content, action), - }; - } - return item; - }); +/** + * Action Types + */ + +type EditorActionMap = { + ADD_ELEMENT: { + containerId: string; + elementDetails: EditorElement; + }; + UPDATE_ELEMENT: { + elementDetails: EditorElement; + }; + UPDATE_ELEMENT_STYLE: React.CSSProperties; + DELETE_ELEMENT: { + elementDetails: EditorElement; + }; + CHANGE_CLICKED_ELEMENT: { + elementDetails?: EditorElement; + }; + CHANGE_CURRENT_TAB_VALUE: { + value: EditorTabTypeValue; + }; + CHANGE_DEVICE: { + device: DeviceType; + }; + TOGGLE_PREVIEW_MODE: undefined; + REDO: undefined; + UNDO: undefined; }; -const updateAnElement = ( - editorArray: EditorElement[], - action: EditorAction, +export type EditorAction = { + [K in keyof EditorActionMap]: { + type: K; + } & (EditorActionMap[K] extends undefined + ? {} + : { payload: EditorActionMap[K] }); +}[keyof EditorActionMap]; + +/** + * Action Helpers + */ + +const updateEditorHistory = ( + editor: Editor, + newState: Editor["state"], +): Editor => ({ + ...editor, + state: newState, + history: { + ...editor.history, + history: [ + ...editor.history.history.slice(0, editor.history.currentIndex + 1), + { ...newState }, + ], + currentIndex: editor.history.currentIndex + 1, + }, +}); + +const traverseElements = ( + elements: EditorElement[], + callback: (element: EditorElement) => EditorElement | null, ): EditorElement[] => { - if (action.type !== "UPDATE_ELEMENT") { - throw Error("You sent the wrong action type to the update Element State"); - } - return editorArray.map((item) => { - if (item.id === action.payload.elementDetails.id) { - return { ...item, ...action.payload.elementDetails }; - } else if (item.content && Array.isArray(item.content)) { - return { - ...item, - content: updateAnElement(item.content, action), - }; + return elements.reduce((acc, element) => { + const updatedElement = callback(element); + if (updatedElement === null) { + return acc; } - return item; - }); -}; -const deleteAnElement = ( - editorArray: EditorElement[], - action: EditorAction, -): EditorElement[] => { - if (action.type !== "DELETE_ELEMENT") - throw Error( - "You sent the wrong action type to the Delete Element editor State", - ); - return editorArray.filter((item) => { - if (item.id === action.payload.elementDetails.id) { - return false; - } else if (item.content && Array.isArray(item.content)) { - item.content = deleteAnElement(item.content, action); + if (Array.isArray(updatedElement.content)) { + return [ + ...acc, + { + ...updatedElement, + content: traverseElements(updatedElement.content, callback), + } as EditorElement, + ]; } - return true; - }); -}; -export const editorReducer = ( - editor = initialEditor, - action: EditorAction, -): Editor => { - switch (action.type) { - case "ADD_ELEMENT": - const updatedEditorState = { - ...editor.state, - elements: addAnElement(editor.state.elements, action), - }; - // Update the history to include the entire updated EditorState - const updatedHistory = [ - ...editor.history.history.slice(0, editor.history.currentIndex + 1), - { ...updatedEditorState }, // Save a copy of the updated state - ]; + return [...acc, updatedElement]; + }, []); +}; - const newEditorState = { - ...editor, - state: updatedEditorState, - history: { - ...editor.history, - history: updatedHistory, - currentIndex: updatedHistory.length - 1, +/** + * Action Handlers + */ + +const actionHandlers: { + [K in EditorAction["type"]]: ( + editor: Editor, + payload: EditorActionMap[K], + ) => Editor; +} = { + ADD_ELEMENT: (editor, payload) => { + const newElements = traverseElements(editor.state.elements, (element) => { + if ( + element.id === payload.containerId && + Array.isArray(element.content) + ) { + return { + ...element, + content: [...element.content, payload.elementDetails], + } as EditorElement; + } + return element; + }); + + return updateEditorHistory(editor, { + ...editor.state, + elements: newElements, + }); + }, + + UPDATE_ELEMENT: (editor, payload) => { + const newElements = traverseElements(editor.state.elements, (element) => { + if (element.id === payload.elementDetails.id) { + return { ...element, ...payload.elementDetails }; + } + return element; + }); + + const isSelectedElementUpdated = + editor.state.selectedElement.id === payload.elementDetails.id; + const newSelectedElement = isSelectedElementUpdated + ? payload.elementDetails + : editor.state.selectedElement; + + return updateEditorHistory(editor, { + ...editor.state, + elements: newElements, + selectedElement: newSelectedElement, + }); + }, + + UPDATE_ELEMENT_STYLE: (editor, payload) => { + return actionHandlers.UPDATE_ELEMENT(editor, { + elementDetails: { + ...editor.state.selectedElement, + styles: { + ...editor.state.selectedElement.styles, + ...payload, }, - }; - - return newEditorState; - - case "UPDATE_ELEMENT": - // Perform your logic to update the element in the state - const updatedElements = updateAnElement(editor.state.elements, action); - - const UpdatedElementIsSelected = - editor.state.selectedElement.id === action.payload.elementDetails.id; - - const updatedEditorStateWithUpdate = { + }, + }); + }, + + DELETE_ELEMENT: (editor, payload) => { + const newElements = traverseElements(editor.state.elements, (element) => { + if (element.id === payload.elementDetails.id) { + return null; + } + return element; + }); + + return updateEditorHistory(editor, { + ...editor.state, + elements: newElements, + }); + }, + + CHANGE_CLICKED_ELEMENT: (editor, payload) => { + const isSelected = isValidSelectEditorElement(payload.elementDetails); + + const newTabValue = isSelected + ? "Element Settings" + : editor.state.currentTabValue === "Element Settings" + ? "Elements" + : editor.state.currentTabValue; + + return updateEditorHistory(editor, { + ...editor.state, + selectedElement: payload.elementDetails ?? emptyElement, + currentTabValue: newTabValue, + }); + }, + + CHANGE_CURRENT_TAB_VALUE: (editor, payload) => { + return { + ...editor, + state: { ...editor.state, - elements: updatedElements, - selectedElement: UpdatedElementIsSelected - ? action.payload.elementDetails - : { - id: "", - content: [], - name: "", - styles: {}, - type: null, - }, - }; - - const updatedHistoryWithUpdate = [ - ...editor.history.history.slice(0, editor.history.currentIndex + 1), - { ...updatedEditorStateWithUpdate }, // Save a copy of the updated state - ]; - const updatedEditor = { - ...editor, - state: updatedEditorStateWithUpdate, - history: { - ...editor.history, - history: updatedHistoryWithUpdate, - currentIndex: updatedHistoryWithUpdate.length - 1, - }, - }; - return updatedEditor; + currentTabValue: payload.value, + }, + }; + }, - case "DELETE_ELEMENT": - // Perform your logic to delete the element from the state - const updatedElementsAfterDelete = deleteAnElement( - editor.state.elements, - action, - ); - const updatedEditorStateAfterDelete = { + CHANGE_DEVICE: (editor, payload) => { + return { + ...editor, + state: { ...editor.state, - elements: updatedElementsAfterDelete, - }; - const updatedHistoryAfterDelete = [ - ...editor.history.history.slice(0, editor.history.currentIndex + 1), - { ...updatedEditorStateAfterDelete }, // Save a copy of the updated state - ]; - - const deletedState = { - ...editor, - state: updatedEditorStateAfterDelete, - history: { - ...editor.history, - history: updatedHistoryAfterDelete, - currentIndex: updatedHistoryAfterDelete.length - 1, - }, - }; - return deletedState; + device: payload.device, + }, + }; + }, - case "CHANGE_CLICKED_ELEMENT": - const isSelected = isValidSelectEditorElement( - action.payload.elementDetails, - ); + TOGGLE_PREVIEW_MODE: (editor) => { + return { + ...editor, + state: { + ...editor.state, + isPreviewMode: !editor.state.isPreviewMode, + }, + }; + }, - const clickedState: Editor = { + REDO: (editor) => { + if (editor.history.currentIndex < editor.history.history.length - 1) { + const nextIndex = editor.history.currentIndex + 1; + return { ...editor, - state: { - ...editor.state, - selectedElement: action.payload.elementDetails || emptyElement, - currentTabValue: isSelected - ? "Element Settings" - : editor.state.currentTabValue === "Element Settings" - ? "Elements" - : editor.state.currentTabValue, - }, + state: { ...editor.history.history[nextIndex] }, history: { ...editor.history, - history: [ - ...editor.history.history.slice(0, editor.history.currentIndex + 1), - { ...editor.state }, // Save a copy of the current editor state - ], - currentIndex: editor.history.currentIndex + 1, + currentIndex: nextIndex, }, }; - return clickedState; - - case "CHANGE_CURRENT_TAB_VALUE": - return { - ...editor, - state: { - ...editor.state, - currentTabValue: action.payload.value, - }, - }; - - case "CHANGE_DEVICE": - const changedDeviceState: Editor = { - ...editor, - state: { - ...editor.state, - device: action.payload.device, - }, - }; - return changedDeviceState; - - case "TOGGLE_PREVIEW_MODE": - const toggleState: Editor = { - ...editor, - state: { - ...editor.state, - isPreviewMode: !editor.state.isPreviewMode, - }, - }; - return toggleState; - - case "REDO": - if (editor.history.currentIndex < editor.history.history.length - 1) { - const nextIndex = editor.history.currentIndex + 1; - const nextEditorState = { ...editor.history.history[nextIndex] }; - const redoState: Editor = { - ...editor, - state: nextEditorState, - history: { - ...editor.history, - currentIndex: nextIndex, - }, - }; - return redoState; - } - return editor; - - case "UNDO": - if (editor.history.currentIndex > 0) { - const prevIndex = editor.history.currentIndex - 1; - const prevEditorState = { ...editor.history.history[prevIndex] }; - const undoState: Editor = { - ...editor, - state: prevEditorState, - history: { - ...editor.history, - currentIndex: prevIndex, - }, - }; - return undoState; - } - return editor; + } + return editor; + }, - case "LOAD_DATA": + UNDO: (editor) => { + if (editor.history.currentIndex > 0) { + const prevIndex = editor.history.currentIndex - 1; return { - ...initialEditor, - state: { - ...initialEditor.state, - elements: action.payload.elements || initialEditorState.elements, - }, - }; - - case "SET_FUNNELPAGE_ID": - const { funnelPageId } = action.payload; - const updatedEditorStateWithFunnelPageId = { - ...editor.state, - funnelPageId, - }; - - const updatedHistoryWithFunnelPageId = [ - ...editor.history.history.slice(0, editor.history.currentIndex + 1), - { ...updatedEditorStateWithFunnelPageId }, // Save a copy of the updated state - ]; - - const funnelPageIdState = { ...editor, - state: updatedEditorStateWithFunnelPageId, + state: { ...editor.history.history[prevIndex] }, history: { ...editor.history, - history: updatedHistoryWithFunnelPageId, - currentIndex: updatedHistoryWithFunnelPageId.length - 1, + currentIndex: prevIndex, }, }; - return funnelPageIdState; + } + return editor; + }, +}; - default: - return editor; - } +export const editorReducer = ( + editor = initialEditor, + action: EditorAction, +): Editor => { + const handler = actionHandlers[action.type]; + // @ts-expect-error: TypeScript cannot infer that the payload is correct for each action type + return handler(editor, action?.payload); }; diff --git a/src/components/editor/constant.ts b/src/components/editor/constant.ts index 7abfdda..e4c9fc7 100644 --- a/src/components/editor/constant.ts +++ b/src/components/editor/constant.ts @@ -1,4 +1,8 @@ -import type { EditorHistory, EditorState } from "~/components/editor/type"; +import type { + EditorElement, + EditorHistory, + EditorState, +} from "~/components/editor/type"; export const defaultStyles: React.CSSProperties = { backgroundPosition: "center", @@ -19,8 +23,8 @@ export const emptyElement = { content: [], name: "", styles: {}, - type: null, -}; + type: "empty", +} satisfies EditorElement; export const initialEditorState: EditorState = { elements: [ @@ -36,7 +40,6 @@ export const initialEditorState: EditorState = { currentTabValue: editorTabValue.ELEMENTS, device: "Mobile", isPreviewMode: false, - funnelPageId: "", }; export const initialEditorHistory: EditorHistory = { diff --git a/src/components/editor/elements/container.tsx b/src/components/editor/elements/container.tsx index 0db8640..5e3c93c 100644 --- a/src/components/editor/elements/container.tsx +++ b/src/components/editor/elements/container.tsx @@ -42,11 +42,11 @@ export default function Container({ element }: Props) { payload: { containerId: id, elementDetails: { - content: [], + type: "container", id: nanoid(), name: "Container", styles: { ...defaultStyles }, - type: "container", + content: [], }, }, }); @@ -57,26 +57,26 @@ export default function Container({ element }: Props) { payload: { containerId: id, elementDetails: { + type: "2Col", + id: nanoid(), + name: "Two Columns", + styles: { ...defaultStyles, display: "flex" }, content: [ { - content: [], + type: "container", id: nanoid(), name: "Container", styles: { ...defaultStyles, width: "100%" }, - type: "container", + content: [], }, { - content: [], + type: "container", id: nanoid(), name: "Container", styles: { ...defaultStyles, width: "100%" }, - type: "container", + content: [], }, ], - id: nanoid(), - name: "Two Columns", - styles: { ...defaultStyles, display: "flex" }, - type: "2Col", }, }, }); @@ -87,14 +87,14 @@ export default function Container({ element }: Props) { payload: { containerId: id, elementDetails: { - content: { innerText: "Text Element" }, + type: "text", id: nanoid(), name: "Text", styles: { color: "black", ...defaultStyles, }, - type: "text", + content: { innerText: "Text Element" }, }, }, }); @@ -105,16 +105,16 @@ export default function Container({ element }: Props) { payload: { containerId: id, elementDetails: { - content: { - address: "", - }, + type: "map", id: nanoid(), name: "map", styles: { color: "black", ...defaultStyles, }, - type: "map", + content: { + address: "", + }, }, }, }); diff --git a/src/components/editor/elements/map-share.tsx b/src/components/editor/elements/map-share.tsx index 9962fdf..d6f35b8 100644 --- a/src/components/editor/elements/map-share.tsx +++ b/src/components/editor/elements/map-share.tsx @@ -1,13 +1,13 @@ import Image from "next/image"; import { toast } from "sonner"; import ElementWrapper from "~/components/editor/elements/element-wrapper"; -import type { EditorElement } from "~/components/editor/type"; +import type { InferEditorElement } from "~/components/editor/type"; type MapType = "naver" | "kakao"; type MapUrls = Record; type Props = { - element: EditorElement; + element: InferEditorElement<"map">; }; const MapShareComponents = ({ element }: Props) => { diff --git a/src/components/editor/elements/text.tsx b/src/components/editor/elements/text.tsx index cede102..48f7032 100644 --- a/src/components/editor/elements/text.tsx +++ b/src/components/editor/elements/text.tsx @@ -2,10 +2,10 @@ import ElementWrapper from "~/components/editor/elements/element-wrapper"; import { useEditor } from "~/components/editor/provider"; -import type { EditorElement } from "~/components/editor/type"; +import type { InferEditorElement } from "~/components/editor/type"; type Props = { - element: EditorElement; + element: InferEditorElement<"text">; }; export default function Text({ element }: Props) { diff --git a/src/components/editor/sidebar-element-settings-tab/advance-setting.tsx b/src/components/editor/sidebar-element-settings-tab/advance-setting.tsx index 6818a6a..94d8510 100644 --- a/src/components/editor/sidebar-element-settings-tab/advance-setting.tsx +++ b/src/components/editor/sidebar-element-settings-tab/advance-setting.tsx @@ -41,21 +41,12 @@ export default function AdvanceSetting() { const handleOnChanges = (e: any) => { const styleSettings = e.target.id; - let value = e.target.value; - const styleObject = { - [styleSettings]: value, - }; + const styleValue = e.target.value; dispatch({ - type: "UPDATE_ELEMENT", + type: "UPDATE_ELEMENT_STYLE", payload: { - elementDetails: { - ...editor.state.selectedElement, - styles: { - ...editor.state.selectedElement.styles, - ...styleObject, - }, - }, + [styleSettings]: styleValue, }, }); }; diff --git a/src/components/editor/sidebar-element-settings-tab/map-setting.tsx b/src/components/editor/sidebar-element-settings-tab/map-setting.tsx index 2dc5446..47d1390 100644 --- a/src/components/editor/sidebar-element-settings-tab/map-setting.tsx +++ b/src/components/editor/sidebar-element-settings-tab/map-setting.tsx @@ -1,10 +1,10 @@ "use client"; import { useEditor } from "~/components/editor/provider"; +import type { InferEditorElement } from "~/components/editor/type"; import { Input } from "~/components/ui/input"; -// TODO: Fix type safety -type Props = { element: any }; +type Props = { element: InferEditorElement<"map"> }; export default function MapSetting({ element }: Props) { const { dispatch } = useEditor(); diff --git a/src/components/editor/type.ts b/src/components/editor/type.ts index e175a3e..543a5af 100644 --- a/src/components/editor/type.ts +++ b/src/components/editor/type.ts @@ -2,36 +2,42 @@ import { editorTabValue } from "~/components/editor/constant"; export type DeviceType = "Desktop" | "Mobile" | "Tablet"; -export type EditorElementType = - | "__body" - | "container" - | "2Col" - | "text" - | "section" - | "image" - | "map" - | null; - export type EditorTabTypeValue = (typeof editorTabValue)[keyof typeof editorTabValue]; -export type EditorElement = { - id: string; - styles: React.CSSProperties; - name: string; - type: EditorElementType; - content: - | EditorElement[] - | { href?: string; innerText?: string; src?: string; address?: string }; +type EditorElementContentMap = { + __body: EditorElement[]; + container: EditorElement[]; + "2Col": EditorElement[]; + text: { innerText: string }; + image: { src: string; alt?: string }; + map: { address: string }; + empty: []; }; +export type EditorElementType = keyof EditorElementContentMap; + +export type EditorElement = { + [K in EditorElementType]: { + type: K; + id: string; + name: string; + styles: React.CSSProperties; + content: EditorElementContentMap[K]; + }; +}[EditorElementType]; + +export type InferEditorElement = Extract< + EditorElement, + { type: K } +>; + export type EditorState = { elements: EditorElement[]; selectedElement: EditorElement; currentTabValue: EditorTabTypeValue; device: DeviceType; isPreviewMode: boolean; - funnelPageId: string; }; export type EditorHistory = {