feat(canvas): enhance rotation controls and cursor handling

- Update rotation cursor SVG for improved design consistency and clarity.
- Refactor rotation control offsets and sizes for better maintainability.
- Implement a patch for `_setCursorFromEvent` to ensure rotation cursors display correctly when hovering over active object controls.
- Integrate rotation cursor handler setup in the fabric canvas initialization.
This commit is contained in:
Kayshen-X 2026-02-20 13:55:20 +08:00
parent afdec6c2d6
commit d5d33e3e91
3 changed files with 55 additions and 8 deletions

View file

@ -1,7 +1,9 @@
import * as fabric from 'fabric'
function rotationCursorSvg(angleDeg: number): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g transform="rotate(${angleDeg} 12 12)"><path d="M12 5a7 7 0 0 1 7 7" fill="none" stroke="%23111" stroke-width="1.5" stroke-linecap="round"/><polyline points="16 4 19 7.5 15 8" fill="none" stroke="%23111" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g></svg>`
// 270° clockwise arc (radius 4) with small arrowhead — Figma-style minimal.
// Uses single quotes so the SVG doesn't break the outer CSS url("...").
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g transform='rotate(${angleDeg} 12 12)' fill='none' stroke='%23333' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M16 12 A4 4 0 1 1 12 8'/><polyline points='10 6.5 12 8 10 9.5'/></g></svg>`
return `url("data:image/svg+xml,${svg}") 12 12, crosshair`
}
@ -12,11 +14,14 @@ const CURSORS = {
bl: rotationCursorSvg(225),
}
const ROTATION_OFFSET = 14
const ROTATION_SIZE = 14
const ROTATION_POSITIONS = [
{ key: 'rtl', x: -0.5, y: -0.5, ox: -10, oy: -10, cursor: CURSORS.tl },
{ key: 'rtr', x: 0.5, y: -0.5, ox: 10, oy: -10, cursor: CURSORS.tr },
{ key: 'rbr', x: 0.5, y: 0.5, ox: 10, oy: 10, cursor: CURSORS.br },
{ key: 'rbl', x: -0.5, y: 0.5, ox: -10, oy: 10, cursor: CURSORS.bl },
{ key: 'rtl', x: -0.5, y: -0.5, ox: -ROTATION_OFFSET, oy: -ROTATION_OFFSET, cursor: CURSORS.tl },
{ key: 'rtr', x: 0.5, y: -0.5, ox: ROTATION_OFFSET, oy: -ROTATION_OFFSET, cursor: CURSORS.tr },
{ key: 'rbr', x: 0.5, y: 0.5, ox: ROTATION_OFFSET, oy: ROTATION_OFFSET, cursor: CURSORS.br },
{ key: 'rbl', x: -0.5, y: 0.5, ox: -ROTATION_OFFSET, oy: ROTATION_OFFSET, cursor: CURSORS.bl },
]
export function applyRotationControls(obj: fabric.FabricObject) {
@ -26,8 +31,8 @@ export function applyRotationControls(obj: fabric.FabricObject) {
y: pos.y,
offsetX: pos.ox,
offsetY: pos.oy,
sizeX: 20,
sizeY: 20,
sizeX: ROTATION_SIZE,
sizeY: ROTATION_SIZE,
actionName: 'rotate',
actionHandler: fabric.controlsUtils.rotationWithSnapping,
cursorStyleHandler: () => pos.cursor,
@ -35,3 +40,43 @@ export function applyRotationControls(obj: fabric.FabricObject) {
})
}
}
/**
* Fabric.js `_setCursorFromEvent` only checks controls on the `target` found
* by `findTarget` (the object directly under the mouse). Rotation controls
* are offset outside the object boundary, so `findTarget` returns null there
* and the rotation cursor never shows.
*
* Fix: patch `_setCursorFromEvent` to also check the active object's controls
* before falling through to the default behavior.
*/
export function setupRotationCursorHandler(canvas: fabric.Canvas) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const c = canvas as any
const original = c._setCursorFromEvent
c._setCursorFromEvent = function (
e: MouseEvent,
target: fabric.FabricObject | undefined,
) {
// Check the active object's rotation controls first
const activeObject = this.getActiveObject()
if (activeObject) {
const pointer = this.getViewportPoint(e)
const found = activeObject.findControl(pointer)
if (found && found.control.actionName === 'rotate') {
this.setCursor(
found.control.cursorStyleHandler(
e,
found.control,
activeObject,
found.coord,
),
)
return
}
}
// No rotation control hit — fall through to default behavior
original.call(this, e, target)
}
}

View file

@ -5,7 +5,7 @@ import type { ToolType } from '@/types/canvas'
// Precise crosshair cursor (thin +)
const CROSSHAIR_CURSOR = (() => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><line x1="12" y1="2" x2="12" y2="10" stroke="%23222" stroke-width="1"/><line x1="12" y1="14" x2="12" y2="22" stroke="%23222" stroke-width="1"/><line x1="2" y1="12" x2="10" y2="12" stroke="%23222" stroke-width="1"/><line x1="14" y1="12" x2="22" y2="12" stroke="%23222" stroke-width="1"/></svg>`
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><line x1='12' y1='2' x2='12' y2='10' stroke='%23222' stroke-width='1'/><line x1='12' y1='14' x2='12' y2='22' stroke='%23222' stroke-width='1'/><line x1='2' y1='12' x2='10' y2='12' stroke='%23222' stroke-width='1'/><line x1='14' y1='12' x2='22' y2='12' stroke='%23222' stroke-width='1'/></svg>`
return `url("data:image/svg+xml,${svg}") 12 12, crosshair`
})()

View file

@ -4,6 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
import { getCanvasBackground, SELECTION_BLUE, MIN_ZOOM, MAX_ZOOM } from './canvas-constants'
import { setupRotationCursorHandler } from './canvas-controls'
const FIT_PADDING = 64
@ -120,6 +121,7 @@ export function useFabricCanvas(
canvas.selectionLineWidth = 1
useCanvasStore.getState().setFabricCanvas(canvas)
setupRotationCursorHandler(canvas)
canvas.requestRenderAll()
// Center viewport on the default frame after a tick (sync needs to run first)