openpencil/electron/main.ts
Kayshen Xu ebaf093891 V0.1.0 (#13)
* feat(editor): implement Figma import functionality

- Added a new FigmaImportDialog component for importing .fig files.
- Integrated file processing and conversion logic to handle Figma files.
- Enhanced the editor layout to include a button for opening the Figma import dialog.
- Introduced page tabs for better navigation between document pages.
- Updated various components to utilize active page children for rendering and interactions.
- Added new dependencies: fzstd and kiwi-schema for improved functionality.

* fix(ai): harden Claude agent env variable resolution

- Filter empty strings and object values from settings.json env
- Validate ANTHROPIC_CUSTOM_HEADERS is valid JSON before passing
- Remove incorrect ANTHROPIC_CUSTOM_HEADERS auto-generation from auth token

* refactor(ai): remove silent fallback logic, surface errors in AI panel

- Remove direct-stream fallback in generateDesign, delegate to orchestrator
- Remove orchestrator planner fallback plan (buildFallbackPlanFromPrompt)
- Remove sub-agent retry logic, minimal mode retry, and placeholder insertion
- Throw on sub-agent error instead of silently degrading
- Call updateLastMessage in catch block so errors display immediately
- Clean up unused imports, functions, and console.log/warn statements

* fix(ai): add 30s API connect timeout for faster error detection

- Set maxRetries: 0 on Anthropic SDK to prevent retry delays on 429
- Add AbortController-based 30s timeout for initial API connection
- If the provider doesn't respond within 30s, abort and send SSE error
- Refactor streamViaAnthropicSDK with shared send() helper

* fix(editor): improve Figma import visual fidelity

- Extract image files from .fig ZIP archive and resolve by SHA-1 hash
- Map Figma textCase property (UPPER/LOWER/TITLE) to text content
- Convert ellipse nodes with image fills to image nodes
- Decode Figma vector paths and match icons via Lucide icon registry
- Fix z-order: move background rectangles behind content layers
- Set clipContent=true for Figma frames (default clip behavior)
- Unwrap single-artboard pages to use artboard as root frame

* fix(canvas): preserve clipPath during async image load and invalidate cache

- Copy clipPath from placeholder rect to FabricImage in onload callback
- Set obj.dirty=true after applying/removing clipPath so Fabric.js
  re-renders with the updated clip region instead of using stale cache

* fix(ai): use page-aware empty frame detection and place new designs as siblings

- Fix isCanvasOnlyEmptyFrame() to use getActivePageChildren() instead of
  document.children which is empty after page migration
- When canvas is empty, replace default frame (existing behavior restored)
- When canvas has content, add generated frame as top-level sibling
  positioned to the right with 100px gap
- Track generationRootFrameId so height adjustment targets correct frame
- Add WIDTH SELECTION rule: app screens use 375x812 mobile dimensions
- Include selected node dimensions in AI context string

* feat(electron): implement application menu with native actions

- Added a native application menu for macOS and Windows, including File, Edit, View, and Window menus.
- Integrated menu actions for creating, opening, saving files, and editing operations like undo/redo.
- Enhanced the editor layout to handle Electron menu actions through a custom hook.
- Updated icon components in the toolbar and top bar for consistent styling.
- Refactored padding and layout components for improved structure and readability.

* fix(canvas): use page-aware children for selection depth resolution

getSelectableNodeIds() used doc.children which is empty under the
pages architecture. Switch to getActivePageChildren() so canvas
selection correctly syncs to the layers panel.

* fix(panels): adjust property panel sections for image nodes

Hide fill/stroke sections (not applicable to images) and show
corner radius section for image elements.

* feat(canvas): implement image corner radius via clipPath

FabricImage does not support rx/ry natively. Use a rounded-rect
clipPath instead, with objectCaching disabled to ensure immediate
re-render on radius changes.

* fix(canvas): use page-aware forcePageResync for canvas re-sync

The old pattern { ...doc, children: [...doc.children] } only touched
root-level children which are empty under pages architecture. Extract
forcePageResync() into canvas-sync-utils and replace all 7 occurrences
across canvas-object-modified, drag-into-layout, and layout-reorder.

* fix(canvas): preserve image corner radius clipPath during drag

Three issues caused corner radius loss on image drag:

1. Scale baking in object:modified changed FabricImage width from
   natural to display dimensions, creating a clipPath size mismatch.
   Exclude images from scale baking (like paths/polygons).

2. object:moving unconditionally cleared all clipPaths to avoid stale
   frame clipping. Now only clears absolutePositioned (frame) clips,
   preserving object-level clips like image corner radius.

3. Post-drag re-sync overwrote the corner radius clipPath with the
   parent frame clip. Now skips frame clip when the object already
   has its own non-absolutePositioned clipPath.

* feat(ai): enhance chat functionality with image attachments support

- Added support for image attachments in chat messages, allowing users to send images alongside text.
- Implemented temporary file handling for attachments, ensuring they are accessible to the AI processing.
- Updated chat message structure to include attachments, and modified the prompt building logic to reference these images.
- Enhanced the chat panel UI to allow users to select and preview images before sending.
- Introduced a new state management for pending attachments in the AI store.
- Refactored related components to accommodate the new attachment feature, improving user experience and interaction.

* feat(multi-page): introduce multi-page architecture and enhance agent settings UI

- Added multi-page architecture to the document structure, allowing for page CRUD actions such as add, remove, rename, reorder, and duplicate.
- Implemented `activePageId` state management in the canvas store for better page handling.
- Enhanced the top bar with an `AgentStatusButton` to display connected AI providers and their statuses.
- Updated the agent settings dialog to improve the UI for managing AI integrations, including better layout and connection indicators.
- Refactored related components for improved usability and visual consistency.

* docs(README): add contributors section with dynamic contributor image

* feat(ai): improve chat streaming and error handling

- Added functionality to hide the checklist when streaming stops with no completed items.
- Implemented abort controller management to handle user-initiated stops during chat generation.
- Enhanced error handling to prevent displaying errors for user-initiated stops while preserving partial content.
- Updated orchestration and design generation functions to support abort signals, improving responsiveness during streaming.

* fix(canvas): align z-order with layer panel convention (top = front)

Establish consistent z-order: children[0] = top of layer panel = frontmost
on canvas. This matches the standard design tool convention (Figma, Sketch).

- Reverse flattenNodes iteration so children[0] is added last to canvas
- Add z-order reconciliation via moveObjectTo after each sync pass
- Figma import: sort children descending so front items are at children[0]
- addNode defaults to prepend (index 0) so new items appear at top
- duplicateNode inserts clone above original (at idx instead of idx+1)
- drag-reparent inserts at index 0 (frontmost) instead of childCount
- Async image loading preserves z-order via insertAt instead of add
- Add FigmaImportLayoutMode toggle (preserve vs openpencil auto-layout)

* chore: resolve .gitignore merge conflict

* fix(canvas): clamp corner radius to half the element height

* chore: update version to 0.1.0 and modify mac artifact naming in electron-builder configuration

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-01 09:33:52 +08:00

567 lines
15 KiB
TypeScript

import {
app,
BrowserWindow,
Menu,
ipcMain,
dialog,
type BrowserWindowConstructorOptions,
} from 'electron'
import { autoUpdater } from 'electron-updater'
import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider'
import { execSync } from 'node:child_process'
import { fork, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'
let mainWindow: BrowserWindow | null = null
let nitroProcess: ChildProcess | null = null
let serverPort = 0
const isDev = !app.isPackaged
type UpdaterStatus =
| 'disabled'
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'downloaded'
| 'not-available'
| 'error'
interface UpdaterState {
status: UpdaterStatus
currentVersion: string
latestVersion?: string
downloadProgress?: number
releaseDate?: string
error?: string
}
let updaterState: UpdaterState = {
status: isDev ? 'disabled' : 'idle',
currentVersion: app.getVersion(),
}
const MacGitHubUpdateProvider = class {
constructor(options: unknown, updater: unknown, runtimeOptions: unknown) {
const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any
if (process.platform === 'darwin') {
provider.getDefaultChannelName = () =>
process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac'
provider.getCustomChannelName = (channel: string) => channel
}
return provider
}
}
let lastUpdateCheckAt = 0
function broadcastUpdaterState(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('updater:state', updaterState)
}
}
}
function setUpdaterState(next: Partial<UpdaterState>): void {
updaterState = {
...updaterState,
...next,
currentVersion: app.getVersion(),
}
broadcastUpdaterState()
}
async function checkForAppUpdates(force = false): Promise<void> {
if (isDev) return
const now = Date.now()
if (!force && now - lastUpdateCheckAt < 60 * 1000) {
return
}
lastUpdateCheckAt = now
try {
await autoUpdater.checkForUpdates()
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
setUpdaterState({ status: 'error', error })
}
}
function setupAutoUpdater(): void {
if (isDev) return
if (process.platform === 'darwin') {
autoUpdater.setFeedURL({
provider: 'custom',
updateProvider: MacGitHubUpdateProvider as any,
owner: 'ZSeven-W',
repo: 'openpencil',
releaseType: 'release',
} as any)
}
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.allowPrerelease = true
autoUpdater.on('checking-for-update', () => {
setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined })
})
autoUpdater.on('update-available', (info) => {
setUpdaterState({
status: 'available',
latestVersion: info.version,
releaseDate: info.releaseDate,
error: undefined,
})
})
autoUpdater.on('download-progress', (progress) => {
setUpdaterState({
status: 'downloading',
downloadProgress: Math.round(progress.percent),
error: undefined,
})
})
autoUpdater.on('update-downloaded', (info) => {
setUpdaterState({
status: 'downloaded',
latestVersion: info.version,
releaseDate: info.releaseDate,
downloadProgress: 100,
error: undefined,
})
})
autoUpdater.on('update-not-available', (info) => {
setUpdaterState({
status: 'not-available',
latestVersion: info.version,
downloadProgress: undefined,
error: undefined,
})
})
autoUpdater.on('error', (err) => {
setUpdaterState({
status: 'error',
error: err?.message ?? String(err),
})
})
// Delay first check until app startup work is done.
setTimeout(() => {
void checkForAppUpdates(true)
}, 5000)
// Poll for new versions while app is running.
setInterval(() => {
void checkForAppUpdates(false)
}, 60 * 60 * 1000).unref()
}
// ---------------------------------------------------------------------------
// Fix PATH for macOS GUI apps (Finder doesn't inherit shell PATH)
// ---------------------------------------------------------------------------
function fixPath(): void {
if (process.platform !== 'darwin' && process.platform !== 'linux') return
try {
const shell = process.env.SHELL || '/bin/zsh'
const shellPath = execSync(`${shell} -ilc 'echo -n "$PATH"'`, {
encoding: 'utf-8',
timeout: 5000,
}).trim()
if (shellPath) {
const current = process.env.PATH || ''
process.env.PATH = [...new Set([...shellPath.split(':'), ...current.split(':')])]
.filter(Boolean)
.join(':')
}
} catch {
// Packaged app may not have a login shell — ignore
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getFreePorts(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr && typeof addr === 'object') {
const { port } = addr
server.close(() => resolve(port))
} else {
reject(new Error('Failed to get free port'))
}
})
server.on('error', reject)
})
}
function getServerEntry(): string {
if (isDev) {
// In dev, the Nitro output lives at .output/server/index.mjs
return join(app.getAppPath(), '.output', 'server', 'index.mjs')
}
// In production, extraResources copies .output into the resources folder
return join(process.resourcesPath, 'server', 'index.mjs')
}
// ---------------------------------------------------------------------------
// Nitro server
// ---------------------------------------------------------------------------
async function startNitroServer(): Promise<number> {
const port = await getFreePorts()
const entry = getServerEntry()
return new Promise((resolve, reject) => {
const child = fork(entry, [], {
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(port),
NITRO_HOST: '127.0.0.1',
NITRO_PORT: String(port),
ELECTRON_RESOURCES_PATH: process.resourcesPath,
},
stdio: 'pipe',
})
nitroProcess = child
child.stdout?.on('data', (data: Buffer) => {
const msg = data.toString()
console.log('[nitro]', msg)
// Resolve once Nitro reports it's listening
if (msg.includes('Listening') || msg.includes('ready')) {
resolve(port)
}
})
child.stderr?.on('data', (data: Buffer) => {
console.error('[nitro:err]', data.toString())
})
child.on('error', reject)
child.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Nitro exited with code ${code}`)
}
nitroProcess = null
})
// Fallback: if no stdout "ready" message comes, wait then resolve anyway
setTimeout(() => resolve(port), 3000)
})
}
// ---------------------------------------------------------------------------
// Window
// ---------------------------------------------------------------------------
function createWindow(): void {
const windowOptions: BrowserWindowConstructorOptions = {
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 600,
title: 'OpenPencil',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
},
}
if (process.platform === 'darwin') {
windowOptions.trafficLightPosition = { x: 16, y: 11 }
}
// Start hidden to avoid visual flash before CSS injection
windowOptions.show = false
mainWindow = new BrowserWindow(windowOptions)
const url = isDev
? 'http://localhost:3000/editor'
: `http://127.0.0.1:${serverPort}/editor`
// Inject traffic-light padding CSS then show window (no flash)
mainWindow.webContents.on('did-finish-load', async () => {
if (!mainWindow) return
if (process.platform === 'darwin') {
await mainWindow.webContents.insertCSS(
'.electron-traffic-light-pad { margin-left: 74px; }' +
'.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }',
)
}
mainWindow.show()
broadcastUpdaterState()
})
// Toggle fullscreen class to remove traffic-light padding in fullscreen
if (process.platform === 'darwin') {
mainWindow.on('enter-full-screen', () => {
mainWindow?.webContents.executeJavaScript(
'document.body.classList.add("electron-fullscreen")',
)
})
mainWindow.on('leave-full-screen', () => {
mainWindow?.webContents.executeJavaScript(
'document.body.classList.remove("electron-fullscreen")',
)
})
}
mainWindow.loadURL(url)
if (isDev) {
mainWindow.webContents.openDevTools({ mode: 'detach' })
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
// ---------------------------------------------------------------------------
// IPC: native file dialogs
// ---------------------------------------------------------------------------
function setupIPC(): void {
ipcMain.handle('dialog:openFile', async () => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open .op file',
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const content = await readFile(filePath, 'utf-8')
return { filePath, content }
})
ipcMain.handle(
'dialog:saveFile',
async (_event, payload: { content: string; defaultPath?: string }) => {
if (!mainWindow) return null
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save .op file',
defaultPath: payload.defaultPath,
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
})
if (result.canceled || !result.filePath) return null
await writeFile(result.filePath, payload.content, 'utf-8')
return result.filePath
},
)
ipcMain.handle(
'dialog:saveToPath',
async (_event, payload: { filePath: string; content: string }) => {
await writeFile(payload.filePath, payload.content, 'utf-8')
return payload.filePath
},
)
ipcMain.handle('updater:getState', () => updaterState)
ipcMain.handle('updater:checkForUpdates', async () => {
await checkForAppUpdates(true)
return updaterState
})
ipcMain.handle('updater:quitAndInstall', () => {
if (!isDev && updaterState.status === 'downloaded') {
autoUpdater.quitAndInstall()
return true
}
return false
})
}
// ---------------------------------------------------------------------------
// Application menu
// ---------------------------------------------------------------------------
function sendMenuAction(action: string): void {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
win?.webContents.send('menu:action', action)
}
function buildAppMenu(): void {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
// macOS app menu
...(isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' as const },
{ type: 'separator' as const },
{ role: 'services' as const },
{ type: 'separator' as const },
{ role: 'hide' as const },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
{ type: 'separator' as const },
{ role: 'quit' as const },
],
},
]
: []),
// File menu
{
label: 'File',
submenu: [
{
label: 'New',
accelerator: 'CmdOrCtrl+N',
click: () => sendMenuAction('new'),
},
{
label: 'Open\u2026',
accelerator: 'CmdOrCtrl+O',
click: () => sendMenuAction('open'),
},
{ type: 'separator' },
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => sendMenuAction('save'),
},
{ type: 'separator' },
{
label: 'Import Figma\u2026',
accelerator: 'CmdOrCtrl+Shift+F',
click: () => sendMenuAction('import-figma'),
},
...(!isMac
? [{ type: 'separator' as const }, { role: 'quit' as const }]
: []),
],
},
// Edit menu (role-based for native text input support)
{
label: 'Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
click: () => sendMenuAction('undo'),
},
{
label: 'Redo',
accelerator: 'CmdOrCtrl+Shift+Z',
click: () => sendMenuAction('redo'),
},
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac ? [{ role: 'pasteAndMatchStyle' as const }] : []),
{ role: 'selectAll' },
],
},
// View menu
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
// Window menu
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' as const },
{ role: 'front' as const },
]
: [{ role: 'close' as const }]),
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
app.on('ready', async () => {
fixPath()
setupIPC()
buildAppMenu()
if (!isDev) {
try {
serverPort = await startNitroServer()
console.log(`Nitro server started on port ${serverPort}`)
} catch (err) {
console.error('Failed to start Nitro server:', err)
app.quit()
return
}
}
createWindow()
if (!isDev) {
setupAutoUpdater()
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
app.on('before-quit', () => {
if (nitroProcess) {
nitroProcess.kill()
nitroProcess = null
}
})