Files
Aislo/B07_DesignDetail/openwebcad/src/state.ts
T
eomsangdonandClaude Opus 5 b0c2e09b16 feat(B07): 자리표 칸 크기 조절·그림 비율 유지·편집 중 실제 값 미리보기
- 텍스트 자리표에 칸 크기(boxWidth/boxHeight) 도입 — basePoint 를 칸 중심으로 두고 가로·세로 가운데 정렬, 선택 시 칸 테두리 표시, DXF 내보내기도 중앙 정렬로 반영
- 그림은 칸 안에서 비율을 유지하며 맞춤(letterbox) — 칸을 늘려도 로고·서명이 찌그러지지 않음
- 자리표 패널에 선택 항목 칸 크기(가로·세로 mm) 입력 추가
- 도각 편집 중 자리표에 실제 값 미리보기 — 서버가 표제란 값(공사명·회사·담당자·로고·서명)을 함께 내려주고 화면만 값으로 표시, 저장값은 {{키}} 토큰 유지
- title_block_fields 공개화 및 frame-template 응답에 fields 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 15:17:39 +09:00

600 lines
20 KiB
TypeScript

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 { StateVariable, type UndoState, createStack } 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: <explanation>
let activeToolActor: Actor<any> | 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<string> = 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<string> = 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 <input type="color"> can consume it directly)
*
* 종이 배경(흰색)에 그리므로 검정이 기본이다. 원본 OpenWebCAD의 흰색을 그대로 두면
* 그은 선도 색 스와치도 흰 바탕에 묻혀 보이지 않는다(2026-09-01 실측).
*/
let activeLineColor = '#000000';
/**
* 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: '#000000', // 흰 종이 배경 — activeLineColor와 같은 이유로 검정이 기본이다
bold: false,
italic: false,
};
/**
* Layers that can contain entities
*/
let layers: Layer[] = [
{
id: crypto.randomUUID(),
isLocked: false,
isVisible: true,
name: '기본',
},
];
/**
* 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<string, Layer> = new Map(layers.map((layer) => [layer.id, layer]));
let snapEnabled = true;
let gridEnabled = false;
/** 객체 스냅 추적 — 스냅점에 머물면 그 점에서 정렬 가이드를 뻗는다 (AutoCAD F11) */
let snapTrackingEnabled = true;
/**
* 부모(B08 페이지)에서 넘어온 설계 컨텍스트. 수량 산출 패널이 이 값을 읽어
* 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다.
*/
let designMeta: DesignMeta | null = null;
/** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */
let frameEditMode = false;
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
let frameFields: Record<string, string> = {};
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
* 없으면 사용자가 그은 선이 경고 없이 사라진다(2026-09-01 실측).
* 도면 적재와 저장 응답에서 내려간다.
*/
let drawingDirty = false;
// getters
export const getCanvas = () => canvas;
export const getActiveToolActor = () => activeToolActor;
export const getLastStateInstructions = () => lastStateInstructions;
export const getEntities = (): Entity[] => entities;
/**
* 집을 수 있는 객체 — 잠금 도면층(도각 b08-frame·원지반 b08-ground 등 참조용)을 뺀다.
* 선택·지우기·수정 도구의 대상 찾기와 화면맞춤은 이 목록을 쓴다. AutoCAD가 도각을
* 배치(도면공간)에 두어 모형공간 작업에 끼지 않게 하는 것과 같은 자리다.
*/
export const getPickableEntities = (): Entity[] =>
entities.filter((entity) => !layersById.get(entity.layerId)?.isLocked);
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 getSnapTrackingEnabled = () => snapTrackingEnabled;
export const getDesignMeta = (): DesignMeta | null => designMeta;
export const isFrameEditMode = (): boolean => frameEditMode;
export const getFrameFields = (): Record<string, string> => frameFields;
export const isDrawingDirty = () => drawingDirty;
/**
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
*/
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
canvas = newCanvas;
};
export const setActiveToolActor = (
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
newToolActor: Actor<any>,
triggerReact = true
) => {
const oldToolActor = getActiveToolActor();
oldToolActor?.stop();
activeToolActor = newToolActor;
activeToolActor.subscribe({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
next: (state: MachineSnapshot<any, any, any, any, any, any, any, any>) => {
const stateInstructions = Object.values(state?.getMeta() as Record<string, StateMetaData>)[0]
?.instructions;
if (getLastStateInstructions() === stateInstructions) {
return;
}
setLastStateInstructions(stateInstructions || null);
},
error: (err) => {
toast.error(
`Error in tool actor: ${
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
(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: <explanation>
activeTool: (activeToolActor.src as any).config.context.type,
});
if (triggerReact) {
triggerReactUpdate(StateVariable.activeTool);
}
};
/** 화면에 알린다 — `window`가 없는 Node 시험에서는 통지를 건너뛴다.
* (`triggerReactUpdate`가 이미 같은 이유로 시험 환경을 건너뛴다.) */
export const notifyWindow = (event: HtmlEvent) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(event));
};
export const setLastStateInstructions = (newInstructions: string | null) => {
lastStateInstructions = newInstructions;
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => {
if (trackInUndoStack) {
trackUndoState(StateVariable.entities, newEntities);
}
entities = newEntities;
bumpSceneVersion();
if (trackInUndoStack) {
drawingDirty = true;
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
};
/** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */
export const clearDrawingDirty = () => {
drawingDirty = false;
};
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
// 잠금 도면층(도각·등고선·계류·원지반)은 배경이다 — 마우스가 스쳐도 밝아지지 않는다
// (2026-09-01 사용자 지시). 거르는 자리를 여기 한 곳에 둬 호출부가 늘어도 새지 않게 한다.
const locked = new Set(
entities.filter((entity) => layersById.get(entity.layerId)?.isLocked).map((entity) => entity.id)
);
if (locked.size) newEntityIds = newEntityIds.filter((id) => !locked.has(id));
highlightedEntityIds = newEntityIds;
highlightedEntityIdSet = new Set(newEntityIds);
};
export const setSelectedEntityIds = (newEntityIds: string[]) => {
// 잠금 도면층 객체는 어느 경로로도 선택되지 않는다 — 선택이 곧 삭제·이동 대상이라
// 호출부(Ctrl+A 등)마다 거르지 않고 여기 한 곳에서 막는다.
const locked = new Set(
entities.filter((entity) => layersById.get(entity.layerId)?.isLocked).map((entity) => entity.id)
);
if (locked.size) newEntityIds = newEntityIds.filter((id) => !locked.has(id));
selectedEntityIds = newEntityIds;
selectedEntityIdSet = new Set(newEntityIds);
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
notifyWindow(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<typeof activeTextStyle>,
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) => {
// 잠금 도면층(도각·원지반 등 배경)은 현재 도면층이 될 수 없다 — 새 객체가 배경으로
// 떨어지면 그린 즉시 집지도 지우지도 못한다. 호출부(브리지·복구·가져오기·도면층
// 패널·리본)마다 거르지 않고 여기 한 곳에서 잠금 아닌 첫 층으로 돌린다.
if (layersById.get(newActiveLayerId)?.isLocked) {
newActiveLayerId = layers.find((layer) => !layer.isLocked)?.id ?? newActiveLayerId;
}
activeLayerId = newActiveLayerId;
if (triggerReact) {
triggerReactUpdate(StateVariable.layers);
}
};
export const setSnapEnabled = (enabled: boolean) => {
snapEnabled = enabled;
if (!enabled) {
setSnapPoint(null);
setSnapPointOnAngleGuide(null);
setHoveredSnapPoints([]);
setAngleGuideEntities([]);
}
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setSnapTrackingEnabled = (enabled: boolean) => {
snapTrackingEnabled = enabled;
if (!enabled) {
setHoveredSnapPoints([]);
}
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
bumpSceneVersion();
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
triggerReactUpdate(StateVariable.designMeta);
};
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
frameEditMode = enabled;
frameFields = enabled ? fields : {};
notifyWindow(HtmlEvent.UPDATE_STATE);
};
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은
// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다.
// 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: <explanation>
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;
}
}
/**
* 지금 객체 목록을 되돌리기 스택의 바닥으로 삼는다. 서버 도면을 실은 직후에 부른다 —
* 안 부르면 첫 Ctrl+Z가 도면을 싣기 전의 빈 상태로 되돌아가 화면이 백지가 된다.
*/
export function resetUndoBaseline() {
undoStack.clear(StateVariable.entities);
undoStack.push({ variable: StateVariable.entities, value: entities });
drawingDirty = false;
}
export function undo() {
const undoState = undoStack.undo();
if (!undoState) return;
updateStates(undoState);
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
export function redo() {
const redoState = undoStack.redo();
if (!redoState) return;
updateStates(redoState);
drawingDirty = true;
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
export function triggerReactUpdate(variable: StateVariable) {
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
return;
}
if (!reactStateVariables.includes(variable)) {
return;
}
notifyWindow(HtmlEvent.UPDATE_STATE);
}