feat(B07 CAD): 표 놓기 도구 · 표 메시지 place-table · update-table (41-2)
- 새 tools/annotate/place-table-tool.ts — PLACETABLE: 부모가 보낸 표를 커서에 붙여 미리보기 · 누르면 그 자리(왼쪽 위) · Esc 그만 · 놓기 = 되돌리기 한 걸음
- 새 integration/aislo-table-bridge.ts — place-table{entity, link} = style.연결 단 표로 놓기 시작 · update-table{link, cells} = 연결 같은 표 칸만 갈기(자리 · 크기 그대로) → table-updated{link, count} · 확정 · 보기 전용이면 안 함
- TableEntity style.연결 · replaceCells · bridge 갈래 한 줄 · 명령 PLACETABLE
- 시험 aislo-table-bridge.test.ts(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
revCloudToolStateMachine,
|
||||
updateDimensions,
|
||||
} from '../tools/annotate/leader-table-tools';
|
||||
import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool';
|
||||
import {
|
||||
tableColumnToolStateMachine,
|
||||
tableEditToolStateMachine,
|
||||
@@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [
|
||||
tool: Tool.TABLEUNMERGE,
|
||||
machine: tableUnmergeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PLACETABLE',
|
||||
label: '표 놓기',
|
||||
glyph: '▦',
|
||||
hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다',
|
||||
tool: Tool.PLACETABLE,
|
||||
machine: placeTableToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKUP_COMMANDS: CadCommand[] = [
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface TableStyleOptions {
|
||||
textColor: string;
|
||||
/** 문자를 칸 좌우 끝에서 띄우는 거리 */
|
||||
padding: number;
|
||||
/** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */
|
||||
연결?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE_STYLE: TableStyleOptions = {
|
||||
@@ -97,6 +99,11 @@ export class TableEntity implements Entity {
|
||||
}
|
||||
|
||||
// ── 쓰기 (표 편집 명령이 쓴다)
|
||||
/** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */
|
||||
public replaceCells(cells: TableCells): void {
|
||||
this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length);
|
||||
}
|
||||
|
||||
public setCell(row: number, column: number, patch: Partial<TableCell>): void {
|
||||
const current = this.cells[row]?.[column];
|
||||
if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다
|
||||
|
||||
@@ -33,6 +33,7 @@ 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';
|
||||
import { handleTableMessage } from './aislo-table-bridge.ts';
|
||||
|
||||
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
|
||||
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
|
||||
@@ -190,6 +191,7 @@ function applyFramePreview(): void {
|
||||
export function registerAisloDrawingBridge() {
|
||||
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장)
|
||||
const message = event.data as Partial<DrawingSaveRequestMessage>;
|
||||
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
|
||||
import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts';
|
||||
import {
|
||||
AISLO_PLACE_TABLE_MESSAGE,
|
||||
AISLO_TABLE_UPDATED_MESSAGE,
|
||||
AISLO_UPDATE_TABLE_MESSAGE,
|
||||
handleTableMessage,
|
||||
updateLinkedTables,
|
||||
} from './aislo-table-bridge.ts';
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run }));
|
||||
|
||||
const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' };
|
||||
|
||||
function table(link?: string): TableEntity {
|
||||
return new TableEntity(
|
||||
layer.id,
|
||||
new Point(10, 20),
|
||||
[30, 15],
|
||||
[5, 5],
|
||||
[
|
||||
[{ text: '항목', colSpan: 2, key: 'S1!A1' }, null],
|
||||
[
|
||||
{ text: '면적', key: 'S1!A2' },
|
||||
{ text: '12.5', align: 'right', key: 'S1!B2' },
|
||||
],
|
||||
],
|
||||
{ fontSize: 2.5, ...(link ? { 연결: link } : {}) }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLayers([layer]);
|
||||
setEntities([]);
|
||||
setPendingTable(null);
|
||||
run.mockClear();
|
||||
});
|
||||
|
||||
describe('표 놓기(PLACETABLE)', () => {
|
||||
it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => {
|
||||
const placed = tableAt(table('T1'), new Point(100, 200), layer.id);
|
||||
expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]);
|
||||
expect(placed.getColumnWidths()).toEqual([30, 15]);
|
||||
expect(placed.getCell(1, 1)?.text).toBe('12.5');
|
||||
expect(placed.getStyle().연결).toBe('T1');
|
||||
});
|
||||
|
||||
it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => {
|
||||
const notify = vi.fn();
|
||||
const handled = await handleTableMessage(
|
||||
{ type: AISLO_PLACE_TABLE_MESSAGE, entity: await table().toJson(), link: 'T7' },
|
||||
notify
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(run).toHaveBeenCalledWith('PLACETABLE');
|
||||
expect(getPendingTable()?.getStyle().연결).toBe('T7');
|
||||
expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => {
|
||||
const linked = table('T1');
|
||||
expect(linked.clone().getStyle().연결).toBe('T1');
|
||||
const json = await linked.toJson();
|
||||
if (!json) throw new Error('표 JSON 없음');
|
||||
const back = await TableEntity.fromJson(json);
|
||||
expect(back.getStyle().연결).toBe('T1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('표 칸 갈기(update-table)', () => {
|
||||
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
|
||||
const linked = table('T1');
|
||||
const other = table('T2');
|
||||
setEntities([linked, other]);
|
||||
const count = updateLinkedTables('T1', [
|
||||
[{ text: '항목(새)', colSpan: 2 }, null],
|
||||
[{ text: '면적' }, { text: '99.0', align: 'right' }],
|
||||
]);
|
||||
expect(count).toBe(1);
|
||||
expect(linked.getCell(1, 1)?.text).toBe('99.0');
|
||||
expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로
|
||||
expect(other.getCell(1, 1)?.text).toBe('12.5');
|
||||
expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]);
|
||||
expect(isDrawingDirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => {
|
||||
const linked = table('T1');
|
||||
setEntities([linked]);
|
||||
updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]);
|
||||
expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([
|
||||
['a', 'b'],
|
||||
['', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => {
|
||||
setEntities([table('T1')]);
|
||||
const before = getEntities();
|
||||
const notify = vi.fn();
|
||||
await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify);
|
||||
expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 });
|
||||
expect(getEntities()).toBe(before);
|
||||
});
|
||||
|
||||
it('표 메시지가 아니면 false', async () => {
|
||||
expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`.
|
||||
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
|
||||
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
|
||||
* → 부모에 table-updated{link, count}
|
||||
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import type { JsonEntity } from '../entities/Entity.ts';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import type { TableCells } from '../helpers/table-geometry.ts';
|
||||
import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts';
|
||||
import { setPendingTable } from '../tools/annotate/place-table-tool.ts';
|
||||
|
||||
export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table';
|
||||
export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table';
|
||||
export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated';
|
||||
|
||||
/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */
|
||||
interface TableMessage {
|
||||
type?: string;
|
||||
entity?: JsonEntity;
|
||||
link?: string;
|
||||
cells?: TableCells;
|
||||
}
|
||||
|
||||
/** 이 표가 그 연결의 것인가 */
|
||||
export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity =>
|
||||
entity instanceof TableEntity && entity.getStyle().연결 === link;
|
||||
|
||||
/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */
|
||||
export function updateLinkedTables(link: string, cells: TableCells): number {
|
||||
const tables = getEntities().filter((entity) => isLinkedTable(entity, link));
|
||||
for (const table of tables) table.replaceCells(cells);
|
||||
if (tables.length) setEntities([...getEntities()], true);
|
||||
return tables.length;
|
||||
}
|
||||
|
||||
/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */
|
||||
export async function startPlaceTable(entity: JsonEntity, link: string): Promise<void> {
|
||||
const table = await TableEntity.fromJson(entity as Parameters<typeof TableEntity.fromJson>[0]);
|
||||
const style = table.getStyle();
|
||||
setPendingTable(
|
||||
new TableEntity(
|
||||
table.layerId,
|
||||
table.getOrigin(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells(),
|
||||
{ ...style, 연결: link }
|
||||
)
|
||||
);
|
||||
runCommandInput('PLACETABLE');
|
||||
}
|
||||
|
||||
const blocked = (): boolean => {
|
||||
if (!isDrawingReadOnly() && !isViewOnly()) return false;
|
||||
toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.');
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */
|
||||
export async function handleTableMessage(
|
||||
data: unknown,
|
||||
notify: (type: string, payload: Record<string, unknown>) => void
|
||||
): Promise<boolean> {
|
||||
const message = data as TableMessage | null;
|
||||
if (!message || typeof message !== 'object') return false;
|
||||
if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) {
|
||||
if (!blocked()) await startPlaceTable(message.entity, message.link);
|
||||
return true;
|
||||
}
|
||||
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
|
||||
const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells);
|
||||
notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export enum Tool {
|
||||
MLEADERSTYLE = 'MLEADERSTYLE',
|
||||
MLEADERALIGN = 'MLEADERALIGN',
|
||||
TABLE = 'TABLE',
|
||||
PLACETABLE = 'PLACETABLE',
|
||||
TABLESTYLE = 'TABLESTYLE',
|
||||
TABLEEDIT = 'TABLEEDIT',
|
||||
TABLEROW = 'TABLEROW',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게
|
||||
* 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음.
|
||||
* 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { TableEntity } from '../../entities/TableEntity';
|
||||
import { addEntities, getActiveLayerId } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
let pending: TableEntity | null = null;
|
||||
|
||||
/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */
|
||||
export function setPendingTable(table: TableEntity | null): void {
|
||||
pending = table;
|
||||
}
|
||||
|
||||
export function getPendingTable(): TableEntity | null {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */
|
||||
export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity {
|
||||
const placed = new TableEntity(
|
||||
layerId,
|
||||
at.clone(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))),
|
||||
table.getStyle()
|
||||
);
|
||||
placed.lineColor = table.lineColor;
|
||||
placed.lineWidth = table.lineWidth;
|
||||
return placed;
|
||||
}
|
||||
|
||||
export const placeTableToolStateMachine = createSequenceTool({
|
||||
tool: Tool.PLACETABLE,
|
||||
steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }],
|
||||
helpers: true,
|
||||
restart: false,
|
||||
preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []),
|
||||
commit: (input) => {
|
||||
if (!pending) return;
|
||||
addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true);
|
||||
pending = null;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user