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:
parent
198f2d138a
commit
b3cbf227e6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ export interface CharacterStyleOverride {
|
|||
fontFamily?: string
|
||||
letterSpacing?: number
|
||||
lineHeight?: number | null
|
||||
fills?: Fill[]
|
||||
}
|
||||
|
||||
export interface StyleRun {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
3
src/assets/rotate-cursor.svg
Normal file
3
src/assets/rotate-cursor.svg
Normal 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 |
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue