diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 1091d467..18b788aa 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -520,6 +520,34 @@ body > canvas[data-id="canvas"] { background: var(--cad-chrome-raised); box-shadow: var(--shadow-lg); } +/* 오토캐드 WEBCLIP 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */ +.cad-webclip-hint { + position: fixed; + z-index: 2; + top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px); + right: 14px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + max-width: 360px; + padding: 2px 4px; + border: 0; + background: transparent; + color: var(--cad-text-muted); + font-size: 11px; + text-align: right; + opacity: 0.55; + cursor: pointer; +} +.cad-webclip-hint:hover { + opacity: 1; +} +.cad-webclip-hint__install { + color: var(--cad-text-dim); + font-size: 10px; +} + .cad-view-controls button { width: 34px; height: 32px; diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index f197d58e..921b682d 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -16,6 +16,7 @@ import { isRibbonCollapsed } from './ui-state'; import { getActiveToolActor } from '../state'; import type { Tool } from '../tools'; import { ViewControls } from './ViewControls'; +import { WebclipHint } from './WebclipHint'; export const Toolbar: FC = () => { useCadRefresh(); @@ -56,6 +57,7 @@ export const Toolbar: FC = () => { onToggleCollapsed={() => setPanelCollapsed((value) => !value)} /> + {commandLineVisible && } diff --git a/B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx b/B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx new file mode 100644 index 00000000..1f561823 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx @@ -0,0 +1,23 @@ +/** 화면 오른쪽 위 흐린 안내 — 오토캐드 WEBCLIP → 여기서 Ctrl+V · 누르면 설치 한 줄 (B07 · M02 양식 둘 다) */ +import { type FC, useState } from 'react'; +import { isDrawingReadOnly } from '../state'; + +export const WebclipHint: FC = () => { + const [open, setOpen] = useState(false); + if (isDrawingReadOnly()) return null; + return ( + + ); +}; diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index a3166cc1..710ddf7e 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -415,11 +415,13 @@ export class InputController { // Tab => move keyboard focus return; } + if (evt.ctrlKey && evt.code === 'KeyV') { + // 막지 않음 — 브라우저가 `paste` 를 쏘고 `integration/dxf-clip-paste.ts` 가 받음(한글 자판도 code 로) + return; + } evt.preventDefault(); evt.stopPropagation(); - if (evt.ctrlKey && evt.key === 'v') { - // User wants to paste the clipboard - } else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') { + if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') { // User wants to undo the last action this.handleUndo(evt); } else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') { diff --git a/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts b/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts new file mode 100644 index 00000000..e2f96e8f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts @@ -0,0 +1,65 @@ +/** + * Ctrl+V — 시스템 클립보드 글이 DXF(오토캐드 WEBCLIP)면 서버에서 도형으로 바꿔 현재 도면에 + * 원좌표 그대로 붙이고 고른 상태로 둔다 · DXF 가 아니면 앱 안 클립보드 붙여넣기(PASTECLIP). + * 키 처리(`input-controller.ts`)는 Ctrl+V 의 기본 동작을 막지 않아 브라우저가 `paste` 를 쏜다 — + * `paste` 이벤트의 글은 권한 없이 읽힘(iframe 안에서도). + */ +import { toast } from 'react-toastify'; +import { runCommandInput } from '../commands/run-command'; +import { hasClipboardContent } from '../helpers/cad-clipboard'; +import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json'; +import { + addEntities, + getLayers, + isDrawingReadOnly, + setLayers, + setSelectedEntityIds, +} from '../state'; +import type { JsonEntity } from '../entities/Entity'; +import { type ClipLayer, isDxfText, prepareClip } from './dxf-clip'; + +const DXF_CLIP_URL = '/api/projects/dxf-clip'; + +export async function pasteDxfText(text: string): Promise { + const response = await fetch(DXF_CLIP_URL, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + const payload = (await response.json().catch(() => ({}))) as { + entities?: JsonEntity[]; + layers?: ClipLayer[]; + message?: string; + }; + if (!response.ok || !payload.entities) { + throw new Error(payload.message ?? `HTTP ${response.status}`); + } + const { entities, newLayers } = prepareClip(payload.entities, payload.layers ?? [], getLayers()); + const drawing = await getEntitiesAndLayersFromJsonObject({ entities, layers: getLayers() }); + if (newLayers.length) setLayers([...getLayers(), ...newLayers]); + addEntities(drawing.entities, true); + setSelectedEntityIds(drawing.entities.map((entity) => entity.id)); + return drawing.entities.length; +} + +export function registerDxfClipPaste(): void { + document.addEventListener('paste', (event: ClipboardEvent) => { + if ((event.target as HTMLElement | null)?.closest?.('input, textarea, select')) return; + const text = event.clipboardData?.getData('text/plain') ?? ''; + event.preventDefault(); + if (!isDxfText(text)) { + if (hasClipboardContent()) runCommandInput('PASTECLIP'); + return; + } + if (isDrawingReadOnly()) { + toast.info('볼 수만 있는 도면입니다 — 붙여넣을 수 없습니다.'); + return; + } + pasteDxfText(text) + .then((count) => toast.success(`오토캐드 도형 ${count}개를 붙여넣었습니다.`)) + .catch((error: unknown) => + toast.error(error instanceof Error ? error.message : 'DXF 를 붙여넣지 못했습니다.') + ); + }); +} diff --git a/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts b/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts new file mode 100644 index 00000000..8c94336a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts @@ -0,0 +1,49 @@ +/** + * 오토캐드 → 웹 CAD 붙여넣기(WEBCLIP) — 순수 부분. + * 오토캐드 쪽 `resources/tools/autocad/aislo_webclip.lsp` 가 고른 개체를 DXF 글로 클립보드에 넣고, + * 여기서 그 글을 알아보고 서버(`/api/projects/dxf-clip`)가 돌려준 JSON 을 현재 도면에 맞춘다. + * DOM · 상태를 안 만지는 것만 둔다(시험이 바로 부름) — 잇기는 `dxf-clip-paste.ts`. + */ +import type { Layer } from '../App.types.ts'; +import type { JsonEntity } from '../entities/Entity'; + +/** DXF 글인가 — 그룹 코드 0 다음 줄이 SECTION 으로 시작(앞 공백 · CRLF 허용). */ +export function isDxfText(text: string): boolean { + return /^\s*0\s*\r?\n\s*SECTION\b/.test(text); +} + +export interface ClipLayer { + name: string; + color: string | null; +} + +/** + * 서버 엔티티(layerId = DXF 도면층 이름)를 현재 도면에 붙일 모양으로 — + * 같은 이름 도면층이 있으면 그 층 · 없으면 새 층(DXF 층 색) · id 는 모두 새로(두 번 붙여도 안 겹침). + */ +export function prepareClip( + entities: JsonEntity[], + clipLayers: ClipLayer[], + existing: Layer[] +): { entities: JsonEntity[]; newLayers: Layer[] } { + const idByName = new Map(existing.map((layer) => [layer.name, layer.id])); + const newLayers: Layer[] = []; + const colorByName = new Map(clipLayers.map((layer) => [layer.name, layer.color])); + const layerIdOf = (name: string): string => { + let id = idByName.get(name); + if (!id) { + id = crypto.randomUUID(); + idByName.set(name, id); + const color = colorByName.get(name); + newLayers.push({ id, name, isVisible: true, isLocked: false, ...(color ? { color } : {}) }); + } + return id; + }; + const renew = (entity: JsonEntity): JsonEntity => ({ + ...entity, + id: crypto.randomUUID(), + layerId: layerIdOf(String(entity.layerId ?? '0')), + ...(entity.children ? { children: entity.children.map(renew) } : {}), + }); + return { entities: entities.map(renew), newLayers }; +} diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 19cd7387..bc8f2dab 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -17,6 +17,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts'; +import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts'; import { getActiveToolActor, getCanvas, @@ -152,6 +153,7 @@ function initApplication() { setLayers(layers); setActiveLayerId(layers[0].id); registerAisloDrawingBridge(); + registerDxfClipPaste(); registerCadDebugHook(); registerAutoSave(); const screenCanvasDrawController = new ScreenCanvasDrawController(context); diff --git a/resources/tester/cad/dxf-clip.test.ts b/resources/tester/cad/dxf-clip.test.ts new file mode 100644 index 00000000..d8959522 --- /dev/null +++ b/resources/tester/cad/dxf-clip.test.ts @@ -0,0 +1,51 @@ +/** + * 오토캐드 WEBCLIP 붙여넣기 순수 부분(`integration/dxf-clip.ts`) 검증. + * 실행: node --experimental-strip-types resources/tester/cad/dxf-clip.test.ts + * (타입만 가져오는 모듈이라 vitest · jsdom 없이 바로 돎) + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + isDxfText, + prepareClip, +} from '../../../B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts'; + +const sample = readFileSync('resources/tester/fixtures/b07_webclip_sample.dxf', 'utf-8'); +assert.equal(isDxfText(sample), true, '견본 DXF 알아봄'); +assert.equal(isDxfText(sample.replace(/\n/g, '\r\n')), true, 'CRLF DXF 도 알아봄'); +assert.equal(isDxfText('0\nSECTION\n'), true, '앞 공백 없는 DXF'); +assert.equal(isDxfText('가나다'), false, '보통 글은 아님'); +assert.equal(isDxfText('0\nSECTIONS'), false, 'SECTION 뒤 글자 붙은 것은 아님'); + +const existing = [{ id: 'L-old', name: '구조물', isVisible: true, isLocked: false }]; +const line = (id: string, layer: string) => ({ + id, + type: 'Line', + lineColor: '#fff', + lineWidth: 1, + layerId: layer, + shapeData: { startPoint: { x: 0, y: 0 }, endPoint: { x: 1, y: 0 } }, +}); +const serverEntities = [ + line('same', '구조물'), + { + ...line('same', '치수글'), + type: 'PolyLine', + shapeData: null, + children: [line('same', '치수글')], + }, +]; +// biome-ignore lint: 시험 — 서버 JSON 모양 그대로 +const out = prepareClip(serverEntities as any, [{ name: '치수글', color: '#00ff00' }], existing); +assert.equal(out.entities[0].layerId, 'L-old', '같은 이름 층은 기존 층으로'); +assert.equal(out.newLayers.length, 1, '없는 층만 새로'); +assert.equal(out.newLayers[0].name, '치수글'); +assert.equal(out.newLayers[0].color, '#00ff00', '새 층은 DXF 층 색'); +assert.equal(out.entities[1].layerId, out.newLayers[0].id, '새 층에 잇기'); +assert.equal(out.entities[1].children?.[0].layerId, out.newLayers[0].id, '폴리선 속 선도 같은 층'); +const ids = [out.entities[0].id, out.entities[1].id, out.entities[1].children?.[0].id]; +assert.equal(new Set(ids).size, 3, 'id 는 모두 새로(서로 안 겹침)'); +assert.ok(!ids.includes('same'), '서버 id 를 그대로 안 씀'); +assert.deepEqual(out.entities[0].shapeData, serverEntities[0].shapeData, '좌표는 원좌표 그대로'); + +console.log('dxf-clip 시험 통과');