refactor: remove ugly type intersection casts

- Add optional nodeEditState, cursorCanvas, and pen resume fields to core EditorState
- Add parent field to LintNode instead of repeated intersection casts
- Use typed LintPathNode for node path traversal
- Replace autosave useDebounceFn cancel hack with watchDebounced
- Simplify AppEditorState to extend EditorState without Omit+intersection
- Remove PenState intersection type alias
- Use in-operator check for optional setViewportSize
This commit is contained in:
Danila Poyarkov 2026-05-06 16:08:36 +03:00
parent 7dce333200
commit 738d199e8a
14 changed files with 54 additions and 81 deletions

View file

@ -38,9 +38,6 @@ export function renderFromEditorState(
dpr = 1,
layer: RenderLayer = 'full'
): void {
const extendedState = state as EditorState & {
nodeEditState?: RenderOverlays['nodeEditState']
}
r.dpr = dpr
r.panX = state.panX
r.panY = state.panY
@ -72,7 +69,7 @@ export function renderFromEditorState(
cursorY: state.penCursorY ?? undefined
} as RenderOverlays['penState'])
: null,
nodeEditState: extendedState.nodeEditState ?? null,
nodeEditState: state.nodeEditState ?? null,
remoteCursors: state.remoteCursors
},
state.sceneVersion,

View file

@ -1,5 +1,5 @@
import type { EditorContext } from '#core/editor/types'
import type { Fill, SceneNode, VectorNetwork, VectorRegion, VectorSegment } from '#core/scene-graph'
import type { SceneNode, VectorNetwork, VectorRegion, VectorSegment } from '#core/scene-graph'
import type { Vector } from '#core/types'
import { computeVectorBounds } from '#core/vector'
@ -189,13 +189,9 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) {
regions: network.regions
}
const penStyle = ps as typeof ps & {
resumedFills?: Fill[]
resumedStrokes?: SceneNode['strokes']
}
const fills = penStyle.resumedFills ? penStyle.resumedFills.map((f) => ({ ...f })) : []
const strokes = penStyle.resumedStrokes
? penStyle.resumedStrokes.map((s) => ({ ...s }))
const fills = ps.resumedFills ? ps.resumedFills.map((f) => ({ ...f })) : []
const strokes = ps.resumedStrokes
? ps.resumedStrokes.map((s) => ({ ...s }))
: [{ ...PEN_DEFAULT_STROKE }]
const nodeId = createShape('VECTOR', bounds.x, bounds.y, bounds.width, bounds.height)

View file

@ -1,6 +1,7 @@
import type { CanvasKit } from 'canvaskit-wasm'
import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
import type { RenderOverlays } from '#core/canvas/renderer/types'
import type { SceneGraph, SceneNode, VectorSegment, VectorVertex } from '#core/scene-graph'
import type { SnapGuide } from '#core/scene-graph/snap'
import type { UndoManager } from '#core/scene-graph/undo'
@ -45,6 +46,9 @@ export interface EditorState {
oppositeDragTangent: Vector | null
pendingClose?: boolean
closingToFirst: boolean
resumingNodeId?: string
resumedFills?: SceneNode['fills']
resumedStrokes?: SceneNode['strokes']
} | null
penCursorX: number | null
penCursorY: number | null
@ -65,6 +69,9 @@ export interface EditorState {
sceneVersion: number
loading: boolean
enteredContainerId: string | null
nodeEditState?: RenderOverlays['nodeEditState'] | null
cursorCanvasX?: number | null
cursorCanvasY?: number | null
}
export interface EditorEvents {

View file

@ -17,7 +17,7 @@ export class Linter {
private rules = new Map<string, Rule>()
private ruleConfigs = new Map<string, { severity: Severity; options?: Record<string, unknown> }>()
private messages: LintMessage[] = []
private nodes = new Map<string, LintNode & { parent?: LintNode }>()
private nodes = new Map<string, LintNode>()
constructor(options: { config?: LintConfig; preset?: string; rules?: string[] } = {}) {
let baseConfig: Record<
@ -61,8 +61,8 @@ export class Linter {
const raw = graph.getNode(id)
if (!raw) return
const node = this.toLintNode(raw)
;(node as LintNode & { parent?: LintNode }).parent = parent
this.nodes.set(id, node as LintNode & { parent?: LintNode })
node.parent = parent
this.nodes.set(id, node)
for (const childId of raw.childIds) this.capture(graph, childId, node)
}
@ -125,7 +125,7 @@ export class Linter {
message,
nodeId: node.id,
nodeName: node.name,
nodePath: getNodePath(this.nodes.get(node.id) as LintNode & { parent?: LintNode }),
nodePath: getNodePath(this.nodes.get(node.id) ?? node),
suggest
})
},

View file

@ -19,7 +19,14 @@ export default defineRule({
'INSTANCE'
],
check(node, context) {
const checkPaints = (paints: typeof node.fills, field: 'fills' | 'strokes') => {
const checkPaints = (
paints: ReadonlyArray<{
visible: boolean
color?: { r: number; g: number; b: number }
type?: string
}>,
field: 'fills' | 'strokes'
) => {
for (const paint of paints) {
if (paint.type !== 'SOLID' || !paint.visible || !paint.color || node.boundVariables[field])
continue
@ -31,6 +38,6 @@ export default defineRule({
}
}
checkPaints(node.fills, 'fills')
checkPaints(node.strokes as typeof node.fills, 'strokes')
checkPaints(node.strokes, 'strokes')
}
})

View file

@ -78,6 +78,7 @@ export interface LintNode {
visible: boolean
radius: number
}>
parent?: LintNode
}
export interface RuleContext {

View file

@ -10,15 +10,17 @@ export function isMultipleOf(value: number, base: number, tolerance = 0.01): boo
return remainder < tolerance || base - remainder < tolerance
}
export function getNodePath(node: {
interface LintPathNode {
name: string
parent?: { name: string; parent?: unknown }
}): string[] {
parent?: LintPathNode
}
export function getNodePath(node: LintPathNode): string[] {
const path: string[] = []
let current: typeof node | undefined = node
let current: LintPathNode | undefined = node
while (current) {
path.unshift(current.name)
current = current.parent as typeof node | undefined
current = current.parent
}
return path
}

View file

@ -28,8 +28,7 @@ export type CanvasNodeEditMethods = Partial<{
}>
export function getCanvasNodeEditState(editor: Editor): CanvasNodeEditState | null | undefined {
return (editor.state as Editor['state'] & { nodeEditState?: CanvasNodeEditState | null })
.nodeEditState
return editor.state.nodeEditState as CanvasNodeEditState | null | undefined
}
export function resolveBendTargetHandle(

View file

@ -7,8 +7,6 @@ import { createPenDrag, handlePenDragMove } from '#vue/canvas/pen-input/drag'
import { handlePenNodeEditDown } from '#vue/shared/input/node-edit'
import type { DragState } from '#vue/shared/input/types'
type NodeEditState = NonNullable<Editor['state']> & { nodeEditState?: object | null }
type SetDrag = (drag: DragState) => void
export function startPenInput(
@ -22,7 +20,7 @@ export function startPenInput(
editor.state.penCursorX = null
editor.state.penCursorY = null
const nodeEditState = (editor.state as NodeEditState).nodeEditState
const nodeEditState = editor.state.nodeEditState
if (nodeEditState) {
handlePenNodeEditDown(e, cx, cy, editor)
return true

View file

@ -12,10 +12,9 @@ export function sizeCanvas(canvas: HTMLCanvasElement, editor: Editor) {
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.clientWidth * dpr
canvas.height = canvas.clientHeight * dpr
const maybeSizedEditor = editor as Editor & {
setViewportSize?: (width: number, height: number) => void
if ('setViewportSize' in editor && typeof editor.setViewportSize === 'function') {
editor.setViewportSize(canvas.clientWidth, canvas.clientHeight)
}
maybeSizedEditor.setViewportSize?.(canvas.clientWidth, canvas.clientHeight)
}
export function makeGLSurface(

View file

@ -14,10 +14,7 @@ export type NodeEditState = {
}
export function getNodeEditState(editor: Editor): NodeEditState | null {
return (
(editor.state as Editor['state'] & { nodeEditState?: NodeEditState | null }).nodeEditState ??
null
)
return (editor.state.nodeEditState as NodeEditState | null) ?? null
}
export const NODE_HIT_THRESHOLD = 8

View file

@ -1,5 +1,4 @@
import { useDebounceFn } from '@vueuse/core'
import { watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import type { EditorState } from '@open-pencil/core/editor'
@ -18,29 +17,20 @@ export function createAutosave({
hasWritableSource,
saveCurrentDocument
}: AutosaveOptions) {
const debouncedAutosave = useDebounceFn(async () => {
if (state.sceneVersion === getSavedVersion()) return
if (!state.autosaveEnabled) return
try {
await saveCurrentDocument()
} catch (e) {
console.warn('Autosave failed:', e)
}
}, 3000)
watch(
const stop = watchDebounced(
() => state.sceneVersion,
(version) => {
async (version) => {
if (version === getSavedVersion()) return
if (!state.autosaveEnabled) return
if (!hasWritableSource()) return
void debouncedAutosave()
}
try {
await saveCurrentDocument()
} catch (e) {
console.warn('Autosave failed:', e)
}
},
{ debounce: 3000 }
)
function disposeAutosave() {
;(debouncedAutosave as typeof debouncedAutosave & { cancel?: () => void }).cancel?.()
}
return { disposeAutosave }
return { disposeAutosave: stop }
}

View file

@ -1,15 +1,8 @@
import type { EditorState } from '@open-pencil/core/editor'
import type { Fill, SceneNode, VectorSegment, VectorVertex } from '@open-pencil/core/scene-graph'
import type { SceneNode, VectorSegment, VectorVertex } from '@open-pencil/core/scene-graph'
type ResumablePenState = NonNullable<EditorState['penState']> & {
resumingNodeId?: string
resumedFills?: Fill[]
resumedStrokes?: SceneNode['strokes']
}
export type PenState = Omit<EditorState, 'penState'> & {
penState: ResumablePenState | null
}
export type PenState = EditorState
type PenStateInit = NonNullable<EditorState['penState']>
export function absoluteVertices(node: SceneNode, vertices: VectorVertex[]): VectorVertex[] {
return vertices.map((v) => ({
@ -31,7 +24,7 @@ export function createResumedPenState(
node: SceneNode,
vertices: VectorVertex[],
segments: VectorSegment[]
): ResumablePenState {
): PenStateInit {
return {
vertices,
segments,

View file

@ -1,6 +1,4 @@
import { createDefaultEditorState, type EditorState } from '@open-pencil/core/editor'
import type { Fill, SceneNode, VectorSegment, VectorVertex } from '@open-pencil/core/scene-graph'
import type { Vector } from '@open-pencil/core/types'
import type { NodeEditState } from '@/app/editor/vector-edit/types'
@ -23,18 +21,7 @@ export function createInitialAppEditorState(pageId: string): AppEditorState {
}
}
export type AppEditorState = Omit<EditorState, 'penState'> & {
penState: {
vertices: VectorVertex[]
segments: VectorSegment[]
dragTangent: Vector | null
oppositeDragTangent: Vector | null
closingToFirst: boolean
pendingClose?: boolean
resumingNodeId?: string
resumedFills?: Fill[]
resumedStrokes?: SceneNode['strokes']
} | null
export type AppEditorState = EditorState & {
showUI: boolean
showRulers: boolean
showRemoteCursors: boolean