diff --git a/src/composables/lineLabelHandler.spec.ts b/src/composables/lineLabelHandler.spec.ts index f03a1475..e5e2bba7 100644 --- a/src/composables/lineLabelHandler.spec.ts +++ b/src/composables/lineLabelHandler.spec.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest"; import { createShape, getCommonStruct } from "../shapes"; import { LineShape } from "../shapes/line"; import { TextShape } from "../shapes/text"; -import { newLineLabelHandler } from "./lineLabelHandler"; +import { getPatchByUpdateLabelAlign, newLineLabelHandler } from "./lineLabelHandler"; import { createStrokeStyle } from "../utils/strokeStyle"; import { newShapeComposite } from "./shapeComposite"; @@ -80,3 +80,50 @@ describe("newLineLabelHandler", () => { }); }); }); + +describe("getPatchByUpdateLabelAlign", () => { + const line0 = createShape(getCommonStruct, "line", { + id: "line0", + p: { x: 0, y: 0 }, + q: { x: 100, y: 100 }, + }); + const label0 = createShape(getCommonStruct, "text", { + id: "label0", + parentId: "line0", + lineAttached: 0.5, + width: 10, + height: 10, + }); + + test("should return patch object to update the label's aligns and position", () => { + expect(getPatchByUpdateLabelAlign(line0, label0, { x: 48, y: 52 })).toEqual({ + hAlign: "center", + vAlign: "center", + p: { x: 45, y: 45 }, + }); + + const ret1 = getPatchByUpdateLabelAlign(line0, label0, { x: 20, y: 20 }); + expect(ret1.hAlign).toBe("right"); + expect(ret1.vAlign).toBe("bottom"); + expect(ret1.p?.x).toBeCloseTo(35.05, 3); + expect(ret1.p?.y).toBeCloseTo(35.05, 3); + + const ret2 = getPatchByUpdateLabelAlign(line0, label0, { x: 80, y: 80 }); + expect(ret2.hAlign).toBe("left"); + expect(ret2.vAlign).toBe("top"); + expect(ret2.p?.x).toBeCloseTo(54.95, 3); + expect(ret2.p?.y).toBeCloseTo(54.95, 3); + }); + + test("should drop attributes that are same to the originals", () => { + expect( + getPatchByUpdateLabelAlign(line0, { ...label0, hAlign: "center", vAlign: "center" }, { x: 48, y: 52 }), + ).toEqual({ + p: { x: 45, y: 45 }, + }); + expect(getPatchByUpdateLabelAlign(line0, { ...label0, p: { x: 45, y: 45 } }, { x: 48, y: 52 })).toEqual({ + hAlign: "center", + vAlign: "center", + }); + }); +}); diff --git a/src/composables/lineLabelHandler.ts b/src/composables/lineLabelHandler.ts index 769fa333..0bee4bd2 100644 --- a/src/composables/lineLabelHandler.ts +++ b/src/composables/lineLabelHandler.ts @@ -1,3 +1,4 @@ +import { IVec2 } from "okageo"; import { EntityPatchInfo, Shape } from "../models"; import { LineShape, getLinePath, getRelativePointOn, isCurveLine, isLineShape } from "../shapes/line"; import { TextShape, isLineLabelShape, patchPosition } from "../shapes/text"; @@ -78,6 +79,28 @@ function getLabelMargin(line: LineShape): number { return (line.stroke.width ?? 1) / 2 + 6; } +/** + * Returns patch object to update the lable's aligns and position based on the parent line and the given point. + */ +export function getPatchByUpdateLabelAlign(parentLine: LineShape, label: TextShape, targetP: IVec2, scale = 1) { + const origin = getRelativePointOn(parentLine, label.lineAttached ?? 0.5); + const size = 20 * scale; + let patched: Partial = { + hAlign: targetP.x < origin.x - size ? "right" : origin.x + size < targetP.x ? "left" : "center", + vAlign: targetP.y < origin.y - size ? "bottom" : origin.y + size < targetP.y ? "top" : "center", + }; + patched = { ...patched, ...patchPosition({ ...label, ...patched }, origin, getLabelMargin(parentLine)) }; + + if (patched.hAlign === label.hAlign) { + delete patched.hAlign; + } + if (patched.vAlign === label.vAlign) { + delete patched.vAlign; + } + + return patched; +} + export function getLineLabelPatch(srcComposite: ShapeComposite, patchInfo: EntityPatchInfo) { if (!patchInfo.update) return {}; diff --git a/src/composables/states/appCanvas/commandExams.ts b/src/composables/states/appCanvas/commandExams.ts index 0dbffb7e..1c6d0886 100644 --- a/src/composables/states/appCanvas/commandExams.ts +++ b/src/composables/states/appCanvas/commandExams.ts @@ -6,6 +6,9 @@ export const COMMAND_EXAM_SRC = { DISABLE_SNAP: { command: getCtrlOrMetaStr(), title: "Disable snapping" }, DISABLE_LINE_VERTEX_SNAP: { command: getCtrlOrMetaStr(), title: "Disable snapping" }, + LABEL_ALIGN: { command: "Shift", title: "Adjust label align" }, + LABEL_ALIGN_ACTIVATE: { command: "Shift + Drag", title: "Adjust label align" }, + RESIZE_PROPORTIONALLY: { command: "Shift", title: "Proportionally" }, RESIZE_AT_CENTER: { command: getAltOrOptionStr(), title: "Based on center" }, diff --git a/src/composables/states/appCanvas/lines/lineLabelSelectedState.ts b/src/composables/states/appCanvas/lines/lineLabelSelectedState.ts index 94ce56c3..c36b6572 100644 --- a/src/composables/states/appCanvas/lines/lineLabelSelectedState.ts +++ b/src/composables/states/appCanvas/lines/lineLabelSelectedState.ts @@ -23,6 +23,7 @@ import { renderParentLineRelation } from "../../../lineLabelHandler"; import { newRotatingLineLabelState } from "./rotatingLineLabelState"; import { CONTEXT_MENU_ITEM_SRC, handleContextItemEvent } from "../contextMenuItems"; import { findBetterShapeAt } from "../../../shapeComposite"; +import { COMMAND_EXAM_SRC } from "../commandExams"; interface Option { boundingBox?: BoundingBox; @@ -37,7 +38,7 @@ export function newLineLabelSelectedState(option?: Option): AppCanvasState { return { getLabel: () => "LineLabelSelected", onStart: (ctx) => { - ctx.setCommandExams(getCommonCommandExams(ctx)); + ctx.setCommandExams([COMMAND_EXAM_SRC.LABEL_ALIGN_ACTIVATE, ...getCommonCommandExams(ctx)]); const shapeComposite = ctx.getShapeComposite(); const shapeMap = shapeComposite.shapeMap; diff --git a/src/composables/states/appCanvas/lines/movingLineLabelState.ts b/src/composables/states/appCanvas/lines/movingLineLabelState.ts index dcc48d7f..8bb669fd 100644 --- a/src/composables/states/appCanvas/lines/movingLineLabelState.ts +++ b/src/composables/states/appCanvas/lines/movingLineLabelState.ts @@ -1,12 +1,19 @@ import type { AppCanvasState } from "../core"; import { AffineMatrix, IDENTITY_AFFINE, sub } from "okageo"; import { mapReduce, patchPipe } from "../../../../utils/commons"; -import { LineLabelHandler, newLineLabelHandler, renderParentLineRelation } from "../../../lineLabelHandler"; +import { + LineLabelHandler, + getPatchByUpdateLabelAlign, + newLineLabelHandler, + renderParentLineRelation, +} from "../../../lineLabelHandler"; import { resizeShape } from "../../../../shapes"; import { newSelectionHubState } from "../selectionHubState"; import { BoundingBox } from "../../../boundingBox"; import { LineShape } from "../../../../shapes/line"; import { TextShape } from "../../../../shapes/text"; +import { Shape } from "../../../../models"; +import { COMMAND_EXAM_SRC } from "../commandExams"; interface Option { boundingBox: BoundingBox; @@ -24,6 +31,7 @@ export function newMovingLineLabelState(option: Option): AppCanvasState { onStart: (ctx) => { ctx.startDragging(); ctx.setCursor("move"); + ctx.setCommandExams([COMMAND_EXAM_SRC.LABEL_ALIGN]); const id = ctx.getLastSelectedShapeId(); const shapeMap = ctx.getShapeComposite().shapeMap; @@ -39,23 +47,43 @@ export function newMovingLineLabelState(option: Option): AppCanvasState { ctx.stopDragging(); ctx.setTmpShapeMap({}); ctx.setCursor(); + ctx.setCommandExams(); }, handleEvent: (ctx, event) => { switch (event.type) { case "pointermove": { - const d = sub(event.data.current, event.data.start); - const affineSrc: AffineMatrix = [1, 0, 0, 1, d.x, d.y]; - const patched = patchPipe( - [ - (src) => mapReduce(src, (s) => resizeShape(ctx.getShapeStruct, s, affineSrc)), - (_src, patch) => lineLabelHandler.onModified(patch), - ], - { [labelShape.id]: labelShape }, - ); - ctx.setTmpShapeMap(patched.patch); + let patch: { [id: string]: Partial }; + if (event.data.shift) { + // Keep the latest label position. + const lineAttached = + (ctx.getTmpShapeMap()[labelShape.id] as TextShape)?.lineAttached ?? labelShape.lineAttached; + patch = { + [labelShape.id]: { + lineAttached, + ...getPatchByUpdateLabelAlign( + parentLineShape, + { ...labelShape, lineAttached }, + event.data.current, + ctx.getScale(), + ), + }, + }; + } else { + const d = sub(event.data.current, event.data.start); + const affineSrc: AffineMatrix = [1, 0, 0, 1, d.x, d.y]; + patch = patchPipe( + [ + (src) => mapReduce(src, (s) => resizeShape(ctx.getShapeStruct, s, affineSrc)), + (_src, patch) => lineLabelHandler.onModified(patch), + ], + { [labelShape.id]: labelShape }, + ).patch; + } + + ctx.setTmpShapeMap(patch); // Save final transition as current affine - const updated = patched.patch[labelShape.id]; + const updated = patch[labelShape.id]; affine = updated.p ? [1, 0, 0, 1, updated.p.x - labelShape.p.x, updated.p.y - labelShape.p.y] : IDENTITY_AFFINE;