fix(M02): 도면 칸 CAD 💾 · Ctrl+S 가 페이지 [저장] 을 부름 · 엉터리 도각 파일은 400 · 자리표 패널 접기 단추 (PLAN 10-3)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
This commit is contained in:
2026-09-25 16:56:05 +09:00
co-authored by Claude Sonnet 5
parent 2aa4c80577
commit 1cd52cfdfb
10 changed files with 104 additions and 6 deletions
+13
View File
@@ -966,11 +966,24 @@ body > canvas[data-id="canvas"] {
} }
.cad-frame-tokens__header { .cad-frame-tokens__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
color: var(--cad-text-dim); color: var(--cad-text-dim);
font-size: 11px; font-size: 11px;
line-height: 1.4; line-height: 1.4;
} }
.cad-frame-tokens__fold {
flex: none;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: transparent;
color: var(--cad-text);
cursor: pointer;
}
.cad-frame-tokens__group { .cad-frame-tokens__group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -3,10 +3,10 @@ import { toast } from 'react-toastify';
import { clearRecovery, restoreRecovery } from '../helpers/autosave'; import { clearRecovery, restoreRecovery } from '../helpers/autosave';
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
import { requestDrawingExport } from '../integration/aislo-drawing-bridge'; import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png'; import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg'; import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { redo, undo } from '../state'; import { isHostSave, redo, undo } from '../state';
import type { CadCommand } from './command.types'; import type { CadCommand } from './command.types';
export const FILE_COMMANDS: CadCommand[] = [ export const FILE_COMMANDS: CadCommand[] = [
@@ -17,6 +17,10 @@ export const FILE_COMMANDS: CadCommand[] = [
glyph: '💾', glyph: '💾',
hint: '현재 도면을 브라우저에 저장한다', hint: '현재 도면을 브라우저에 저장한다',
run: () => { run: () => {
if (isHostSave()) {
requestHostSave();
return '양식 저장 요청';
}
void exportEntitiesToLocalStorage().then(() => { void exportEntitiesToLocalStorage().then(() => {
clearRecovery(); clearRecovery();
toast.success('도면을 저장했습니다.'); toast.success('도면을 저장했습니다.');
@@ -113,6 +113,8 @@ function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: n
export const FramePlaceholderPanel: FC = () => { export const FramePlaceholderPanel: FC = () => {
const [visible, setVisible] = useState(isFrameEditMode()); const [visible, setVisible] = useState(isFrameEditMode());
// 접으면 그림 오른쪽 아래를 덮지 않는다 — 머리 줄만 남는다.
const [folded, setFolded] = useState(false);
const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null); const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 }); const [size, setSize] = useState({ width: 0, height: 0 });
@@ -139,8 +141,21 @@ export const FramePlaceholderPanel: FC = () => {
return ( return (
<section className="cad-frame-tokens controls"> <section className="cad-frame-tokens controls">
<header className="cad-frame-tokens__header"> <header className="cad-frame-tokens__header">
자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다 <span>
{folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'}
</span>
<button
type="button"
className="cad-frame-tokens__fold"
aria-expanded={!folded}
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
onClick={() => setFolded(!folded)}
>
{folded ? '▴' : '▾'}
</button>
</header> </header>
{!folded && (
<>
<div className="cad-frame-tokens__group"> <div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">글자</span> <span className="cad-frame-tokens__title">글자</span>
{TEXT_TOKENS.map((token) => ( {TEXT_TOKENS.map((token) => (
@@ -190,6 +205,8 @@ export const FramePlaceholderPanel: FC = () => {
</label> </label>
</div> </div>
)} )}
</>
)}
</section> </section>
); );
}; };
@@ -22,6 +22,7 @@ import {
resolveCommandInput, resolveCommandInput,
} from '../commands/registry.ts'; } from '../commands/registry.ts';
import { runCommand } from '../commands/run-command.ts'; import { runCommand } from '../commands/run-command.ts';
import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts'; import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
import { findClosestEntity } from '../helpers/find-closest-entity.ts'; import { findClosestEntity } from '../helpers/find-closest-entity.ts';
@@ -40,6 +41,7 @@ import {
getSnapEnabled, getSnapEnabled,
getSnapPoint, getSnapPoint,
getSnapPointOnAngleGuide, getSnapPointOnAngleGuide,
isHostSave,
redo, redo,
setActiveToolActor, setActiveToolActor,
setAngleStep, setAngleStep,
@@ -426,6 +428,9 @@ export class InputController {
} else if (evt.ctrlKey && evt.key === 'y') { } else if (evt.ctrlKey && evt.key === 'y') {
// User wants to redo the last action // User wants to redo the last action
this.handleRedo(evt); this.handleRedo(evt);
} else if (evt.ctrlKey && evt.key === 's') {
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
if (isHostSave()) requestHostSave();
} else if (evt.ctrlKey && evt.key === 'a') { } else if (evt.ctrlKey && evt.key === 'a') {
// User wants to select everything // User wants to select everything
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id)); setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
@@ -21,6 +21,7 @@ import {
setEntities, setEntities,
setFrameEditMode, setFrameEditMode,
setHostReadOnly, setHostReadOnly,
setHostSave,
setHostTitle, setHostTitle,
setLayers, setLayers,
} from '../state.ts'; } from '../state.ts';
@@ -38,6 +39,7 @@ export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response'; export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate'; export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file'; export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
interface DrawingLoadMessage { interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE; type: typeof AISLO_DRAWING_LOAD_MESSAGE;
@@ -53,6 +55,8 @@ interface DrawingLoadMessage {
readOnly?: boolean; readOnly?: boolean;
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */ /** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
hostTitle?: string; hostTitle?: string;
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡기는가. */
hostSave?: boolean;
} }
interface DrawingSaveRequestMessage { interface DrawingSaveRequestMessage {
@@ -75,6 +79,11 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction }); notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
} }
/** 저장 요청을 부모 페이지 [저장]에 넘긴다 — 캐드는 양식 파일을 모른다. */
export function requestHostSave() {
notifyParent(AISLO_DRAWING_HOST_SAVE_MESSAGE);
}
/** /**
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시). * 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다. * 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
@@ -197,6 +206,7 @@ export function registerAisloDrawingBridge() {
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true); setHostReadOnly(event.data.readOnly === true);
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계'); setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
setHostSave(event.data.hostSave === true);
if (event.data.frameEdit) applyFramePreview(); if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
+6
View File
@@ -193,6 +193,7 @@ let frameFields: Record<string, string> = {};
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */ /** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
let hostReadOnly = false; let hostReadOnly = false;
let hostTitle = 'B07 상세 설계'; let hostTitle = 'B07 상세 설계';
let hostSave = false;
/** /**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -498,6 +499,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ /** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */ /** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */ /** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
/** 저장(💾 · Ctrl+S)을 부모 페이지 [저장]에 맡기는가 — M02 양식만 켠다. */
export const isHostSave = (): boolean => hostSave;
export const setHostSave = (enabled: boolean) => {
hostSave = enabled;
};
export const getHostTitle = (): string => hostTitle; export const getHostTitle = (): string => hostTitle;
export const setHostTitle = (title: string) => { export const setHostTitle = (title: string) => {
hostTitle = title; hostTitle = title;
@@ -24,7 +24,7 @@ export interface DrawingTemplateDoc extends CadHostDrawing {
} }
export interface DrawingTemplateOptions { export interface DrawingTemplateOptions {
/** 쓰지 않음 — 저장은 페이지 머리 [저장] 한 곳(`getDoc()` 으로 편집본을 가져감 · 판 · 409 흐름). */ /** CAD 안 💾 · Ctrl+S 가 부른다 — 페이지 머리 [저장] 과 같은 일(판 · 409 흐름)을 하게 페이지가 넘긴다. */
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>; onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */ /** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean; readOnly?: boolean;
@@ -167,7 +167,21 @@ export function mountDrawingTemplate(
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"), showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
); );
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" }); // CAD 안 💾 · Ctrl+S — 양식 파일은 페이지 [저장] 만 쓴다. 저장 중 또 눌러도 한 번만 부른다.
let saving = false;
const hostSave = (): void => {
if (readOnly) return void showToast("이 양식은 볼 수만 있습니다.", "info");
if (saving || !options.onSave) return;
saving = true;
getDoc()
.then((next) => options.onSave?.(next))
.catch((error) =>
showToast(error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", "error"),
)
.finally(() => (saving = false));
};
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식", onHostSave: hostSave });
const load = (drawing: DrawingTemplateDoc): void => { const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading(); cad.beginLoading();
cad.load( cad.load(
@@ -175,7 +189,7 @@ export function mountDrawingTemplate(
null, null,
!readOnly, !readOnly,
{}, {},
{ recoveryScope, readOnly, hostTitle: "도면 양식 편집" }, { recoveryScope, readOnly, hostTitle: "도면 양식 편집", hostSave: true },
); );
}; };
@@ -8,6 +8,7 @@ import asyncio
import logging import logging
from uuid import UUID from uuid import UUID
import ezdxf
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, File, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -54,6 +55,8 @@ async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다.""" """외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다."""
try: try:
data = await file.read() data = await file.read()
if not data:
raise ValueError("빈 파일입니다.")
if len(data) > _IMPORT_MAX_BYTES: if len(data) > _IMPORT_MAX_BYTES:
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).") raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data) entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
@@ -67,6 +70,16 @@ async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
) )
except ValueError as exc: except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except (OSError, UnicodeError, ezdxf.DXFError):
# 엉터리 파일 — 읽는 라이브러리가 ValueError 아닌 것으로 던짐(DXF 아님 · 글자 깨짐).
# 던진 글에 임시 폴더 경로가 섞여 있어 그대로 내지 않는다.
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "도각 파일을 읽지 못했습니다 — DXF 가 아니거나 깨진 파일입니다.",
},
)
except Exception: except Exception:
logger.exception("M02 도각 불러오기 실패: %s", file.filename) logger.exception("M02 도각 불러오기 실패: %s", file.filename)
return JSONResponse( return JSONResponse(
@@ -56,3 +56,12 @@ def test_import_dxf():
body = response.json() body = response.json()
assert body["entity_count"] == 1 assert body["entity_count"] == 1
assert body["drawing"]["entities"][0]["type"] == "Line" assert body["drawing"]["entities"][0]["type"] == "Line"
def test_import_garbage_is_400_not_500():
for data in (b"garbage\x00\xff", b"", b"0\nSECTION\n2\nENTITIES\n0\nLINE\n10\nabc\n"):
response = client.post(
"/api/m02/drawing-import", files={"file": ("bad.dxf", data, "application/dxf")}
)
assert response.status_code == 400, (data, response.text)
assert response.json()["message"]
+7
View File
@@ -22,6 +22,7 @@ const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file"; const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save";
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시). /** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */ * CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_MESSAGE = "aislo:b08:toast";
@@ -48,6 +49,8 @@ export interface CadHostOptions {
onNavigate?: (direction: "prev" | "next") => void; onNavigate?: (direction: "prev" | "next") => void;
/** CAD 리본의 DXF·DWG 내보내기 단추. */ /** CAD 리본의 DXF·DWG 내보내기 단추. */
onExport?: (fileFormat: "dxf" | "dwg") => void; onExport?: (fileFormat: "dxf" | "dwg") => void;
/** CAD 안 💾 · Ctrl+S — `hostSave` 로 실은 화면(M02 양식)만 온다. */
onHostSave?: () => void;
} }
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */ /** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
@@ -58,6 +61,8 @@ export interface CadLoadExtra {
readOnly?: boolean; readOnly?: boolean;
/** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */ /** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
hostTitle?: string; hostTitle?: string;
/** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡긴다 — 켜면 `onHostSave` 로 옴. */
hostSave?: boolean;
} }
export interface CadHost<D extends CadHostDrawing, M, Q> { export interface CadHost<D extends CadHostDrawing, M, Q> {
@@ -229,6 +234,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
options.onChanged?.(message.dirty !== false); options.onChanged?.(message.dirty !== false);
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
options.onNavigate?.(message.direction); options.onNavigate?.(message.direction);
} else if (message.type === CAD_HOST_SAVE_MESSAGE) {
options.onHostSave?.();
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) { } else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
options.onExport?.(message.fileFormat); options.onExport?.(message.fileFormat);
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {