diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index bfa0ba31e..2e4f3cb41 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -10,7 +10,6 @@ export const COMPONENT_COLOR = { r: 0.592, g: 0.278, b: 1, a: 1 } satisfies Colo export const SNAP_COLOR = { r: 1.0, g: 0.0, b: 0.56, a: 1 } satisfies Color export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color -export const ROTATION_HANDLE_OFFSET = 20 export const SNAP_THRESHOLD = 5 export const RULER_SIZE = 20 @@ -38,8 +37,6 @@ export const DEFAULT_STROKE_MITER_LIMIT = 4 export const LABEL_FONT_SIZE = 11 export const SIZE_FONT_SIZE = 10 -export const ROTATION_HANDLE_RADIUS = 4 - export const HANDLE_HALF_SIZE = 3 export const LABEL_OFFSET_Y = 8 diff --git a/packages/core/src/copy.ts b/packages/core/src/copy.ts index 4843a7f8a..455e45ed1 100644 --- a/packages/core/src/copy.ts +++ b/packages/core/src/copy.ts @@ -39,7 +39,10 @@ export function copyEffect(e: Effect): Effect { export function copyStyleRun(r: StyleRun): StyleRun { return { ...r, - style: { ...r.style }, + style: { + ...r.style, + fills: r.style.fills ? r.style.fills.map(copyFill) : undefined, + }, } } diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index 3993fc417..3c21cfd4e 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -237,6 +237,9 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { override.lineHeight = { value: style.lineHeight, units: 'PIXELS' } } if (style.textDecoration) override.textDecoration = style.textDecoration + if (style.fills && style.fills.length > 0) { + override.fillPaints = style.fills.map(fillToKiwiPaint) + } overrideTable.push(override as unknown as NodeChange) } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 49d581ecb..46004bf4c 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -338,6 +338,10 @@ function convertStyleOverride( } const deco = override.textDecoration if (deco) style.textDecoration = mapTextDecoration(deco) + if (override.fillPaints) { + const fills = convertFills(override.fillPaints) + if (fills.length > 0) style.fills = fills + } return style } diff --git a/packages/core/src/renderer/overlays.ts b/packages/core/src/renderer/overlays.ts index d8b169f00..f41f931d5 100644 --- a/packages/core/src/renderer/overlays.ts +++ b/packages/core/src/renderer/overlays.ts @@ -1,6 +1,4 @@ import { - ROTATION_HANDLE_OFFSET, - ROTATION_HANDLE_RADIUS, HANDLE_HALF_SIZE, LABEL_OFFSET_Y, SIZE_PILL_PADDING_X, @@ -153,16 +151,6 @@ export function drawNodeSelection( r.drawHandle(canvas, x1, my) r.drawHandle(canvas, x2, my) - const rotHandleY = y1 - ROTATION_HANDLE_OFFSET - ROTATION_HANDLE_RADIUS - r.auxStroke.setStrokeWidth(1) - r.auxStroke.setColor(r.selColor()) - r.auxStroke.setPathEffect(null) - canvas.drawLine(mx, y1, mx, rotHandleY, r.auxStroke) - - r.auxFill.setColor(r.ck.WHITE) - canvas.drawCircle(mx, rotHandleY, ROTATION_HANDLE_RADIUS, r.auxFill) - canvas.drawCircle(mx, rotHandleY, ROTATION_HANDLE_RADIUS, r.auxStroke) - canvas.restore() } diff --git a/packages/core/src/renderer/text.ts b/packages/core/src/renderer/text.ts index a602816fd..7cfba92ea 100644 --- a/packages/core/src/renderer/text.ts +++ b/packages/core/src/renderer/text.ts @@ -123,9 +123,18 @@ function addStyledRuns( const runLineHeight = s.lineHeight !== undefined ? s.lineHeight : node.lineHeight const runFontSize = s.fontSize ?? baseFontSize + let runColor = baseColor + if (s.fills) { + const visibleFill = s.fills.find((f) => f.visible && f.type === 'SOLID') + if (visibleFill) { + const c = visibleFill.color + runColor = ck.Color4f(c.r, c.g, c.b, c.a * visibleFill.opacity) + } + } + builder.pushStyle( new ck.TextStyle({ - color: baseColor, + color: runColor, fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)), fontSize: runFontSize, fontStyle: { diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index a396a897b..1d543d592 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -169,6 +169,7 @@ export interface CharacterStyleOverride { fontFamily?: string letterSpacing?: number lineHeight?: number | null + fills?: Fill[] } export interface StyleRun { diff --git a/packages/core/src/svg-export/index.ts b/packages/core/src/svg-export/index.ts index c6c490044..bfce18a32 100644 --- a/packages/core/src/svg-export/index.ts +++ b/packages/core/src/svg-export/index.ts @@ -147,6 +147,12 @@ function styleOverrideToTspanAttrs(style: CharacterStyleOverride): Record f.visible && f.type === 'SOLID') + if (visibleFill) { + attrs.fill = formatColor(visibleFill.color, visibleFill.opacity) + } + } return attrs } diff --git a/packages/core/src/vector.ts b/packages/core/src/vector.ts index d69d6d015..c8688b052 100644 --- a/packages/core/src/vector.ts +++ b/packages/core/src/vector.ts @@ -229,18 +229,33 @@ function addLoopToPath( ): void { if (loop.length === 0) return - // Region loops have pre-oriented segments — always draw forward const firstSeg = segments[loop[0]] - path.moveTo(vertices[firstSeg.start].x, vertices[firstSeg.start].y) + + // Determine the starting vertex — if the loop has multiple segments, + // the first segment's direction is determined by which vertex connects + // to the second segment. + let current: number + if (loop.length === 1) { + current = firstSeg.start + } else { + const secondSeg = segments[loop[1]] + if (firstSeg.end === secondSeg.start || firstSeg.end === secondSeg.end) { + current = firstSeg.start + } else { + current = firstSeg.end + } + } + + path.moveTo(vertices[current].x, vertices[current].y) for (const segIdx of loop) { - addSegmentDirected(path, segments[segIdx], vertices, true) + const seg = segments[segIdx] + const forward = seg.start === current + addSegmentDirected(path, seg, vertices, forward) + current = forward ? seg.end : seg.start } - const lastSeg = segments[loop[loop.length - 1]] - if (lastSeg.end === firstSeg.start) { - path.close() - } + path.close() } function addSegmentDirected( diff --git a/src/assets/rotate-cursor.svg b/src/assets/rotate-cursor.svg new file mode 100644 index 000000000..0c7e32aac --- /dev/null +++ b/src/assets/rotate-cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 9091d5011..bf5ab29fe 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -3,11 +3,10 @@ import { ref, type Ref } from 'vue' import { AUTO_LAYOUT_BREAK_THRESHOLD, + CORNER_ROTATE_ZONE, HANDLE_HIT_RADIUS, - ROTATION_HIT_RADIUS, PEN_CLOSE_THRESHOLD, ROTATION_SNAP_DEGREES, - ROTATION_HIT_OFFSET, DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT } from '@/constants' @@ -198,7 +197,9 @@ function hitTestHandle( return null } -function hitTestRotationHandle( +type CornerPosition = 'nw' | 'ne' | 'se' | 'sw' + +function hitTestCornerRotation( sx: number, sy: number, absX: number, @@ -209,15 +210,59 @@ function hitTestRotationHandle( panX: number, panY: number, rotation = 0 -): boolean { - const { x1, x2, y1, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) +): CornerPosition | null { + const { x1, y1, x2, y2 } = getScreenRect(absX, absY, w, h, zoom, panX, panY) const cx = (x1 + x2) / 2 const cy = (y1 + y2) / 2 const ur = unrotate(sx, sy, cx, cy, rotation) - const mx = (x1 + x2) / 2 - const rotY = y1 - ROTATION_HIT_OFFSET - return Math.abs(ur.sx - mx) < ROTATION_HIT_RADIUS && Math.abs(ur.sy - rotY) < ROTATION_HIT_RADIUS + const corners: Array<{ pos: CornerPosition; x: number; y: number }> = [ + { pos: 'nw', x: x1, y: y1 }, + { pos: 'ne', x: x2, y: y1 }, + { pos: 'se', x: x2, y: y2 }, + { pos: 'sw', x: x1, y: y2 }, + ] + + for (const { pos, x, y } of corners) { + const dx = Math.abs(ur.sx - x) + const dy = Math.abs(ur.sy - y) + if ( + dx <= CORNER_ROTATE_ZONE && + dy <= CORNER_ROTATE_ZONE && + (dx > HANDLE_HIT_RADIUS || dy > HANDLE_HIT_RADIUS) + ) { + return pos + } + } + return null +} + +const CORNER_BASE_ANGLES: Record = { nw: 0, ne: 90, se: 180, sw: 270 } + +import rotateCursorSvg from '@/assets/rotate-cursor.svg?raw' + +const rotationCursorCache = new Map() + +function buildRotationCursor(angleDeg: number): string { + const key = Math.round(angleDeg) % 360 + let cached = rotationCursorCache.get(key) + if (cached) return cached + let svg: string + if (key === 0) { + svg = rotateCursorSvg + } else { + svg = rotateCursorSvg.replace( + '', '') + } + cached = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto` + rotationCursorCache.set(key, cached) + return cached +} + +function cornerRotationCursor(corner: CornerPosition, nodeRotation = 0): string { + return buildRotationCursor(CORNER_BASE_ANGLES[corner] + nodeRotation) } export function useCanvasInput( @@ -292,7 +337,7 @@ export function useCanvasInput( if (!node) return false const abs = store.graph.getAbsolutePosition(id) if ( - !hitTestRotationHandle( + !hitTestCornerRotation( sx, sy, abs.x, @@ -514,36 +559,34 @@ export function useCanvasInput( const { sx, sy, cx, cy } = getCoords(e) let cursor: string | null = null - if (store.state.selectedIds.size === 1) { + for (const id of store.state.selectedIds) { + const node = store.graph.getNode(id) + if (!node) continue + const abs = store.graph.getAbsolutePosition(id) + const handle = hitTestHandle( + sx, + sy, + abs.x, + abs.y, + node.width, + node.height, + store.state.zoom, + store.state.panX, + store.state.panY, + node.rotation + ) + if (handle) { + cursor = HANDLE_CURSORS[handle] + break + } + } + + if (!cursor && store.state.selectedIds.size === 1) { const id = [...store.state.selectedIds][0] const node = store.graph.getNode(id) if (node) { const abs = store.graph.getAbsolutePosition(id) - if ( - hitTestRotationHandle( - sx, - sy, - abs.x, - abs.y, - node.width, - node.height, - store.state.zoom, - store.state.panX, - store.state.panY, - node.rotation - ) - ) { - cursor = 'grab' - } - } - } - - if (!cursor) { - for (const id of store.state.selectedIds) { - const node = store.graph.getNode(id) - if (!node) continue - const abs = store.graph.getAbsolutePosition(id) - const handle = hitTestHandle( + const corner = hitTestCornerRotation( sx, sy, abs.x, @@ -555,12 +598,12 @@ export function useCanvasInput( store.state.panY, node.rotation ) - if (handle) { - cursor = HANDLE_CURSORS[handle] - break + if (corner) { + cursor = cornerRotationCursor(corner, node.rotation) } } } + cursorOverride.value = cursor const hit = diff --git a/src/constants.ts b/src/constants.ts index 407146ded..91f04fe22 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,7 +4,6 @@ export { COMPONENT_COLOR, SNAP_COLOR, CANVAS_BG_COLOR, - ROTATION_HANDLE_OFFSET, SNAP_THRESHOLD, RULER_SIZE, RULER_BG_COLOR, @@ -28,7 +27,6 @@ export { DEFAULT_FONT_SIZE, LABEL_FONT_SIZE, SIZE_FONT_SIZE, - ROTATION_HANDLE_RADIUS, HANDLE_HALF_SIZE, LABEL_OFFSET_Y, SIZE_PILL_PADDING_X, @@ -113,12 +111,11 @@ export const ACTION_TOAST_DURATION = 800 export const DRAG_DEAD_ZONE = 4 export const PEN_CLOSE_THRESHOLD = 8 export const ROTATION_SNAP_DEGREES = 15 -export const ROTATION_HIT_OFFSET = 24 +export const CORNER_ROTATE_ZONE = 16 export const DEFAULT_TEXT_WIDTH = 200 export const DEFAULT_TEXT_HEIGHT = 24 export const AUTO_LAYOUT_BREAK_THRESHOLD = 8 export const HANDLE_HIT_RADIUS = 6 -export const ROTATION_HIT_RADIUS = 8 // Pixels of deltaY for one e-fold of zoom (Math.exp(-deltaY / ZOOM_DIVISOR)) // Trackpad pinch sends small deltas (~2-5px), mouse wheel sends large (~100px).