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:
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user