Fix per-character text fill colors and vector region loop direction (#95)

* Fix per-character text fill colors and vector region loop direction

* Replace rotation handle with corner rotation zones

Remove the rotation handle circle/stem above the selection bounding box.
Rotation is now triggered from corner zones outside resize handles,
matching Figma's behavior. Cursor uses native CSS directional arrows
(n-resize, ne-resize, etc.) that rotate with the node, avoiding
cross-platform SVG cursor issues.
This commit is contained in:
Danila Poyarkov 2026-03-12 18:28:04 +03:00 committed by GitHub
parent 198f2d138a
commit b3cbf227e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 135 additions and 66 deletions

View file

@ -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

View file

@ -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,
},
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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: {

View file

@ -169,6 +169,7 @@ export interface CharacterStyleOverride {
fontFamily?: string
letterSpacing?: number
lineHeight?: number | null
fills?: Fill[]
}
export interface StyleRun {

View file

@ -147,6 +147,12 @@ function styleOverrideToTspanAttrs(style: CharacterStyleOverride): Record<string
if (style.letterSpacing) attrs['letter-spacing'] = round(style.letterSpacing)
if (style.textDecoration === 'UNDERLINE') attrs['text-decoration'] = 'underline'
if (style.textDecoration === 'STRIKETHROUGH') attrs['text-decoration'] = 'line-through'
if (style.fills) {
const visibleFill = style.fills.find((f) => f.visible && f.type === 'SOLID')
if (visibleFill) {
attrs.fill = formatColor(visibleFill.color, visibleFill.opacity)
}
}
return attrs
}

View file

@ -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(

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="680 1800 640 620">
<path d="M1113.142,1956.331C1008.608,1982.71 887.611,2049.487 836.035,2213.487L891.955,2219.403L779,2396L705.496,2199.678L772.745,2206.792C832.051,1999.958 984.143,1921.272 1110.63,1892.641L1107.952,1824.711L1299,1911L1115.34,2012.065L1113.142,1956.331Z" fill="black" stroke="white" stroke-width="33.33"/>
</svg>

After

Width:  |  Height:  |  Size: 406 B

View file

@ -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<CornerPosition, number> = { nw: 0, ne: 90, se: 180, sw: 270 }
import rotateCursorSvg from '@/assets/rotate-cursor.svg?raw'
const rotationCursorCache = new Map<number, string>()
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(
'<path',
`<g transform='translate(1002 2110) rotate(${key}) translate(-1002 -2110)'><path`
).replace('</svg>', '</g></svg>')
}
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 =

View file

@ -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).