Export IS_BROWSER from core, replace all raw typeof window checks

- Add IS_BROWSER constant to core/constants.ts, rewrite IS_TAURI to use it
- Replace 9 raw 'typeof window' checks across canvaskit, fig-export,
  figma-api, fig-file, fonts, SafariBanner, use-chat
- Promote no-typeof-window-check lint rule from warn to error
- Extract array mutation helpers (updateArrayItem, removeArrayItem,
  toggleArrayVisibility) into useMultiProps — DRY up FillSection and
  StrokeSection (removes 20+ duplicated for-loops)
This commit is contained in:
Danila Poyarkov 2026-03-16 14:05:24 +03:00
parent c06ed32990
commit 921d2d35c5
14 changed files with 86 additions and 96 deletions

View file

@ -223,7 +223,7 @@ const noTypeofWindowCheck = {
},
create(context) {
const file = context.filename ?? context.getFilename?.()
if (file?.endsWith('constants.ts') || file?.endsWith('fonts.ts')) return {}
if (file?.endsWith('constants.ts')) return {}
return {
BinaryExpression(node) {

View file

@ -118,7 +118,7 @@
"open-pencil/no-hand-rolled-color": "error",
"open-pencil/no-raw-console-format": "off",
"open-pencil/no-silent-catch": "error",
"open-pencil/no-typeof-window-check": "warn"
"open-pencil/no-typeof-window-check": "error"
},
"overrides": [
{

View file

@ -1,5 +1,7 @@
import CanvasKitInit, { type CanvasKit } from 'canvaskit-wasm'
import { IS_BROWSER } from './constants'
let instance: CanvasKit | null = null
export type GpuBackend = 'webgl' | 'webgpu'
@ -16,7 +18,7 @@ export interface CanvasKitOptions {
}
function detectBackend(): GpuBackend {
if (typeof window === 'undefined') return 'webgl'
if (!IS_BROWSER) return 'webgl'
const params = new URLSearchParams(window.location.search)
if (params.get('gpu') === 'webgpu' && 'gpu' in navigator) return 'webgpu'
return 'webgl'
@ -45,7 +47,7 @@ export async function getCanvasKit(options?: CanvasKitOptions): Promise<CanvasKi
activeBackend = backend
const defaultLocate = (file: string) => {
if (typeof window !== 'undefined') return `/${file}`
if (IS_BROWSER) return `/${file}`
return file
}

View file

@ -1,6 +1,7 @@
import type { Color } from './types'
export const IS_TAURI = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
export const IS_BROWSER = typeof window !== 'undefined'
export const IS_TAURI = IS_BROWSER && '__TAURI_INTERNALS__' in window
export const BLACK: Color = { r: 0, g: 0, b: 0, a: 1 }
export const TRANSPARENT: Color = { r: 0, g: 0, b: 0, a: 0 }

View file

@ -1,6 +1,6 @@
import { zipSync, deflateSync, type Zippable } from 'fflate'
import { CANVAS_BG_COLOR, IS_TAURI } from './constants'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi, buildFontDigestMap } from './kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { stringToGuid } from './kiwi/kiwi-convert'
@ -271,7 +271,7 @@ export function compressFigDataSync(
}
function canUseWorker(): boolean {
return typeof Worker !== 'undefined' && typeof window !== 'undefined'
return typeof Worker !== 'undefined' && IS_BROWSER
}
function compressViaWorker(

View file

@ -7,6 +7,7 @@ import type {
VariableValue
} from './scene-graph'
import type { Rect, Vector } from './types'
import { IS_BROWSER } from './constants'
import { copyFills, copyStrokes, copyEffects } from './copy'
import {
@ -337,8 +338,8 @@ export class FigmaAPI implements NodeProxyHost {
const padding = 80
const contentW = maxX - minX + padding * 2
const contentH = maxY - minY + padding * 2
const viewW = typeof window !== 'undefined' ? window.innerWidth : 1280
const viewH = typeof window !== 'undefined' ? window.innerHeight : 720
const viewW = IS_BROWSER ? window.innerWidth : 1280
const viewH = IS_BROWSER ? window.innerHeight : 720
const zoom = Math.min(viewW / contentW, viewH / contentH, 1)
this._viewport = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, zoom }
}

View file

@ -2,6 +2,7 @@ import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm'
import {
DEFAULT_FONT_FAMILY,
IS_BROWSER,
CJK_FALLBACK_FAMILIES_MACOS,
CJK_FALLBACK_FAMILIES_WINDOWS,
CJK_FALLBACK_FAMILIES_LINUX,
@ -17,8 +18,6 @@ export interface FontInfo {
postscriptName: string
}
const IS_BROWSER = typeof window !== 'undefined'
const loadedFamilies = new Map<string, ArrayBuffer>()
let fontProvider: TypefaceFontProvider | null = null

View file

@ -1,6 +1,7 @@
import { unzipSync, inflateSync } from 'fflate'
import { decompress as zstdDecompress } from 'fzstd'
import { IS_BROWSER } from '../constants'
import { importNodeChanges } from './fig-import'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi-schema'
import { isZstdCompressed } from './protocol'
@ -129,7 +130,7 @@ function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
}
export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
if (typeof Worker !== 'undefined' && typeof window !== 'undefined') {
if (typeof Worker !== 'undefined' && IS_BROWSER) {
const copy = buffer.slice(0)
try {
return await parseViaWorker(buffer)

View file

@ -1,10 +1,10 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { IS_TAURI } from '@/constants'
import { IS_BROWSER, IS_TAURI } from '@/constants'
const dismissed = useLocalStorage('safari-banner-dismissed', false)
const show = !IS_TAURI && typeof window !== 'undefined' && !window.showSaveFilePicker
const show = !IS_TAURI && IS_BROWSER && !window.showSaveFilePicker
</script>
<template>

View file

@ -22,7 +22,7 @@ import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import type { Fill, Variable, Color } from '@open-pencil/core'
const { store } = useNodeProps()
const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps()
const { nodes, isMulti, active, activeNode, targetNodes, isArrayMixed, updateArrayItem, removeArrayItem, toggleArrayVisibility } = useMultiProps()
const fillsAreMixed = computed(() => isArrayMixed('fills'))
@ -56,50 +56,26 @@ function resolvedSwatchStyle(variable: Variable): string {
}
function updateFill(index: number, fill: Fill) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const fills = [...n.fills]
fills[index] = fill
store.updateNodeWithUndo(n.id, { fills }, 'Change fill')
}
updateArrayItem('fills', index, fill, 'Change fill')
}
function updateOpacity(index: number, opacity: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const fills = [...n.fills]
fills[index] = { ...fills[index], opacity: Math.max(0, Math.min(1, opacity / 100)) }
store.updateNodeWithUndo(n.id, { fills }, 'Change fill')
}
updateArrayItem('fills', index, { opacity: Math.max(0, Math.min(1, opacity / 100)) }, 'Change fill')
}
function toggleVisibility(index: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const fills = [...n.fills]
fills[index] = { ...fills[index], visible: !fills[index].visible }
store.updateNodeWithUndo(n.id, { fills }, 'Change fill')
}
toggleArrayVisibility('fills', index)
}
function add() {
if (isMulti.value) {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { fills: [{ ...DEFAULT_SHAPE_FILL }] }, 'Set fill')
}
store.requestRender()
} else {
const n = activeNode.value
if (!n) return
store.updateNodeWithUndo(n.id, { fills: [...n.fills, { ...DEFAULT_SHAPE_FILL }] }, 'Add fill')
for (const n of targetNodes()) {
const fills = isMulti.value ? [{ ...DEFAULT_SHAPE_FILL }] : [...n.fills, { ...DEFAULT_SHAPE_FILL }]
store.updateNodeWithUndo(n.id, { fills }, isMulti.value ? 'Set fill' : 'Add fill')
}
}
function remove(index: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
store.updateNodeWithUndo(n.id, { fills: n.fills.filter((_, i) => i !== index) }, 'Remove fill')
}
removeArrayItem('fills', index, 'Remove fill')
}
const searchTerm = ref('')

View file

@ -20,7 +20,7 @@ import type { Color, SceneNode, Stroke } from '@open-pencil/core'
type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM'
const { store } = useNodeProps()
const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps()
const { nodes, isMulti, active, activeNode, targetNodes, isArrayMixed, updateArrayItem, removeArrayItem, toggleArrayVisibility } = useMultiProps()
const strokesAreMixed = computed(() => isArrayMixed('strokes'))
@ -59,47 +59,26 @@ const hasStrokes = computed(
const sideMenuOpen = ref(false)
function updateColor(index: number, color: Color) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const strokes = [...n.strokes]
strokes[index] = { ...strokes[index], color }
store.updateNodeWithUndo(n.id, { strokes }, 'Change stroke')
}
updateArrayItem('strokes', index, { color }, 'Change stroke')
}
function updateWeight(index: number, weight: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const strokes = [...n.strokes]
strokes[index] = { ...strokes[index], weight }
store.updateNodeWithUndo(n.id, { strokes }, 'Change stroke')
}
updateArrayItem('strokes', index, { weight }, 'Change stroke')
}
function updateOpacity(index: number, opacity: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const strokes = [...n.strokes]
strokes[index] = { ...strokes[index], opacity: Math.max(0, Math.min(1, opacity / 100)) }
store.updateNodeWithUndo(n.id, { strokes }, 'Change stroke')
}
updateArrayItem('strokes', index, { opacity: Math.max(0, Math.min(1, opacity / 100)) }, 'Change stroke')
}
function updateAlign(align: Stroke['align']) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
for (const n of targetNodes()) {
const strokes = n.strokes.map((s) => ({ ...s, align }))
store.updateNodeWithUndo(n.id, { strokes }, 'Change stroke align')
}
}
function toggleVisibility(index: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const strokes = [...n.strokes]
strokes[index] = { ...strokes[index], visible: !strokes[index].visible }
store.updateNodeWithUndo(n.id, { strokes }, 'Change stroke')
}
toggleArrayVisibility('strokes', index)
}
function add() {
@ -110,32 +89,18 @@ function add() {
visible: true,
align: 'CENTER'
}
if (isMulti.value) {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { strokes: [stroke] }, 'Set stroke')
}
store.requestRender()
} else {
const n = activeNode.value
if (!n) return
store.updateNodeWithUndo(n.id, { strokes: [...n.strokes, stroke] }, 'Add stroke')
for (const n of targetNodes()) {
const strokes = isMulti.value ? [stroke] : [...n.strokes, stroke]
store.updateNodeWithUndo(n.id, { strokes }, isMulti.value ? 'Set stroke' : 'Add stroke')
}
}
function remove(index: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
store.updateNodeWithUndo(
n.id,
{ strokes: n.strokes.filter((_, i) => i !== index) },
'Remove stroke'
)
}
removeArrayItem('strokes', index, 'Remove stroke')
}
function selectSide(side: StrokeSides) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
for (const n of targetNodes()) {
const weight = n.strokes.length > 0 ? n.strokes[0].weight : 1
if (side === 'ALL') {
store.updateNodeWithUndo(
@ -193,8 +158,7 @@ function updateBorderWeight(side: 'top' | 'right' | 'bottom' | 'left', value: nu
bottom: 'borderBottomWeight',
left: 'borderLeftWeight'
} as const
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
for (const n of targetNodes()) {
store.updateNodeWithUndo(
n.id,
{ [fieldMap[side]]: value } as Partial<SceneNode>,

View file

@ -15,6 +15,7 @@ import {
AI_PROVIDERS,
DEFAULT_AI_MODEL,
DEFAULT_AI_PROVIDER,
IS_BROWSER,
IS_TAURI,
setPexelsApiKey,
setUnsplashAccessKey
@ -41,7 +42,7 @@ function migrateLegacyStorage() {
}
}
if (typeof window !== 'undefined') migrateLegacyStorage()
if (IS_BROWSER) migrateLegacyStorage()
const providerID = useLocalStorage<AIProviderID>(
`${STORAGE_PREFIX}ai-provider`,
@ -270,7 +271,7 @@ function resetChat() {
transportDirty = false
}
if (typeof window !== 'undefined') {
if (IS_BROWSER) {
window.__OPEN_PENCIL_SET_TRANSPORT__ = (factory) => {
overrideTransport = factory
}

View file

@ -43,6 +43,46 @@ export function useMultiProps() {
return all.some((n) => JSON.stringify(n[key]) !== first)
}
type ArrayPropKey = 'fills' | 'strokes' | 'effects'
function targetNodes(): SceneNode[] {
if (isMulti.value) return nodes.value
return activeNode.value ? [activeNode.value] : []
}
function updateArrayItem(
key: ArrayPropKey,
index: number,
patch: Record<string, unknown>,
label: string
) {
for (const n of targetNodes()) {
const arr = [...n[key]]
arr[index] = { ...arr[index], ...patch } as (typeof arr)[number]
store.updateNodeWithUndo(n.id, { [key]: arr } as Partial<SceneNode>, label)
}
}
function removeArrayItem(key: ArrayPropKey, index: number, label: string) {
for (const n of targetNodes()) {
store.updateNodeWithUndo(
n.id,
{ [key]: (n[key] as unknown[]).filter((_, i) => i !== index) } as Partial<SceneNode>,
label
)
}
}
function toggleArrayVisibility(key: ArrayPropKey, index: number) {
for (const n of targetNodes()) {
const items = n[key] as Array<{ visible: boolean }>
if (!items[index]) continue
const arr = [...n[key]]
arr[index] = { ...arr[index], visible: !items[index].visible }
store.updateNodeWithUndo(n.id, { [key]: arr } as Partial<SceneNode>, `Toggle ${key} visibility`)
}
}
return {
store,
node,
@ -50,9 +90,13 @@ export function useMultiProps() {
isMulti,
active,
activeNode,
targetNodes,
prop,
merged,
updateAllWithUndo,
updateArrayItem,
removeArrayItem,
toggleArrayVisibility,
isArrayMixed
}
}

View file

@ -1,4 +1,5 @@
export {
IS_BROWSER,
IS_TAURI,
SELECTION_COLOR,
COMPONENT_COLOR,