openpencil/packages/core/src/constants.ts
Danila Poyarkov 1a2107b0e1 fix(canvas): keep labels readable and refine section badges
Choose readable opposite edges for rotated frame titles and size badges, sharing placement with hit testing. Render section titles as compact inset badges with contrast-aware borders and hover feedback.
2026-09-15 19:54:04 +03:00

510 lines
15 KiB
TypeScript

import type { Fill, Stroke } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
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 }
export const DEFAULT_SHADOW_COLOR: Color = { r: 0, g: 0, b: 0, a: 0.25 }
export const SELECTION_COLOR = { r: 0.23, g: 0.51, b: 0.96, a: 1 } satisfies Color
export const COMPONENT_COLOR = { r: 0.592, g: 0.278, b: 1, a: 1 } satisfies Color
export const SNAP_COLOR = { r: 1.0, g: 0.0, b: 0.56, a: 1 } satisfies Color
export const MEASUREMENT_COLOR = { r: 0.949, g: 0.282, b: 0.133, a: 1 } satisfies Color
export const MEASUREMENT_PILL_PADDING_X = 5
export const MEASUREMENT_PILL_HEIGHT = 18
export const MEASUREMENT_PILL_RADIUS = 3
export const MEASUREMENT_TEXT_BASELINE = 4
export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color
export const CANVAS_BG_COLOR_DARK = { r: 0.173, g: 0.173, b: 0.173, a: 1 } satisfies Color // #2c2c2c, Figma-ish dark canvas
/**
* Returns the canvas background to initialize new pages with. Defers
* to the OS `prefers-color-scheme` so users on a dark desktop don't
* get a white flash every time they open a document.
*
* NOTE: this is deliberately the runtime/new-page path only. The
* `.fig` serialization path continues to write the static light
* `CANVAS_BG_COLOR` so files stay portable — a dark-theme user saving
* a file must not force darkness on recipients.
*/
export function getDefaultCanvasBgColor(): Color {
if (IS_BROWSER) {
const params = new URLSearchParams(window.location.search)
if ('env' in import.meta && import.meta.env.DEV && params.has('test')) {
return CANVAS_BG_COLOR
}
}
if (
IS_BROWSER &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return CANVAS_BG_COLOR_DARK
}
return CANVAS_BG_COLOR
}
export const SNAP_THRESHOLD_SCREEN_PX = 5
export const RULER_SIZE = 20
export const RULER_BG_COLOR = { r: 0.14, g: 0.14, b: 0.14, a: 1 } satisfies Color
export const RULER_TICK_COLOR = { r: 0.4, g: 0.4, b: 0.4, a: 1 } satisfies Color
export const RULER_TEXT_COLOR = { r: 0.55, g: 0.55, b: 0.55, a: 1 } satisfies Color
export const RULER_BADGE_HEIGHT = 14
export const RULER_BADGE_PADDING = 3
export const RULER_BADGE_RADIUS = 2
export const RULER_BADGE_EXCLUSION = 30
export const RULER_TEXT_BASELINE = 0.65
export const RULER_MAJOR_TICK = 0.5
export const RULER_MINOR_TICK = 0.25
export const RULER_HIGHLIGHT_ALPHA = 0.3
export const PEN_HANDLE_RADIUS = 2.5
export const PEN_VERTEX_RADIUS = 3
export const PEN_CLOSE_RADIUS_BOOST = 2
export const PEN_PATH_STROKE_WIDTH = 2
export const PARENT_OUTLINE_ALPHA = 0.5
export const PARENT_OUTLINE_DASH = 4
export const DEFAULT_FONT_FAMILY = 'Inter'
export const DEFAULT_FONT_SIZE = 14
export const DEFAULT_STROKE_MITER_LIMIT = 4
export const LABEL_FONT_SIZE = 11
export const SIZE_FONT_SIZE = 10
export const HANDLE_HALF_SIZE = 3
export const LABEL_OFFSET_Y = 8
export const SIZE_PILL_PADDING_X = 6
export const SIZE_PILL_PADDING_Y = 6
export const SIZE_PILL_HEIGHT = 18
export const SIZE_PILL_RADIUS = 4
export const SIZE_PILL_TEXT_OFFSET_Y = 13
export const MARQUEE_FILL_ALPHA = 0.08
export const SELECTION_DASH_ALPHA = 0.6
export const DROP_HIGHLIGHT_ALPHA = 0.8
export const DROP_HIGHLIGHT_STROKE = 2
export const LAYOUT_INDICATOR_STROKE = 2
export const AUTO_LAYOUT_HOVER_TICK_LENGTH = 12
export const AUTO_LAYOUT_HOVER_STRIPE_GAP = 8
export const AUTO_LAYOUT_HOVER_STRIPE_WIDTH = 1
export const AUTO_LAYOUT_HOVER_STROKE_WIDTH = 1.5
export const AUTO_LAYOUT_HOVER_CHILD_DASH = 4
export const AUTO_LAYOUT_HOVER_VALUE_OFFSET = 18
export const AUTO_LAYOUT_HOVER_VALUE_PILL_HEIGHT = 22
export const AUTO_LAYOUT_HOVER_VALUE_PILL_RADIUS = 5
export const AUTO_LAYOUT_HOVER_VALUE_PILL_PADDING_X = 5
export const AUTO_LAYOUT_HOVER_GAP_REGION_TOLERANCE = 12
export const AUTO_LAYOUT_HOVER_TICK_HIT_TOLERANCE = 8
export const AUTO_LAYOUT_HOVER_PADDING_REGION_TOLERANCE = 20
export const AUTO_LAYOUT_PADDING_EDITOR_OFFSET_X = 18
export const AUTO_LAYOUT_PADDING_EDITOR_OFFSET_Y = 36
export const AUTO_LAYOUT_HOVER_BLUE = { r: 0.28, g: 0.64, b: 1, a: 0.82 } satisfies Color
export const AUTO_LAYOUT_HOVER_BLUE_FILL = { r: 0.28, g: 0.64, b: 1, a: 0.1 } satisfies Color
export const AUTO_LAYOUT_HOVER_MAGENTA = { r: 1, g: 0.32, b: 0.68, a: 0.78 } satisfies Color
export const AUTO_LAYOUT_HOVER_MAGENTA_FILL = { r: 1, g: 0.32, b: 0.68, a: 0.1 } satisfies Color
export const SECTION_CORNER_RADIUS = 5
export const SECTION_TITLE_HEIGHT = 24
export const SECTION_TITLE_PADDING_X = 6
export const SECTION_TITLE_RADIUS = 5
export const SECTION_TITLE_FONT_SIZE = 11
export const SECTION_TITLE_GAP = 6
export const SECTION_HOVER_STROKE_WIDTH = 2
export const COMPONENT_SET_DASH = 6
export const COMPONENT_SET_DASH_GAP = 4
export const COMPONENT_SET_BORDER_WIDTH = 1.5
export const COMPONENT_LABEL_FONT_SIZE = 11
export const COMPONENT_LABEL_GAP = 6
export const COMPONENT_LABEL_ICON_SIZE = 10
export const COMPONENT_LABEL_ICON_GAP = 4
export const RULER_TARGET_PIXEL_SPACING = 100
export const RULER_MAJOR_TOLERANCE = 0.01
export const FLASH_COLOR = SELECTION_COLOR
export const FLASH_ATTACK_MS = 200
export const FLASH_HOLD_MS = 400
export const FLASH_RELEASE_MS = 300
export const FLASH_STROKE_WIDTH = 2
export const FLASH_PADDING = 5
export const FLASH_OVERSHOOT = 30
export const FLASH_RADIUS = 4
export const AI_ACTIVE_COLOR = { r: 0.26, g: 0.52, b: 0.96 }
export const AI_DONE_COLOR = { r: 0.16, g: 0.73, b: 0.36 }
export const AI_PULSE_PERIOD_MS = 1500
export const AI_DONE_DURATION_MS = 800
export const TEXT_SELECTION_COLOR = { r: 0.26, g: 0.52, b: 0.96, a: 0.3 }
export const TEXT_CARET_COLOR = BLACK
export const TEXT_CARET_WIDTH = 1
export type ACPAgentID = 'claude-code' | 'codex' | 'gemini-cli'
export interface ACPAgentDef {
id: ACPAgentID
name: string
command: string
args: string[]
installCommand?: string
}
export const ACP_AGENTS: ACPAgentDef[] = [
{
id: 'claude-code',
name: 'Claude Code',
command: 'claude-agent-acp',
args: [],
installCommand: 'npm i -g @agentclientprotocol/claude-agent-acp'
},
{
id: 'codex',
name: 'Codex',
command: 'codex-acp',
args: [],
installCommand: 'npm i -g @zed-industries/codex-acp'
},
{
id: 'gemini-cli',
name: 'Gemini CLI',
command: 'gemini',
args: ['--acp'],
installCommand: 'npm i -g @google/gemini-cli'
}
]
export type AIProviderID =
| 'openrouter'
| 'anthropic'
| 'openai'
| 'google'
| 'deepseek'
| 'openai-compatible'
| 'zai'
| 'minimax'
| 'anthropic-compatible'
| 'harness:pi'
| `acp:${ACPAgentID}`
export interface ModelOption {
id: string
name: string
tag?: string
capabilities?: readonly ('tools' | 'vision')[]
recommendedMaxOutputTokens?: number
releaseDate?: string
status?: 'active' | 'beta' | 'deprecated'
}
export interface AIProviderDef {
id: AIProviderID
name: string
keyPlaceholder: string
keyURL: string
models: ModelOption[]
defaultModel: string
supportsCustomBaseURL?: boolean
supportsCustomModel?: boolean
}
export const HARNESS_PROVIDER_ID = 'harness:pi' as const
export const AI_PROVIDERS: AIProviderDef[] = [
{
id: HARNESS_PROVIDER_ID,
name: 'Pi',
keyPlaceholder: 'Provider API key',
keyURL: '',
defaultModel: '',
supportsCustomModel: true,
models: []
},
{
id: 'openrouter',
name: 'OpenRouter',
keyPlaceholder: 'sk-or-…',
keyURL: 'https://openrouter.ai/keys',
defaultModel: 'anthropic/claude-sonnet-5',
supportsCustomModel: true,
models: [
{
id: 'anthropic/claude-sonnet-5',
name: 'Claude Sonnet 5',
tag: 'Best for design',
capabilities: ['tools', 'vision']
},
{
id: 'anthropic/claude-opus-5',
name: 'Claude Opus 5',
tag: 'Smartest',
capabilities: ['tools', 'vision']
},
{
id: 'anthropic/claude-fable-5.1',
name: 'Claude Fable 5.1',
tag: 'Latest Anthropic',
capabilities: ['tools', 'vision']
},
{
id: 'openai/gpt-5.6',
name: 'GPT-5.6',
tag: 'Latest OpenAI',
capabilities: ['tools', 'vision']
},
{
id: 'google/gemini-3.8-flash',
name: 'Gemini 3.8 Flash',
tag: 'Fast',
capabilities: ['tools', 'vision']
},
{ id: 'z-ai/glm-5.3', name: 'GLM-5.3', capabilities: ['tools'] },
{
id: 'deepseek/deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
tag: 'Reasoning',
capabilities: ['tools']
},
{
id: 'moonshotai/kimi-k3',
name: 'Kimi K3',
tag: 'Vision + code',
capabilities: ['tools', 'vision']
},
{ id: 'qwen/qwen3-coder:free', name: 'Qwen3 Coder', tag: 'Free' },
{ id: 'openai/gpt-oss-120b:free', name: 'GPT-OSS 120B', tag: 'Free' }
]
},
{
id: 'anthropic',
name: 'Anthropic',
keyPlaceholder: 'sk-ant-…',
keyURL: 'https://console.anthropic.com/settings/keys',
defaultModel: 'claude-sonnet-5',
models: [
{
id: 'claude-sonnet-5',
name: 'Claude Sonnet 5',
tag: 'Best for design',
capabilities: ['tools', 'vision']
},
{
id: 'claude-opus-5',
name: 'Claude Opus 5',
tag: 'Smartest',
capabilities: ['tools', 'vision']
},
{
id: 'claude-fable-5-1',
name: 'Claude Fable 5.1',
tag: 'Latest',
capabilities: ['tools', 'vision']
}
]
},
{
id: 'openai',
name: 'OpenAI',
keyPlaceholder: 'sk-…',
keyURL: 'https://platform.openai.com/api-keys',
defaultModel: 'gpt-5.6',
models: [
{ id: 'gpt-5.6', name: 'GPT-5.6', tag: 'Best', capabilities: ['tools', 'vision'] },
{ id: 'gpt-5.5', name: 'GPT-5.5', capabilities: ['tools', 'vision'] },
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 mini', tag: 'Fast', capabilities: ['tools', 'vision'] },
{ id: 'gpt-5.4-nano', name: 'GPT-5.4 nano', tag: 'Cheap', capabilities: ['tools', 'vision'] }
]
},
{
id: 'google',
name: 'Google AI',
keyPlaceholder: 'AIza…',
keyURL: 'https://aistudio.google.com/apikey',
defaultModel: 'gemini-3.8-flash',
models: [
{
id: 'gemini-3.8-flash',
name: 'Gemini 3.8 Flash',
tag: 'Latest',
capabilities: ['tools', 'vision']
},
{ id: 'gemini-3.7-flash', name: 'Gemini 3.7 Flash', capabilities: ['tools', 'vision'] },
{
id: 'gemini-flash-latest',
name: 'Gemini Flash Latest',
tag: 'Alias',
capabilities: ['tools', 'vision']
},
{
id: 'gemini-3.1-pro-preview',
name: 'Gemini 3.1 Pro',
tag: 'Pro',
capabilities: ['tools', 'vision']
}
]
},
{
id: 'deepseek',
name: 'DeepSeek',
keyPlaceholder: 'sk-…',
keyURL: 'https://platform.deepseek.com/api_keys',
defaultModel: 'deepseek-v4-flash',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', tag: 'Fast' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', tag: 'Reasoning' }
]
},
{
id: 'zai',
name: 'Z.ai',
keyPlaceholder: 'API key',
keyURL: 'https://docs.z.ai/devpack/quick-start',
defaultModel: 'glm-5.3',
models: [
{ id: 'glm-5.3', name: 'GLM-5.3', tag: 'Best' },
{ id: 'glm-5.3-flash', name: 'GLM-5.3-Flash', tag: 'Fast' },
{ id: 'glm-5.2', name: 'GLM-5.2' },
{
id: 'glm-5v-turbo',
name: 'GLM-5V-Turbo',
tag: 'Vision',
capabilities: ['tools', 'vision']
},
{ id: 'glm-5.1', name: 'GLM-5.1' },
{ id: 'glm-5', name: 'GLM-5' },
{ id: 'glm-5-code', name: 'GLM-5-Code' },
{ id: 'glm-4.7', name: 'GLM-4.7' },
{ id: 'glm-4.7-flashx', name: 'GLM-4.7-FlashX' },
{ id: 'glm-4.6', name: 'GLM-4.6' },
{ id: 'glm-4.5', name: 'GLM-4.5' },
{ id: 'glm-4.5-x', name: 'GLM-4.5-X' },
{ id: 'glm-4.5-air', name: 'GLM-4.5-Air' },
{ id: 'glm-4.5-airx', name: 'GLM-4.5-AirX' },
{ id: 'glm-4-32b-0414-128k', name: 'GLM-4-32B-0414-128K' },
{ id: 'glm-4.7-flash', name: 'GLM-4.7-Flash', tag: 'Free' },
{ id: 'glm-4.5-flash', name: 'GLM-4.5-Flash', tag: 'Free' }
]
},
{
id: 'minimax',
name: 'MiniMax',
keyPlaceholder: 'API key',
keyURL: 'https://platform.minimax.io/user-center/basic-information/interface-key',
defaultModel: 'MiniMax-M3',
models: [
{ id: 'MiniMax-M3', name: 'MiniMax-M3', tag: 'Best' },
{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7' },
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax-M2.7-highspeed', tag: 'Fast' },
{ id: 'MiniMax-M2.5', name: 'MiniMax-M2.5' },
{ id: 'MiniMax-M2.5-highspeed', name: 'MiniMax-M2.5 Highspeed', tag: 'Fast' },
{ id: 'MiniMax-M2.1', name: 'MiniMax-M2.1' },
{ id: 'MiniMax-M2.1-highspeed', name: 'MiniMax-M2.1 Highspeed', tag: 'Fast' },
{ id: 'MiniMax-M2', name: 'MiniMax-M2' }
]
},
{
id: 'openai-compatible',
name: 'OpenAI-compatible',
keyPlaceholder: 'API key',
keyURL: '',
defaultModel: '',
models: [],
supportsCustomBaseURL: true,
supportsCustomModel: true
},
{
id: 'anthropic-compatible',
name: 'Anthropic-compatible',
keyPlaceholder: 'API key',
keyURL: '',
defaultModel: '',
models: [],
supportsCustomBaseURL: true,
supportsCustomModel: true
}
]
export const DEFAULT_AI_PROVIDER: AIProviderID = 'openai-compatible'
export const DEFAULT_AI_MODEL =
AI_PROVIDERS.find((provider) => provider.id === DEFAULT_AI_PROVIDER)?.defaultModel ?? ''
export const AUTOMATION_HTTP_PORT = 7600
export const GOOGLE_FONTS_API_KEY = 'AIzaSyD1tYDR_dUEiV-Tw1vksEhZbUytgKW5pc8'
export const CJK_FALLBACK_FAMILIES_MACOS = [
'PingFang SC',
'PingFang TC',
'Hiragino Sans',
'Apple SD Gothic Neo',
'Heiti SC',
'Heiti TC'
]
export const CJK_FALLBACK_FAMILIES_WINDOWS = [
'Microsoft YaHei',
'Microsoft YaHei UI',
'Microsoft JhengHei',
'Yu Gothic',
'Malgun Gothic',
'SimHei',
'SimSun'
]
export const CJK_FALLBACK_FAMILIES_LINUX = [
'Noto Sans CJK SC',
'Noto Sans CJK TC',
'Noto Sans CJK JP',
'Noto Sans CJK KR',
'WenQuanYi Micro Hei',
'Droid Sans Fallback'
]
export const CJK_GOOGLE_FONTS = ['Noto Sans SC', 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans KR']
export const DEFAULT_SHAPE_FILL: Fill = {
type: 'SOLID',
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true
}
export const DEFAULT_FRAME_FILL: Fill = {
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
opacity: 1,
visible: true
}
export const SECTION_DEFAULT_FILL: Fill = {
type: 'SOLID',
color: { r: 0.37, g: 0.37, b: 0.37, a: 1 },
opacity: 1,
visible: true
}
export const SECTION_DEFAULT_STROKE: Stroke = {
color: { r: 0.55, g: 0.55, b: 0.55, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'INSIDE'
}
export const ZOOM_DIVISOR = 50
export const ZOOM_SCALE_MIN = 0.75
export const ZOOM_SCALE_MAX = 1.25
export const PEN_CLOSE_THRESHOLD = 8
export const ROTATION_SNAP_DEGREES = 15
export const CORNER_ROTATE_ZONE = 16
export const HANDLE_HIT_RADIUS = 6
export const ROTATION_HANDLE_DISTANCE = 24
export const DEFAULT_TEXT_WIDTH = 200
export const DEFAULT_TEXT_HEIGHT = 24
export const AUTO_LAYOUT_BREAK_THRESHOLD = 8