import type { Point } from '@flatten-js/core'; import { isEqual } from 'es-toolkit'; import { toast } from 'react-toastify'; import type { Actor, MachineSnapshot } from 'xstate'; import { type DesignMeta, type HoverPoint, HtmlEvent, type Layer, type SnapPoint, type StateMetaData, } from './App.types'; import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; import { bumpSceneVersion } from './helpers/scene-version'; import { createStack, StateVariable, type UndoState } from './helpers/undo-stack'; import type { InputController } from './inputController/input-controller.ts'; // state variables // state variables /** * Canvas element */ let canvas: HTMLCanvasElement | null = null; /** * Active tool xstate actor */ // biome-ignore lint/suspicious/noExplicitAny: let activeToolActor: Actor | null = null; /** * Last state instructions */ let lastStateInstructions: string | null = null; /** * List of entities like lines, circles, rectangles, etc to be drawn on the canvas */ let entities: Entity[] = []; /** * Entities that are highlighted: when the mouse is close to an entity */ let highlightedEntityIds: string[] = []; let highlightedEntityIdSet: Set = new Set(); /** * Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle */ let selectedEntityIds: string[] = []; let selectedEntityIdSet: Set = new Set(); /** * Whether to draw the cursor or not */ let shouldDrawCursor = false; /** * Angle guide temporary entities, these are recalculated every frame as the user moves their mouse during a draw action */ let angleGuideEntities: Entity[] = []; /** * These are entities that are being drawn on the canvas during a move, scale or rotate operation * To give visual feedback to the user of the final result */ let ghostHelperEntities: Entity[] = []; /** * Should helper entities be calculated and drawn? eg: angle guides and snap points */ let shouldDrawHelpers = false; /** * Entities that are drawn for debugging the application purposes */ let debugEntities: Entity[] = []; /** * Angle step for angle guide. Can be changes by the user using the angle step buttons */ let angleStep = 45; /** * Draw controller to draw lines to the screen while taking zoom level and screen offset into account * We use a drawController, so we can reuse draw logic of the entities for printing to PDF and possibly more formats in the future */ let screenCanvasDrawController: ScreenCanvasDrawController | null = null; /** * Class object to manage keyboard input while drawing * It also draws the inputted text to the canvas, next to the cursor */ let inputController: InputController | null = null; /** * Location where the user started dragging their mouse * Used for panning the screen */ let panStartLocation: Point | null = null; /** * Entity snap point like endpoint of a line or mid-point of a line or circle center point or the intersection of 2 lines */ let snapPoint: SnapPoint | null = null; /** * Snap point on angle guide */ let snapPointOnAngleGuide: SnapPoint | null = null; /** * Last drawn point of an entity that is being drawn to be used as angle guide origin */ let angleGuideOriginPoint: Point | null = null; /** * Snap points that are hovered for a certain amount of time */ let hoveredSnapPoints: HoverPoint[] = []; /** * Timestamp of the last draw call */ let lastDrawTimestamp: DOMHighResTimeStamp = 0; /** * Active line color (7-char hex so can consume it directly) */ let activeLineColor = '#ffffff'; /** * Active line width */ let activeLineWidth = 1; /** * Active line dash pattern (screen px). undefined → solid line */ let activeLineDash: number[] | undefined = undefined; /** * Active text style defaults applied to newly created text and selected text entities */ let activeTextStyle = { fontFamily: 'Noto Sans KR', fontSize: 16, textColor: '#ffffff', }; /** * Layers that can contain entities */ let layers: Layer[] = [ { id: crypto.randomUUID(), isLocked: false, isVisible: true, name: 'Default', }, ]; /** * Id of the currently active layer where newly drawn entities will be added to */ let activeLayerId: string = layers[0].id; /** * layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this * lookup once per entity per frame — a linear find() was a hot spot) */ let layersById: Map = new Map(layers.map((layer) => [layer.id, layer])); let snapEnabled = true; let gridEnabled = false; /** * 부모(B07 페이지)에서 넘어온 설계 컨텍스트. 수량 산출 패널이 이 값을 읽어 * 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다. */ let designMeta: DesignMeta | null = null; // getters export const getCanvas = () => canvas; export const getActiveToolActor = () => activeToolActor; export const getLastStateInstructions = () => lastStateInstructions; export const getEntities = (): Entity[] => entities; export const getSelectedEntityIds = () => selectedEntityIds; export const getShouldDrawCursor = () => shouldDrawCursor; export const getAngleGuideEntities = () => angleGuideEntities; export const getGhostHelperEntities = () => ghostHelperEntities; export const getShouldDrawHelpers = () => shouldDrawHelpers; export const getDebugEntities = () => debugEntities; export const getAngleStep = () => angleStep; export const getPanStartLocation = () => panStartLocation; export const getSnapPoint = () => snapPoint; export const getSnapPointOnAngleGuide = () => snapPointOnAngleGuide; export const getAngleGuideOriginPoint = () => angleGuideOriginPoint; export const getHoveredSnapPoints = () => hoveredSnapPoints; export const getLastDrawTimestamp = () => lastDrawTimestamp; export const getActiveLineColor = () => activeLineColor; export const getActiveLineWidth = () => activeLineWidth; export const getActiveLineDash = () => activeLineDash; export const getActiveTextStyle = () => activeTextStyle; export const getScreenCanvasDrawController = (): ScreenCanvasDrawController => { if (!screenCanvasDrawController) { throw new Error('getScreenCanvasDrawController() returned null'); } return screenCanvasDrawController; }; export const getInputController = (): InputController => { if (!inputController) { throw new Error('getInputController() returned null'); } return inputController; }; export const getSelectedEntities = (): Entity[] => { return entities.filter((e) => selectedEntityIdSet.has(e.id)); }; export const getNotSelectedEntities = (): Entity[] => { return entities.filter((e) => !selectedEntityIdSet.has(e.id)); }; export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id); export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id); export const getHighlightedEntityIds = () => highlightedEntityIds; export const getLayers = () => { return layers; }; export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId); export const getActiveLayerId = (): string => { return activeLayerId; }; export const getSnapEnabled = () => snapEnabled; export const getGridEnabled = () => gridEnabled; export const getDesignMeta = (): DesignMeta | null => designMeta; // setters export const setCanvas = (newCanvas: HTMLCanvasElement) => { canvas = newCanvas; }; export const setActiveToolActor = ( // biome-ignore lint/suspicious/noExplicitAny: newToolActor: Actor, triggerReact = true ) => { const oldToolActor = getActiveToolActor(); oldToolActor?.stop(); activeToolActor = newToolActor; activeToolActor.subscribe({ // biome-ignore lint/suspicious/noExplicitAny: next: (state: MachineSnapshot) => { const stateInstructions = Object.values(state?.getMeta() as Record)[0] ?.instructions; if (getLastStateInstructions() === stateInstructions) { return; } setLastStateInstructions(stateInstructions || null); }, error: (err) => { toast.error( `Error in tool actor: ${ // biome-ignore lint/suspicious/noExplicitAny: (err as any)?.message || 'unknown error' }` ); console.error('Error in tool actor', { err, newToolActor }); }, }); activeToolActor.start(); console.log('User clicked on tool: ', { // biome-ignore lint/suspicious/noExplicitAny: activeTool: (activeToolActor.src as any).config.context.type, }); if (triggerReact) { triggerReactUpdate(StateVariable.activeTool); } }; export const setLastStateInstructions = (newInstructions: string | null) => { lastStateInstructions = newInstructions; window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => { if (trackInUndoStack) { trackUndoState(StateVariable.entities, newEntities); } entities = newEntities; bumpSceneVersion(); if (trackInUndoStack) { window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } }; export const setHighlightedEntityIds = (newEntityIds: string[]) => { highlightedEntityIds = newEntityIds; highlightedEntityIdSet = new Set(newEntityIds); }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; selectedEntityIdSet = new Set(newEntityIds); bumpSceneVersion(); // selection style (dashed) is baked into the scene cache window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setShouldDrawCursor = (newValue: boolean) => { shouldDrawCursor = newValue; }; export const setAngleGuideEntities = (newAngleGuideEntities: Entity[]) => { angleGuideEntities = newAngleGuideEntities; }; export const setGhostHelperEntities = (newGhostHelperEntities: Entity[]) => { ghostHelperEntities = newGhostHelperEntities; }; export const setShouldDrawHelpers = (shouldDraw: boolean) => { setSnapPoint(null); setSnapPointOnAngleGuide(null); setAngleGuideEntities([]); shouldDrawHelpers = shouldDraw; }; export const setDebugEntities = (newDebugEntities: Entity[]) => { debugEntities = newDebugEntities; }; export const setAngleStep = (newStep: number, triggerReact = true) => { angleStep = newStep; if (triggerReact) { triggerReactUpdate(StateVariable.activeTool); } }; export const setScreenCanvasDrawController = ( newScreenCanvasDrawController: ScreenCanvasDrawController ) => { screenCanvasDrawController = newScreenCanvasDrawController; }; export const setInputController = (newInputController: InputController) => { inputController = newInputController; }; export const setPanStartLocation = (newLocation: Point | null) => { panStartLocation = newLocation; }; export const setSnapPoint = (newSnapPoint: SnapPoint | null) => { snapPoint = newSnapPoint; }; export const setSnapPointOnAngleGuide = (newSnapPointOnAngleGuide: SnapPoint | null) => { snapPointOnAngleGuide = newSnapPointOnAngleGuide; }; export const setAngleGuideOriginPoint = (newAngleGuideOriginPoint: Point | null) => { angleGuideOriginPoint = newAngleGuideOriginPoint; }; export const setHoveredSnapPoints = (newHoveredSnapPoints: HoverPoint[]) => { hoveredSnapPoints = newHoveredSnapPoints; }; export const setLastDrawTimestamp = (newTimestamp: DOMHighResTimeStamp) => { lastDrawTimestamp = newTimestamp; }; export const setActiveLineColor = (newColor: string, triggerReact = true) => { activeLineColor = newColor; if (triggerReact) { triggerReactUpdate(StateVariable.activeLineColor); } }; export const setActiveLineWidth = (newWidth: number, triggerReact = true) => { activeLineWidth = newWidth; if (triggerReact) { triggerReactUpdate(StateVariable.activeLineWidth); } }; export const setActiveLineDash = (newDash: number[] | undefined, triggerReact = true) => { activeLineDash = newDash; if (triggerReact) { triggerReactUpdate(StateVariable.activeLineDash); } }; export const setActiveTextStyle = ( newStyle: Partial, triggerReact = true ) => { activeTextStyle = { ...activeTextStyle, ...newStyle }; if (triggerReact) { triggerReactUpdate(StateVariable.activeTextStyle); } }; export const setLayers = (newLayers: Layer[], triggerReact = true) => { layers = newLayers; layersById = new Map(newLayers.map((layer) => [layer.id, layer])); bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows if (triggerReact) { triggerReactUpdate(StateVariable.layers); } }; export const setActiveLayerId = (newActiveLayerId: string, triggerReact = true) => { activeLayerId = newActiveLayerId; if (triggerReact) { triggerReactUpdate(StateVariable.layers); } }; export const setSnapEnabled = (enabled: boolean) => { snapEnabled = enabled; if (!enabled) { setSnapPoint(null); setSnapPointOnAngleGuide(null); setHoveredSnapPoints([]); setAngleGuideEntities([]); } window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setGridEnabled = (enabled: boolean) => { gridEnabled = enabled; bumpSceneVersion(); window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setDesignMeta = (newMeta: DesignMeta | null) => { designMeta = newMeta; triggerReactUpdate(StateVariable.designMeta); }; /** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */ export const setDesignQuantityTable = (table: Record) => { if (!designMeta) return; designMeta = { ...designMeta, quantityTable: table, confirmed: false }; triggerReactUpdate(StateVariable.designMeta); }; // Computed setters export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => { const entityIdsToBeDeleted = entitiesToDelete.map((entity) => entity.id); const newEntities = getEntities().filter((entity) => !entityIdsToBeDeleted.includes(entity.id)); setEntities(newEntities, trackInUndoStack); return newEntities; }; export const addEntities = (entitiesToAdd: Entity[], trackInUndoStack: boolean): Entity[] => { const newEntities = [...getEntities(), ...entitiesToAdd]; setEntities(newEntities, trackInUndoStack); return newEntities; }; // Undo redo states const reactStateVariables: StateVariable[] = [ StateVariable.activeTool, StateVariable.angleStep, StateVariable.activeLineColor, StateVariable.activeLineWidth, StateVariable.activeLineDash, StateVariable.activeTextStyle, StateVariable.designMeta, StateVariable.screenZoom, StateVariable.layers, ]; const undoableStateVariables: StateVariable[] = [StateVariable.entities]; const undoStack = createStack(); // biome-ignore lint/suspicious/noExplicitAny: function trackUndoState(variable: StateVariable, value: any) { if (!undoableStateVariables.includes(variable)) return; const lastUndoState = undoStack.peek(); if (isEqual(value, lastUndoState?.value)) { return; // Sometimes entities are updated because of highlighting, but not actually differ with the last list of entities } // Push the new undo state undoStack.push({ variable: variable, value: value }); } function updateStates(undoState: UndoState) { const variable = undoState.variable; const value = undoState.value; // Do not use the setters for setting these states, otherwise you trigger the undo stack again switch (variable) { case StateVariable.entities: entities = value; bumpSceneVersion(); break; } } export function undo() { const undoState = undoStack.undo(); if (!undoState) return; updateStates(undoState); window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } export function redo() { const redoState = undoStack.redo(); if (!redoState) return; updateStates(redoState); window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } export function triggerReactUpdate(variable: StateVariable) { if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') { return; } if (!reactStateVariables.includes(variable)) { return; } window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }