diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 9ded147..ad5d68a 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -31,8 +31,57 @@ const uid = (prefix: string): string => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; // ───────────────────────────────────────────────────────────── -// select (Task A3 – Pilot) +// select (Task A3 – Pilot) + Handle-Drag (Task C3fin interaktiv) // ───────────────────────────────────────────────────────────── +/** Handle-Drag-Sitzung: aktives Element + gegenüberliegende Ecke. */ +let handleDrag: { + el: CADElement; + anchorCorner: Pt; + handleIndex: number; +} | null = null; + +/** Prüft, ob (wx,wy) eine BBox-Ecke des Elements trifft (Toleranz tol). */ +function hitHandle( + el: CADElement, + wx: number, + wy: number, + tol: number, +): { handleIndex: number; anchorCorner: Pt } | null { + const props = el.properties as Record; + let minX: number, minY: number, maxX: number, maxY: number; + if ( + props.x1 !== undefined && props.y1 !== undefined && + props.x2 !== undefined && props.y2 !== undefined + ) { + minX = Math.min(props.x1 as number, props.x2 as number); + minY = Math.min(props.y1 as number, props.y2 as number); + maxX = Math.max(props.x1 as number, props.x2 as number); + maxY = Math.max(props.y1 as number, props.y2 as number); + } else { + minX = el.x - el.width / 2; + minY = el.y - el.height / 2; + maxX = el.x + el.width / 2; + maxY = el.y + el.height / 2; + } + const corners: Array<{ x: number; y: number }> = [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]; + for (let i = 0; i < corners.length; i++) { + if ( + Math.abs(wx - corners[i].x) <= tol && + Math.abs(wy - corners[i].y) <= tol + ) { + // Gegenüberliegende Ecke = Anker (bleibt fix beim Skalieren) + const anchor = corners[(i + 2) % 4]; + return { handleIndex: i, anchorCorner: anchor }; + } + } + return null; +} + export const selectTool: ToolExtensionV2 = { manifest: { id: 'select', @@ -46,10 +95,28 @@ export const selectTool: ToolExtensionV2 = { handlers: { down(e, ctx) { const bridge = (ctx as FullToolContext).selection; + const c = ctx as FullToolContext; if (!bridge) return; const additive = e.shift; const subtractive = e.ctrl; const hit = bridge.hitTest(e.world.x, e.world.y, 5); + + // ── Handle-Drag-Start (Task C3fin interaktiv) ── + // Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke + // eines davon trifft → Handle-Drag starten statt neu zu wählen. + if (!additive && !subtractive && bridge.getIds().length > 0 && c.doc) { + for (const id of bridge.getIds()) { + const el = c.doc.getElement(id); + if (!el) continue; + const h = hitHandle(el, e.world.x, e.world.y, 8); + if (h) { + handleDrag = { el, anchorCorner: h.anchorCorner, handleIndex: h.handleIndex }; + ctx.setStatus(`Handle ${h.handleIndex + 1} — skalieren durch Ziehen`); + return; + } + } + } + if (!hit) { if (!additive && !subtractive) bridge.setIds([]); return; @@ -64,6 +131,101 @@ export const selectTool: ToolExtensionV2 = { bridge.setIds(Array.from(ids)); ctx.setStatus(`Selektion: ${ids.size} Element(e)`); }, + + move(e, ctx) { + // Live-Skalierung während Handle-Drag: + if (!handleDrag) return; + const c = ctx as FullToolContext; + if (!c.doc) return; + const el = c.doc.getElement(handleDrag.el.id); + if (!el) return; + const props = el.properties as Record; + // BBox aus Element abrufen (mit aktuellen Koordinaten): + let minX: number, minY: number, maxX: number, maxY: number; + if ( + props.x1 !== undefined && props.y1 !== undefined && + props.x2 !== undefined && props.y2 !== undefined + ) { + minX = Math.min(props.x1 as number, props.x2 as number); + minY = Math.min(props.y1 as number, props.y2 as number); + maxX = Math.max(props.x1 as number, props.x2 as number); + maxY = Math.max(props.y1 as number, props.y2 as number); + } else { + minX = el.x - el.width / 2; + minY = el.y - el.height / 2; + maxX = el.x + el.width / 2; + maxY = el.y + el.height / 2; + } + // Neue BBox = Anker fix, gezogene Ecke = Maus: + const anchor = handleDrag.anchorCorner; + const newMinX = Math.min(anchor.x, e.world.x); + const newMinY = Math.min(anchor.y, e.world.y); + const newMaxX = Math.max(anchor.x, e.world.x); + const newMaxY = Math.max(anchor.y, e.world.y); + // Preview-Linie von Anker zur Maus (vereinfachtes Feedback): + c.setPreview?.({ + ...el, + x: (newMinX + newMaxX) / 2, + y: (newMinY + newMaxY) / 2, + width: newMaxX - newMinX, + height: newMaxY - newMinY, + } as CADElement); + void minX; void minY; void maxX; void maxY; + }, + + up(e, ctx) { + if (!handleDrag) return; + const c = ctx as FullToolContext; + if (!c.doc) { handleDrag = null; return; } + const el = c.doc.getElement(handleDrag.el.id); + const anchor = handleDrag.anchorCorner; + if (!el) { handleDrag = null; return; } + const props = el.properties as Record; + // Neue BBox berechnen: + const newMinX = Math.min(anchor.x, e.world.x); + const newMinY = Math.min(anchor.y, e.world.y); + const newMaxX = Math.max(anchor.x, e.world.x); + const newMaxY = Math.max(anchor.y, e.world.y); + const newW = newMaxX - newMinX; + const newH = newMaxY - newMinY; + const oldW = el.width || 1; + const oldH = el.height || 1; + const scaleX = oldW !== 0 ? newW / oldW : 1; + const scaleY = oldH !== 0 ? newH / oldH : 1; + // Neues Zentrum: + const newCx = (newMinX + newMaxX) / 2; + const newCy = (newMinY + newMaxY) / 2; + + c.doc.transact(() => { + // Element in neuer BBox skalieren (shallow properties überschrieben): + const newProps = { ...(el.properties as Record) }; + // Für line: Koordinaten skalieren relativ zur neuen BBox + if ( + newProps.x1 !== undefined && newProps.y1 !== undefined && + newProps.x2 !== undefined && newProps.y2 !== undefined + ) { + newProps.x1 = newMinX + ((newProps.x1 as number) - (el.x - el.width / 2)) * scaleX; + newProps.y1 = newMinY + ((newProps.y1 as number) - (el.y - el.height / 2)) * scaleY; + newProps.x2 = newMinX + ((newProps.x2 as number) - (el.x - el.width / 2)) * scaleX; + newProps.y2 = newMinY + ((newProps.y2 as number) - (el.y - el.height / 2)) * scaleY; + } + c.doc!.updateElement(el.id, { + x: newCx, + y: newCy, + width: newW, + height: newH, + properties: newProps, + }); + }); + ctx.setStatus(`Skaliert auf ${newW.toFixed(0)}×${newH.toFixed(0)}`); + handleDrag = null; + ctx.setPreview?.(null); + }, + + cancel(ctx) { + handleDrag = null; + ctx.setPreview?.(null); + }, }, }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index 60fc74c..f5e3ff8 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -860,3 +860,65 @@ describe('D-Ext: array-polar tool', () => { expect(doc.getAllElements().length).toBe(1); }); }); + +describe('C3fin interaktiv: handle-drag on select tool', () => { + function makeRect(id: string, x: number, y: number, w: number, h: number): CADElement { + return { + id, type: 'rect', layerId: 'l', + x: x + w / 2, y: y + h / 2, + width: w, height: h, + properties: { fill: '#abc' }, + } as unknown as CADElement; + } + + it('down auf Handle startet Drag, move skaliert, up committet', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + // Rect: (0,0) bis (50,50) + doc.addElement(makeRect('r1', 0, 0, 50, 50)); + let selIds: string[] = ['r1']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const select = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'select')!; + + // down auf Handle der oberen linken Ecke (0,0): + select.handlers.down!(pe(0, 0), ctx); + // move zu neuem Punkt (-20, -20): + select.handlers.move!(pe(-20, -20), ctx); + // up → Commit (Skalierung auf neue BBox von (-20,-20) bis (50,50)): + select.handlers.up!(pe(-20, -20), ctx); + + const updated = doc.getElement('r1')!; + // Neue BBox: 70×70 statt 50×50: + expect(updated.width).toBe(70); + expect(updated.height).toBe(70); + expect(doc.undo()).toBe(true); // Skalierung ist undo-bar + expect(doc.getElement('r1')!.width).toBe(50); + }); + + it('down auf NICHT-Handle startet normale Selektion (kein Drag)', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addElement(makeRect('r2', 0, 0, 50, 50)); + doc.addElement(makeRect('r3', 200, 200, 30, 30)); + let selIds: string[] = ['r2']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + // Klick bei (210,210) trifft r3: + hitTest: () => doc.getElement('r3')!, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const select = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'select')!; + + // Klick weit weg von Handles von r2 (r2-Ecken sind 0/50): + select.handlers.down!(pe(210, 210), ctx); + + // Normal-Selektion: r3 ist jetzt alleine selektiert (kein Drag gestartet): + expect(selIds).toEqual(['r3']); + }); +});