feat(B07 CAD): Ctrl+V 로 오토캐드 DXF 붙여넣기 · 오른쪽 위 WEBCLIP 안내
Ctrl+V 기본 동작을 막지 않아 paste 이벤트로 시스템 클립보드 글을 읽음 — DXF 면 서버에서 도형으로 바꿔 원좌표 그대로 붙이고 고른 상태로 둠(같은 이름 층은 기존 층 · 없는 층은 DXF 층 색으로 새로 · id 는 모두 새로). DXF 가 아니면 앱 안 클립보드 붙여넣기(PASTECLIP). 볼 수만 있는 도면은 안 붙임. 캔버스 오른쪽 위 흐린 안내 「오토캐드에서 WEBCLIP → 여기서 Ctrl+V」 · 누르면 설치 한 줄(보기 전용이면 숨김 · B07 · M02 양식 둘 다). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
<ViewControls />
|
||||
<WebclipHint />
|
||||
<QuickProperties />
|
||||
<BlockLibraryPanel />
|
||||
{commandLineVisible && <CommandLine />}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className="cad-webclip-hint controls"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
title="오토캐드 도형을 이 화면에 붙여넣는 법"
|
||||
>
|
||||
오토캐드에서 WEBCLIP → 여기서 Ctrl+V
|
||||
{open && (
|
||||
<span className="cad-webclip-hint__install">
|
||||
설치: 오토캐드 APPLOAD → aislo_webclip.lsp 불러오기(시작하기 세트에 넣으면 매번 자동)
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<number> {
|
||||
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 를 붙여넣지 못했습니다.')
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 시험 통과');
|
||||
Reference in New Issue
Block a user