fix(B07): 편집이 사라지지 않게 하고 확정한 도면을 잠근다

사용자 편의성 점검 15건 중 반영 확정분 12건.

편집 손실 방지
- CAD에 미저장 표시(drawingDirty)를 두고, 도면을 바꾸기 전에 묻는다.
  전에는 그은 선이 경고 없이 사라졌다(실측: 캔버스 서명이 원본과 동일).
- 도면 전환을 겹쳐 눌러도 늦게 온 응답이 화면을 덮지 않게 가드를 뒀다.
- 도면층 삭제·객체 이동을 되돌리기에 실어 Ctrl+Z로 돌아오게 했다.

확정한 도면은 읽기 전용
- runCommand 한 곳에서 보는 명령만 통과시킨다(허용 명시 방식).
- 도면을 실을 때 앞 도면의 그리기 도구를 내린다 — 켜 둔 도구가 확정본에도
  계속 그렸다.
- 확정 버튼 자리를 [현재 도면 확정] / [수정]으로 가른다. 확정 해제는 [수정]
  한 곳뿐 — 되돌리기·색 고르기로 확정이 풀리던 경로를 없앴다.

그 밖
- 잠금 도면층(도각·등고선·계류)은 마우스가 스쳐도 하이라이트하지 않는다.
- 준비 중 도면 7종도 빈 도각으로 열린다(눌리지 않는 회색 버튼 제거).
- 도면층 행이 잘리지 않게 패널을 300px로 넓히고 이름을 줄여 담는다.
- 기본 그리기 색을 검정으로 — 흰 종이에 흰 선이라 안 보였다.
- 색 고르는 동안 변경 통지가 연발하지 않는다.
- 수량표는 앞 단계 산출물이라 도면에서 고치지 못하게 막고 안내한다.
- 자동백업 칸을 도면별로 나눈다 — 되살리면 다른 도면에 붙었다.
- 횡단 장 id를 시작 측점 기준으로 바꿔 구간이 달라져도 옛 확정이 안 붙는다.
- 회사 도각 저장 전에 좌표를 검사하고, 기본 도각으로 되돌리기를 연다.
- 횡단 파일이 없어도 나머지 도면 목록은 남는다.
- 불러오는 중·실패를 화면에 표시한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 19:05:22 +09:00
co-authored by Claude Fable 5
parent f82e60492e
commit 17e14a25ed
22 changed files with 666 additions and 105 deletions
+22 -2
View File
@@ -8,7 +8,9 @@
/* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */
--cad-command-height: 86px;
--cad-status-height: 28px;
--cad-panel-width: 248px;
/* 도면층 한 행에 아이콘 6개와 이름이 함께 들어가는 폭. 248px에서는 이름 몫이
21px밖에 남지 않아 글자가 뭉개졌다(2026-09-01 실측). */
--cad-panel-width: 300px;
font-family: var(--font-body);
color: var(--color-text-body);
background: var(--color-bg);
@@ -296,7 +298,8 @@ body > canvas[data-id="canvas"] {
width: 44px;
padding: 1px;
background: var(--cad-chrome-sunken);
border: 1px solid var(--cad-line);
/* 흰색을 골라도 스와치 자리가 보이게 테두리를 진하게 준다 (2026-09-01) */
border: 1px solid var(--cad-text-muted);
border-radius: 3px;
cursor: pointer;
}
@@ -442,12 +445,29 @@ body > canvas[data-id="canvas"] {
max-height: calc(100vh - 250px);
padding: 9px;
overflow-y: auto;
overflow-x: hidden;
}
.cad-layer-manager button {
min-height: 36px;
padding-top: 6px;
padding-bottom: 6px;
}
/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이
행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */
.cad-layer-manager .layer > button > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.cad-layer-manager .layer > button > div {
flex-shrink: 0;
}
/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */
.cad-layer-manager .layer > button [data-size="small"] {
padding-left: 2px;
padding-right: 2px;
min-width: 0;
}
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
.cad-layer-manager button[data-type="regular"] {
background: var(--cad-chrome-sunken);
+4 -1
View File
@@ -40,11 +40,14 @@ export enum HtmlEvent {
/** 부모(B08 페이지)가 도면과 함께 넘기는 설계 컨텍스트 (수량 패널 표시용). */
export interface DesignMeta {
kind: 'cross' | 'longitudinal' | 'mass_haul' | 'watershed';
kind: 'cover' | 'cross' | 'longitudinal' | 'mass_haul' | 'watershed' | 'blank';
/** 도면 식별자 — 자동백업을 도면별로 나누는 열쇠. */
drawingId: string;
/** 패널 제목 (측점 라벨, 예: "2+0.0" 또는 "종단도 전체") */
title: string;
/** 측점 부가 정보 (예: "STA.0+050.000") */
info: string;
/** 확정본은 읽기 전용 — 그리기·수정·값 편집이 막힌다. 부모의 [수정]으로 푼다. */
confirmed: boolean;
/** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */
quantityTable: Record<string, number | null> | null;
@@ -56,6 +56,45 @@ for (const command of ALL_COMMANDS) {
}
}
/**
* 확정한 도면에서도 쓸 수 있는 명령 — **보는 일만 하는 것들**이다
* (2026-09-01 사용자 확정: 확정하면 수정이 안 되고, 풀려면 [수정]을 누른다).
*
* 허용을 명시하는 쪽으로 짠다. 새 편집 명령이 늘어도 자동으로 막히고, 반대로 짜면
* 새 명령마다 여기 적어야 해 언젠가 빠뜨린다.
*/
const VIEW_ONLY_COMMAND_IDS: ReadonlySet<string> = new Set([
// 보기 — 확대·이동·다시그리기·선택
...VIEW_COMMANDS.map((command) => command.id),
// 조회 — 거리·면적·각도처럼 재기만 하는 것
...INQUIRY_COMMANDS.map((command) => command.id),
// 도면층 보이기·잠금 — 객체를 건드리지 않는 것만 (LAYMCH·LAYCUR·LAYMRG·LAYDEL 제외)
'LAYER',
'LAYCURSET',
'LAYOFF',
'LAYON',
'LAYFRZ',
'LAYTHW',
'LAYLCK',
'LAYULK',
'LAYISO',
'LAYUNISO',
'LAYERP',
'LAYERSTATE',
'LAYERSTATERESTORE',
'LAYWALK',
// 파일 — 내보내기와 클립보드 복사는 도면을 바꾸지 않는다
'EXPORT',
'EXPORTSVG',
'EXPORTPNG',
'COPYCLIP',
'COPYBASE',
]);
/** 확정한 도면에서 이 명령을 실행해도 되는가. */
export const isViewOnlyCommand = (command: CadCommand): boolean =>
VIEW_ONLY_COMMAND_IDS.has(command.id);
export const getAllCommands = (): CadCommand[] => ALL_COMMANDS;
export const getCommandById = (id: string): CadCommand | undefined => COMMANDS_BY_ID.get(id);
@@ -2,9 +2,9 @@
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { HtmlEvent } from '../App.types';
import { getSelectedEntities, setActiveToolActor } from '../state';
import { getSelectedEntities, isDrawingReadOnly, setActiveToolActor } from '../state';
import type { CadCommand } from './command.types';
import { getCommandById, resolveCommandInput } from './registry';
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
const COMMAND_HISTORY_LIMIT = 200;
@@ -24,6 +24,13 @@ function log(line: string) {
/** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */
export function runCommand(command: CadCommand): string {
// 확정한 도면은 읽기 전용이다 — 보는 명령만 통과시킨다. 리본·명령행·단축키가 모두
// 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정).
if (isDrawingReadOnly() && !isViewOnlyCommand(command)) {
toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`);
log(`${command.id}: 확정 도면 — 실행하지 않음`);
return '';
}
if (command.needsSelection && getSelectedEntities().length === 0) {
toast.info(`${command.label}: 객체를 먼저 선택하십시오.`);
log(`${command.id}: 선택 없음`);
@@ -44,13 +44,15 @@ export const LayerManager: FC<LayerManagerProps> = ({
for (const entity of selectedEntities) {
entity.layerId = layerId;
}
console.info(`Assigned ${selectedEntities.length} entities to layer`);
// 객체를 옮기는 것도 도면 편집이다 — 되돌리기·미저장 표시에 실어야 한다.
setEntities([...getEntities()], true);
};
const handleDeleteLayer = (evt: MouseEvent, layerId: string): void => {
evt.stopPropagation();
const entitiesNotOnLayer = getEntities().filter((entity) => entity.layerId !== layerId);
setEntities(entitiesNotOnLayer);
// 도면층을 지우면 그 위의 객체가 통째로 사라진다 — 되돌리기에 실어야 Ctrl+Z로 돌아온다.
setEntities(entitiesNotOnLayer, true);
setLayers(getLayers().filter((layer) => layer.id !== layerId));
if (getActiveLayerId() === layerId) {
setActiveLayerId(getLayers()[0].id);
@@ -36,16 +36,31 @@ export const dashToLineType = (dash: number[] | undefined): string =>
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
/** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
function applyToSelection(mutate: (entity: Entity) => void): boolean {
function applyToSelection(mutate: (entity: Entity) => void, trackInUndoStack = true): boolean {
const selected = getSelectedEntities();
if (!selected.length) return false;
for (const entity of selected) {
mutate(entity);
}
setEntities([...getEntities()], true);
setEntities([...getEntities()], trackInUndoStack);
return true;
}
/**
* 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면
* 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 끄는 동안에는 화면만 칠하고,
* 멎은 뒤 한 번만 되돌리기·통지에 싣는다.
*/
const COLOR_COMMIT_DELAY_MS = 350;
let colorCommitTimer: ReturnType<typeof setTimeout> | undefined;
function paintNowCommitLater(mutate: (entity: Entity) => void): void {
applyToSelection(mutate, false); // 화면만 — 되돌리기·미저장 표시는 아직
clearTimeout(colorCommitTimer);
colorCommitTimer = setTimeout(() => {
applyToSelection(() => {}, true); // 멎었다 — 이번 한 번만 실어 보낸다
}, COLOR_COMMIT_DELAY_MS);
}
export const PropertiesWidget: FC = () => {
const lineColor = getActiveLineColor();
const lineWidth = getActiveLineWidth();
@@ -59,9 +74,10 @@ export const PropertiesWidget: FC = () => {
type="color"
value={lineColor}
onChange={(event) => {
setActiveLineColor(event.target.value);
applyToSelection((entity) => {
entity.lineColor = event.target.value;
const color = event.target.value;
setActiveLineColor(color);
paintNowCommitLater((entity) => {
entity.lineColor = color;
});
}}
/>
@@ -145,13 +161,16 @@ export const LayersWidget: FC = () => {
export const TextStyleWidget: FC = () => {
const textStyle = getActiveTextStyle();
const handle = (patch: Partial<typeof textStyle>) => {
const handle = (patch: Partial<typeof textStyle>, live = false) => {
setActiveTextStyle(patch);
applyToSelection((entity) => {
const mutate = (entity: Entity) => {
if (entity.getType() === EntityName.Text) {
(entity as TextEntity).setTextOptions(patch);
}
});
};
// 색만 드래그 중 연발한다 — 나머지 항목은 한 번 고르면 끝이라 바로 싣는다.
if (live) paintNowCommitLater(mutate);
else applyToSelection(mutate);
};
return (
@@ -203,7 +222,7 @@ export const TextStyleWidget: FC = () => {
<input
type="color"
value={textStyle.textColor}
onChange={(event) => handle({ textColor: event.target.value })}
onChange={(event) => handle({ textColor: event.target.value }, true)}
/>
</label>
</div>
@@ -24,10 +24,10 @@ export const Toolbar: FC = () => {
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
useEffect(() => {
document.documentElement.style.setProperty(
'--cad-panel-width',
panelCollapsed ? '0px' : '248px'
);
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
// 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨).
if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px');
else document.documentElement.style.removeProperty('--cad-panel-width');
document.documentElement.style.setProperty(
'--cad-command-height',
commandLineVisible ? '86px' : '0px'
@@ -6,7 +6,14 @@
import { toast } from 'react-toastify';
import { HtmlEvent } from '../App.types';
import type { Entity } from '../entities/Entity';
import { getEntities, resetUndoBaseline, setActiveLayerId, setEntities, setLayers } from '../state';
import {
getEntities,
isDrawingReadOnly,
resetUndoBaseline,
setActiveLayerId,
setEntities,
setLayers,
} from '../state';
import { exportEntitiesAndLayersToJsonString } from './import-export-handlers/export-entities-to-json';
import { getEntitiesAndLayersFromJsonString } from './import-export-handlers/import-entities-from-json';
@@ -22,9 +29,37 @@ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
/** 마지막으로 백업한 객체 배열. setEntities가 매번 새 배열을 만들어 참조로 비교된다 */
let lastBackedUp: Entity[] | null = null;
/**
* 지금 백업이 어느 도면 것인가. 칸을 하나만 쓰면 도면을 넘겨볼 때마다 서로 덮어
* 마지막에 본 것만 남고, 되살리면 **지금 열린 다른 도면 위에 붙는다**
* (2026-09-01 지적). B07 임베드에서는 도면을 실을 때마다 이 값이 바뀐다.
*/
let recoveryScope: string | null = null;
/** 서버 도면을 싣는 동안에는 백업하지 않는다 — 적재가 사용자 백업을 덮어 쓴다. */
let suspended = false;
function recoveryKey(): string {
return recoveryScope ? `${RECOVERY_KEY}__${recoveryScope}` : RECOVERY_KEY;
}
/**
* 백업 칸을 이 도면 것으로 옮긴다 (B07 브리지가 도면을 실을 때 부른다).
* 옮기는 동안은 백업을 멈춘다 — 방금 실은 서버 도면이 사용자 백업을 덮지 않게.
*/
export function setRecoveryScope(drawingId: string | null): void {
recoveryScope = drawingId;
lastBackedUp = null;
suspended = true;
clearTimeout(debounceTimer);
// 적재가 부르는 상태 갱신이 모두 지나간 뒤 다시 연다.
setTimeout(() => {
suspended = false;
}, AUTOSAVE_DEBOUNCE_MS);
}
function readRecovery(): RecoveryFile | null {
try {
const raw = localStorage.getItem(RECOVERY_KEY);
const raw = localStorage.getItem(recoveryKey());
return raw ? (JSON.parse(raw) as RecoveryFile) : null;
} catch {
return null;
@@ -33,13 +68,13 @@ function readRecovery(): RecoveryFile | null {
async function writeBackup(): Promise<void> {
const entities = getEntities();
if (entities === lastBackedUp || entities.length === 0) return;
if (suspended || entities === lastBackedUp || entities.length === 0) return;
try {
const file: RecoveryFile = {
savedAt: Date.now(),
json: await exportEntitiesAndLayersToJsonString(),
};
localStorage.setItem(RECOVERY_KEY, JSON.stringify(file));
localStorage.setItem(recoveryKey(), JSON.stringify(file));
lastBackedUp = entities;
} catch {
// 저장 공간이 없거나 막혀 있으면 조용히 넘어간다 — 편집을 막을 일은 아니다
@@ -48,13 +83,17 @@ async function writeBackup(): Promise<void> {
export function clearRecovery(): void {
try {
localStorage.removeItem(RECOVERY_KEY);
localStorage.removeItem(recoveryKey());
} catch {
// 지우지 못해도 다음 백업이 덮어쓴다
}
}
export async function restoreRecovery(): Promise<void> {
if (isDrawingReadOnly()) {
toast.info('확정한 도면입니다. 되살리려면 [수정]을 먼저 누르세요.');
return;
}
const file = readRecovery();
if (!file) {
toast.info('복구할 백업이 없습니다.');
@@ -2,7 +2,16 @@
* 화면 검증용 상태 조회 창구 — 브라우저 콘솔·자동화 스크립트가
* window.__aisloCad로 현재 객체·도면층·선택을 읽는다. 앱 동작에는 관여하지 않는다.
*/
import { getEntities, getLayers, getSelectedEntityIds, getSnapPoint } from '../state';
import {
getDesignMeta,
getEntities,
getHighlightedEntityIds,
getLayers,
getSelectedEntityIds,
getSnapPoint,
isDrawingDirty,
isDrawingReadOnly,
} from '../state';
import { isEntityHidden } from './visibility';
export function registerCadDebugHook(): void {
@@ -30,5 +39,11 @@ export function registerCadDebugHook(): void {
selection: () => getSelectedEntityIds(),
// 마우스 위치의 객체 스냅점 — 잠금 도면층(도각)이 스냅에 끼는지 검증할 때 읽는다.
snap: () => getSnapPoint(),
// 마우스가 스친 객체 — 잠금층(도각·등고선)이 밝아지는지 수치로 본다.
highlighted: () => getHighlightedEntityIds(),
// 확정 도면의 편집 잠금과 미저장 표시 — 화면 검증이 상태를 직접 읽는다.
readOnly: () => isDrawingReadOnly(),
dirty: () => isDrawingDirty(),
meta: () => getDesignMeta(),
};
}
@@ -5,17 +5,23 @@ import type { JsonDrawingFileSerialized } from '../helpers/import-export-handler
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
import {
clearDrawingDirty,
getCanvas,
getDesignMeta,
getEntities,
getLayers,
getScreenCanvasDrawController,
isDrawingDirty,
isDrawingReadOnly,
resetUndoBaseline,
setActiveLayerId,
setDesignMeta,
setEntities,
setLayers,
} from '../state.ts';
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
@@ -52,19 +58,25 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 수량표 값 편집을 부모에 알린다 (확정 상태 롤백 연동). */
export function notifyDrawingChangedByTable() {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
}
/**
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
* 화면과 저장값이 어긋났다.
*/
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
/**
* 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다.
* CAD 테이블(측점 테이블·수량 산출표) 값 셀 수정 수단 — 잠금 레이어는 제외.
* CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외.
*/
function registerTextDoubleClickEdit() {
const canvas = getCanvas();
if (!canvas) return;
canvas.addEventListener('dblclick', (event: MouseEvent) => {
if (isDrawingReadOnly()) {
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
return;
}
const drawController = getScreenCanvasDrawController();
const bounds = canvas.getBoundingClientRect();
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
@@ -76,18 +88,24 @@ function registerTextDoubleClickEdit() {
);
let closest: TextEntity | null = null;
let closestDistance = Number.MAX_SAFE_INTEGER;
let closestOnQuantityTable = false;
for (const entity of getEntities()) {
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
const distanceInfo = entity.distanceTo(worldPoint);
if (distanceInfo && distanceInfo[0] < closestDistance) {
closestDistance = distanceInfo[0];
closest = entity;
closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID;
}
}
if (!closest) return;
const fontSize = closest.getTextOptions().fontSize;
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
if (closestDistance > tolerance) return;
if (closestOnQuantityTable) {
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
return;
}
const nextLabel = window.prompt('값 수정', closest.getLabel());
if (nextLabel === null || nextLabel === closest.getLabel()) return;
closest.setLabel(nextLabel);
@@ -108,8 +126,9 @@ export function registerAisloDrawingBridge() {
const drawing = JSON.parse(
await exportEntitiesAndLayersToJsonString()
) as JsonDrawingFileSerialized;
// 편집된 수량표 값을 도면과 함께 부모로 돌려준다.
// 수량표는 앞 단계 산출값을 그대로 돌려준다 — B07에서는 고치지 않는다.
const quantityTable = getDesignMeta()?.quantityTable ?? null;
clearDrawingDirty(); // 부모가 받아 저장한다 — 미저장 경고 대상이 아니다
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
} catch (error) {
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
@@ -125,9 +144,17 @@ export function registerAisloDrawingBridge() {
setLayers(drawing.layers);
setActiveLayerId(drawing.layers[0].id);
// 실은 도면이 되돌리기의 바닥 — 첫 Ctrl+Z가 백지로 가지 않게 한다.
// (미저장 표시도 여기서 내려간다 — 방금 서버 도면을 실었으니 고친 것이 없다.)
resetUndoBaseline();
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
// 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중
// 상태를 이어 갈 이유도 없다.
runCommandInput('SELECT');
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
setRecoveryScope(event.data.meta?.drawingId ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
@@ -135,8 +162,10 @@ export function registerAisloDrawingBridge() {
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
}
});
// 부모는 이 통지로 **미저장 여부만** 안다. 확정을 푸는 것은 [수정] 버튼 한 곳뿐이다
// (2026-09-01 사용자 확정) — 예전에는 되돌리기·색 고르기까지 확정을 풀었다.
window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() });
});
registerTextDoubleClickEdit();
+78 -4
View File
@@ -1,11 +1,18 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { DesignMeta } from './App.types';
import type { Entity } from './entities/Entity';
import {
clearDrawingDirty,
getActiveLayerId,
getEntities,
getHighlightedEntityIds,
isDrawingDirty,
isDrawingReadOnly,
resetUndoBaseline,
setActiveLayerId,
setDesignMeta,
setEntities,
setHighlightedEntityIds,
setLayers,
undo,
} from './state';
@@ -17,6 +24,24 @@ const layer = (id: string, isLocked: boolean) => ({
isLocked,
});
/** 노드 환경에는 window가 없다 — 상태 알림을 받는 시늉만 시킨다. */
const stubWindow = () => {
(globalThis as { window?: unknown }).window = { dispatchEvent: () => true };
};
const entityOnLayer = (id: string, layerId: string) => ({ id, layerId }) as unknown as Entity;
const meta = (confirmed: boolean): DesignMeta => ({
kind: 'cross',
drawingId: 'cross_s00020m',
title: '1장',
info: '',
confirmed,
quantityTable: null,
hasPrev: false,
hasNext: false,
});
describe('setActiveLayerId', () => {
it('잠금 도면층(배경)은 현재 도면층이 되지 않고 잠금 아닌 첫 층으로 간다', () => {
setLayers([layer('도각', true), layer('계획선', false)]);
@@ -32,10 +57,7 @@ describe('setActiveLayerId', () => {
});
describe('resetUndoBaseline', () => {
// 상태 변경 알림만 window로 나간다 — 노드 환경이라 받는 시늉만 시킨다.
beforeAll(() => {
(globalThis as { window?: unknown }).window = { dispatchEvent: () => true };
});
beforeAll(stubWindow);
it('기준선을 세운 뒤의 되돌리기는 실은 도면까지만 물러난다', () => {
const loaded = [{ id: 'a' } as unknown as Entity];
@@ -46,3 +68,55 @@ describe('resetUndoBaseline', () => {
expect(getEntities()).toHaveLength(1);
});
});
describe('setHighlightedEntityIds', () => {
it('잠금 도면층(도각·등고선) 객체는 마우스가 가도 밝아지지 않는다', () => {
setLayers([layer('도각', true), layer('계획선', false)]);
setEntities([entityOnLayer('배경', '도각'), entityOnLayer('내선', '계획선')], false);
setHighlightedEntityIds(['배경', '내선']);
expect(getHighlightedEntityIds()).toEqual(['내선']);
});
it('잠금층만 가리키면 아무것도 밝아지지 않는다', () => {
setLayers([layer('도각', true)]);
setEntities([entityOnLayer('배경', '도각')], false);
setHighlightedEntityIds(['배경']);
expect(getHighlightedEntityIds()).toEqual([]);
});
});
describe('미저장 표시(drawingDirty)', () => {
beforeAll(stubWindow);
it('편집하면 서고, 도면을 새로 실으면(기준선 재설정) 내려간다', () => {
setEntities([entityOnLayer('a', 'L')], false);
resetUndoBaseline();
expect(isDrawingDirty()).toBe(false);
setEntities([entityOnLayer('a', 'L'), entityOnLayer('b', 'L')], true);
expect(isDrawingDirty()).toBe(true);
resetUndoBaseline();
expect(isDrawingDirty()).toBe(false);
});
it('되돌리기도 미저장이다 — 저장본과 달라질 수 있다', () => {
setEntities([entityOnLayer('a', 'L')], false);
resetUndoBaseline();
setEntities([entityOnLayer('a', 'L'), entityOnLayer('b', 'L')], true);
clearDrawingDirty();
undo();
expect(isDrawingDirty()).toBe(true);
});
});
describe('isDrawingReadOnly', () => {
beforeAll(stubWindow);
it('확정한 도면은 읽기 전용, 확정을 풀면 편집이 열린다', () => {
setDesignMeta(meta(true));
expect(isDrawingReadOnly()).toBe(true);
setDesignMeta(meta(false));
expect(isDrawingReadOnly()).toBe(false);
setDesignMeta(null);
expect(isDrawingReadOnly()).toBe(false);
});
});
+35 -8
View File
@@ -126,8 +126,11 @@ let lastDrawTimestamp: DOMHighResTimeStamp = 0;
/**
* Active line color (7-char hex so <input type="color"> can consume it directly)
*
* 종이 배경(흰색)에 그리므로 검정이 기본이다. 원본 OpenWebCAD의 흰색을 그대로 두면
* 그은 선도 색 스와치도 흰 바탕에 묻혀 보이지 않는다(2026-09-01 실측).
*/
let activeLineColor = '#ffffff';
let activeLineColor = '#000000';
/**
* Active line width
@@ -145,7 +148,7 @@ let activeLineDash: number[] | undefined = undefined;
let activeTextStyle = {
fontFamily: 'Noto Sans KR',
fontSize: 16,
textColor: '#ffffff',
textColor: '#000000', // 흰 종이 배경 — activeLineColor와 같은 이유로 검정이 기본이다
bold: false,
italic: false,
};
@@ -184,6 +187,13 @@ let snapTrackingEnabled = true;
*/
let designMeta: DesignMeta | null = null;
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
* 없으면 사용자가 그은 선이 경고 없이 사라진다(2026-09-01 실측).
* 도면 적재와 저장 응답에서 내려간다.
*/
let drawingDirty = false;
// getters
export const getCanvas = () => canvas;
export const getActiveToolActor = () => activeToolActor;
@@ -246,6 +256,12 @@ export const getSnapEnabled = () => snapEnabled;
export const getGridEnabled = () => gridEnabled;
export const getSnapTrackingEnabled = () => snapTrackingEnabled;
export const getDesignMeta = (): DesignMeta | null => designMeta;
export const isDrawingDirty = () => drawingDirty;
/**
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
*/
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
@@ -304,10 +320,21 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
entities = newEntities;
bumpSceneVersion();
if (trackInUndoStack) {
drawingDirty = true;
window.dispatchEvent(new CustomEvent(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);
};
@@ -453,12 +480,9 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
triggerReactUpdate(StateVariable.designMeta);
};
/** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */
export const setDesignQuantityTable = (table: Record<string, number | null>) => {
if (!designMeta) return;
designMeta = { ...designMeta, quantityTable: table, confirmed: false };
triggerReactUpdate(StateVariable.designMeta);
};
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은
// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다.
// Computed setters
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
@@ -523,6 +547,7 @@ function updateStates(undoState: UndoState) {
export function resetUndoBaseline() {
undoStack.clear(StateVariable.entities);
undoStack.push({ variable: StateVariable.entities, value: entities });
drawingDirty = false;
}
export function undo() {
@@ -530,6 +555,7 @@ export function undo() {
if (!undoState) return;
updateStates(undoState);
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
}
@@ -538,6 +564,7 @@ export function redo() {
if (!redoState) return;
updateStates(redoState);
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
}