task(C3fin-int): interactive handle drag for select tool (scale by corner)
This commit is contained in:
@@ -31,8 +31,57 @@ const uid = (prefix: string): string =>
|
|||||||
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
`${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<string, unknown>;
|
||||||
|
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 = {
|
export const selectTool: ToolExtensionV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -46,10 +95,28 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
handlers: {
|
handlers: {
|
||||||
down(e, ctx) {
|
down(e, ctx) {
|
||||||
const bridge = (ctx as FullToolContext).selection;
|
const bridge = (ctx as FullToolContext).selection;
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
if (!bridge) return;
|
if (!bridge) return;
|
||||||
const additive = e.shift;
|
const additive = e.shift;
|
||||||
const subtractive = e.ctrl;
|
const subtractive = e.ctrl;
|
||||||
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
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 (!hit) {
|
||||||
if (!additive && !subtractive) bridge.setIds([]);
|
if (!additive && !subtractive) bridge.setIds([]);
|
||||||
return;
|
return;
|
||||||
@@ -64,6 +131,101 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
bridge.setIds(Array.from(ids));
|
bridge.setIds(Array.from(ids));
|
||||||
ctx.setStatus(`Selektion: ${ids.size} Element(e)`);
|
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<string, unknown>;
|
||||||
|
// 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<string, unknown>;
|
||||||
|
// 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<string, unknown>) };
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -860,3 +860,65 @@ describe('D-Ext: array-polar tool', () => {
|
|||||||
expect(doc.getAllElements().length).toBe(1);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user