- 텍스트 자리표에 칸 크기(boxWidth/boxHeight) 도입 — basePoint 를 칸 중심으로 두고 가로·세로 가운데 정렬, 선택 시 칸 테두리 표시, DXF 내보내기도 중앙 정렬로 반영
- 그림은 칸 안에서 비율을 유지하며 맞춤(letterbox) — 칸을 늘려도 로고·서명이 찌그러지지 않음
- 자리표 패널에 선택 항목 칸 크기(가로·세로 mm) 입력 추가
- 도각 편집 중 자리표에 실제 값 미리보기 — 서버가 표제란 값(공사명·회사·담당자·로고·서명)을 함께 내려주고 화면만 값으로 표시, 저장값은 {{키}} 토큰 유지
- title_block_fields 공개화 및 frame-template 응답에 fields 추가
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
600 lines
20 KiB
TypeScript
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);
|
|
}
|