feat(B07): 자리표 마우스 크기조절·기본 도각 칸 정렬·도면명 미리보기

- 칸이 있는 글자와 그림에 모서리 그립 추가, 모서리를 끌어 칸 크기를 바꾸는 조작 신설 (다른 도면 요소에도 동일 적용)
- 기본 도각(00_template_A1)의 글자 24개에 표제란 칸을 계산해 부여 — 칸 기준 가로·세로 가운데 정렬
- 도각 편집 시 도면명·도면번호도 보던 도면 값으로 미리보기 제공
- 복제 시 미리보기 값 유지 (그립 편집 후 자리표가 토큰으로 되돌아가던 문제)
- CAD index.html 을 캐시하지 않도록 처리 — 빌드 후에도 옛 화면이 남던 문제 해소

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 15:52:36 +09:00
co-authored by Claude Opus 5
parent b0c2e09b16
commit d0d3f2a311
9 changed files with 1555 additions and 1305 deletions
@@ -42,6 +42,8 @@ interface Options {
restoreDrawing: () => void;
/** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */
onSaved: () => void;
/** 지금 보던 도면의 이름·번호 — 자리표 미리보기에 도면명·도면번호로 보여 준다. */
currentDrawingInfo: () => { label: string; number: string } | null;
}
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
@@ -133,7 +135,12 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
async function enter(): Promise<void> {
try {
const response = await fetchFrameTemplate(options.projectId);
frameFields = response.fields ?? {};
// 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다.
const info = options.currentDrawingInfo();
frameFields = {
...(response.fields ?? {}),
...(info ? { 도면명: info.label, 도면번호: info.number } : {}),
};
editing = true;
button.disabled = true;
banner.hidden = false;
@@ -453,6 +453,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
},
onSaved: () => drawingCache.clear(),
currentDrawingInfo: () =>
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
window.addEventListener("message", (event: MessageEvent<unknown>) => {
@@ -140,6 +140,14 @@ export class ImageEntity implements Entity {
);
}
/** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */
public setBoxFromCorners(a: Point, b: Point): void {
this.polygon = twoPointBoxToPolygon(
new Point(Math.min(a.x, b.x), Math.min(a.y, b.y)),
new Point(Math.max(a.x, b.x), Math.max(a.y, b.y))
);
}
public move(x: number, y: number) {
this.polygon = this.polygon.translate(new Vector(x, y));
}
@@ -110,12 +110,16 @@ export class TextEntity implements Entity {
}
public clone(): TextEntity {
return new TextEntity(
const copy = new TextEntity(
getActiveLayerId(),
this.label,
this.basePoint.clone(),
cloneDeep(this.options)
);
// 보여 주기용 값도 함께 옮긴다 — 안 옮기면 그립을 옮긴 순간 자리표가 다시
// `{{도면명}}` 으로 보인다(2026-09-06 실측).
copy.previewLabel = this.previewLabel;
return copy;
}
public intersectsWithBox(box: Box): boolean {
@@ -152,6 +156,17 @@ export class TextEntity implements Entity {
this.options.boxHeight = Math.max(height, 1);
}
/** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */
public setBoxFromCorners(a: Point, b: Point): void {
this.setBoxSize(Math.abs(b.x - a.x), Math.abs(b.y - a.y));
this.basePoint = new Point((a.x + b.x) / 2, (a.y + b.y) / 2);
}
/** 자리표 칸이 있는가 — 칸이 있으면 basePoint 가 칸 한가운데다. */
public hasBox(): boolean {
return Boolean(this.options.boxWidth && this.options.boxHeight);
}
public getTextOptions(): TextOptions {
return this.options;
}
@@ -2,11 +2,13 @@
* 그립 — 선택한 객체에 붙는 편집점. 집어서 다음 클릭 위치로 옮긴다.
* 형상 필드가 전부 private이라 좌표를 고칠 때는 공개 생성자로 같은 객체를 다시 만들어
* 배열에서 바꿔 끼운다(id는 그대로 둬서 선택·그룹이 유지된다).
* ponytail: 호·해치·이미지·치수는 그립을 만들지 않는다 — 각각 각도·경계·비율·연관 규칙이
* 따로 있어 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다.
* ponytail: 호·해치·치수는 그립을 만들지 않는다 — 각각 각도·경계·연관 규칙이 따로 있어
* 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다.
* 그림과 「칸이 있는 글자」는 네 모서리를 끌어 **칸 크기**를 바꾼다 (2026-09-06 사용자 지시).
*/
import { type Circle, Point, type Polygon, type Segment } from '@flatten-js/core';
import { CircleEntity } from '../entities/CircleEntity';
import { ImageEntity } from '../entities/ImageEntity';
import type { Entity } from '../entities/Entity';
import { LineEntity } from '../entities/LineEntity';
import { PointEntity } from '../entities/PointEntity';
@@ -124,6 +126,20 @@ export function getGrips(entity: Entity): Grip[] {
}
return grips;
}
// 칸이 있는 글자·그림 — 네 모서리로 칸을 늘이고 줄인다. 가운데 그립은 옮기기.
if (
(entity instanceof TextEntity && entity.hasBox()) ||
entity instanceof ImageEntity
) {
const box = entity.getBoundingBox();
return [
{ point: new Point(box.xmin, box.ymin), kind: 'vertex', index: 0 },
{ point: new Point(box.xmax, box.ymin), kind: 'vertex', index: 1 },
{ point: new Point(box.xmax, box.ymax), kind: 'vertex', index: 2 },
{ point: new Point(box.xmin, box.ymax), kind: 'vertex', index: 3 },
{ point: new Point(box.center.x, box.center.y), kind: 'base', index: 0 },
];
}
if (entity instanceof TextEntity || entity instanceof PointEntity) {
const point = entity.getFirstPoint();
return point ? [{ point, kind: 'base', index: 0 }] : [];
@@ -188,6 +204,23 @@ export function applyGrip(entity: Entity, grip: Grip, target: Point): Entity | n
copy.setRowHeight(grip.index, top - target.y);
return copy;
}
// 칸이 있는 글자·그림 — 모서리를 끌면 마주 보는 모서리를 붙박아 칸을 다시 잡는다.
if ((entity instanceof TextEntity && entity.hasBox()) || entity instanceof ImageEntity) {
const box = entity.getBoundingBox();
if (grip.kind === 'base') {
return moveCopy(entity, target.x - box.center.x, target.y - box.center.y);
}
const corners = [
new Point(box.xmin, box.ymin),
new Point(box.xmax, box.ymin),
new Point(box.xmax, box.ymax),
new Point(box.xmin, box.ymax),
];
const opposite = corners[(grip.index + 2) % corners.length];
const copy = inherit(entity, entity.clone()) as TextEntity | ImageEntity;
copy.setBoxFromCorners(target, opposite);
return copy;
}
if (entity instanceof TextEntity || entity instanceof PointEntity) {
const base = entity.getFirstPoint();
if (!base) return null;
@@ -25,6 +25,7 @@ import {
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
import { registerBoxResizeDrag } from './box-resize-drag.ts';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
@@ -207,6 +208,7 @@ export function registerAisloDrawingBridge() {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() });
});
registerTextDoubleClickEdit();
registerBoxResizeDrag();
notifyParent(AISLO_DRAWING_READY_MESSAGE);
}
@@ -0,0 +1,119 @@
import { Point } from '@flatten-js/core';
import { HtmlEvent } from '../App.types.ts';
import { ImageEntity } from '../entities/ImageEntity.ts';
import { TextEntity } from '../entities/TextEntity.ts';
import {
getCanvas,
getEntities,
getScreenCanvasDrawController,
getSelectedEntities,
isDrawingReadOnly,
setEntities,
} from '../state.ts';
/**
* 칸 모서리를 **끌어서** 크기를 바꾼다 (2026-09-06 사용자 지시).
*
* 대상은 「칸이 있는 글자(도각 자리표)」와 「그림」이다. 캐드 본래의 그립은 집었다 놓는
* 방식이라 도각 칸을 맞출 때 손이 많이 갔다 — 끌기는 여기서 따로 받는다.
*
* 그리기 도구와 부딪히지 않게 **모서리를 집었을 때만** 이벤트를 가로챈다(그 밖에는 그대로
* 흘려보낸다). 확정한 도면은 읽기 전용이라 손대지 않는다.
*/
/** 모서리를 집었다고 볼 화면 거리(px). 그립 크기(8px)보다 조금 넉넉하게 잡는다. */
const GRAB_PIXELS = 9;
type Resizable = TextEntity | ImageEntity;
function resizableSelection(): Resizable | null {
const selected = getSelectedEntities();
if (selected.length !== 1) return null;
const entity = selected[0];
if (entity instanceof TextEntity && entity.hasBox()) return entity;
if (entity instanceof ImageEntity) return entity;
return null;
}
/** 마우스 위치(화면) → 도면 좌표. 캐드는 화면 y 를 아래에서 위로 잰다. */
function worldAt(event: MouseEvent): Point | null {
const canvas = getCanvas();
if (!canvas) return null;
const bounds = canvas.getBoundingClientRect();
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
return getScreenCanvasDrawController().targetToWorld(screenPoint);
}
function corners(entity: Resizable): Point[] {
const box = entity.getBoundingBox();
return [
new Point(box.xmin, box.ymin),
new Point(box.xmax, box.ymin),
new Point(box.xmax, box.ymax),
new Point(box.xmin, box.ymax),
];
}
let dragging: { entity: Resizable; opposite: Point } | null = null;
export function registerBoxResizeDrag(): void {
const canvas = getCanvas();
if (!canvas) return;
// 캡처 단계에서 먼저 받는다 — 모서리를 집은 경우에만 선택 도구로 넘어가지 않게 막는다.
window.addEventListener(
'mousedown',
(event: MouseEvent) => {
if (event.button !== 0 || event.target !== canvas || isDrawingReadOnly()) return;
const entity = resizableSelection();
const world = entity ? worldAt(event) : null;
if (!entity || !world) return;
const scale = getScreenCanvasDrawController().getScreenScale();
const grabDistance = GRAB_PIXELS / scale;
const points = corners(entity);
let index = -1;
let best = grabDistance;
points.forEach((corner, seq) => {
const distance = corner.distanceTo(world)[0];
if (distance <= best) {
best = distance;
index = seq;
}
});
if (index < 0) return;
dragging = { entity, opposite: points[(index + 2) % points.length] };
event.stopImmediatePropagation();
event.preventDefault();
},
true
);
window.addEventListener(
'mousemove',
(event: MouseEvent) => {
if (!dragging) return;
const world = worldAt(event);
if (!world) return;
dragging.entity.setBoxFromCorners(world, dragging.opposite);
// 끄는 동안에는 되돌리기 스택에 쌓지 않는다 — 손을 뗄 때 한 번만 쌓는다.
setEntities([...getEntities()], false);
// 자리표 패널의 칸 크기 숫자도 따라 움직이게 알린다.
window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE));
event.stopImmediatePropagation();
},
true
);
window.addEventListener(
'mouseup',
(event: MouseEvent) => {
if (!dragging) return;
dragging = null;
setEntities([...getEntities()], true);
window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE));
event.stopImmediatePropagation();
event.preventDefault();
},
true
);
}
+17 -1
View File
@@ -340,9 +340,25 @@ logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_D
# B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe
B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_DesignDetail" / "openwebcad" / "dist")
class _NoCacheHtmlStatic(StaticFiles):
"""`index.html` 만 캐시하지 않는다 (2026-09-06).
캐드를 새로 빌드해도 브라우저가 옛 `index.html` 을 들고 있어 **옛 화면이 그대로**
남았다(파일 이름에 해시가 붙는 자바스크립트는 새 이름이라 문제가 없다).
"""
def file_response(self, *args, **kwargs): # type: ignore[override]
response = super().file_response(*args, **kwargs)
if str(getattr(response, "path", "")).endswith(".html"):
response.headers["Cache-Control"] = "no-store"
return response
app.mount(
"/b07-cad",
StaticFiles(directory=B07_CAD_DIST_DIR, html=True, check_dir=False),
_NoCacheHtmlStatic(directory=B07_CAD_DIST_DIR, html=True, check_dir=False),
name="b07-cad",
)
logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}")
File diff suppressed because it is too large Load Diff