painapple-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageAnnotateWidget - Draw on an image before uploading
|
|
3
|
+
*
|
|
4
|
+
* Paint-style editor opened when the user pastes an image (Cmd/Ctrl+V).
|
|
5
|
+
* Tools: numbered comment markers, freehand pen, arrow, rectangle, ellipse,
|
|
6
|
+
* text; color swatches, three stroke sizes, undo/redo. "Attach annotated"
|
|
7
|
+
* exports the canvas as PNG and hands it back; "Attach original" skips
|
|
8
|
+
* editing. Enter accepts (attach annotated); Escape cancels — but once
|
|
9
|
+
* anything has been drawn, Escape/Cancel arm an inline "Discard changes?"
|
|
10
|
+
* confirm and a second press within 4s actually discards.
|
|
11
|
+
*
|
|
12
|
+
* Marker tool (default): click drops a numbered yellow badge and opens a
|
|
13
|
+
* floating comment box. The comment is NOT drawn on the image — onDone
|
|
14
|
+
* receives it as {n, note} so the caller can attach it to the Comments
|
|
15
|
+
* Stash, keeping prose as prompt text while the badge anchors the spot.
|
|
16
|
+
* Committed comments stay visible as a DOM bubble beside the badge
|
|
17
|
+
* (never rasterized into the exported PNG); clicking the bubble or the
|
|
18
|
+
* badge re-opens the comment for editing.
|
|
19
|
+
*
|
|
20
|
+
* Vector model: the base image plus a shapes array, fully redrawn on every
|
|
21
|
+
* change — undo/redo are just stack pops, no pixel snapshots.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { WidgetManager } from '../widget-system/index.js';
|
|
25
|
+
import { isAnnotateOnPasteEnabled, state as configState } from './config/state.js';
|
|
26
|
+
import { withChords } from '../shortcuts.js';
|
|
27
|
+
import S from '../strings.js';
|
|
28
|
+
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
// STATE
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
// Server resizes uploads to 1568px anyway — cap the canvas so huge retina
|
|
34
|
+
// screenshots don't allocate hundreds of MB of pixel buffers.
|
|
35
|
+
const MAX_CANVAS_DIM = 3000;
|
|
36
|
+
|
|
37
|
+
const state = {
|
|
38
|
+
img: null, // loaded HTMLImageElement (base picture)
|
|
39
|
+
objectUrl: null, // blob URL backing img (revoked on close)
|
|
40
|
+
fileName: null, // original file name, for the exported file
|
|
41
|
+
shapes: [],
|
|
42
|
+
redoStack: [],
|
|
43
|
+
tool: 'marker', // 'marker' | 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text'
|
|
44
|
+
color: '#ff3b30',
|
|
45
|
+
stroke: 'm', // 's' | 'm' | 'l'
|
|
46
|
+
callbacks: null, // {onDone, onSkip, onCancel}
|
|
47
|
+
settled: false, // a callback has fired — ignore further outcomes
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const COLORS = ['#ff3b30', '#ffcc00', '#34c759', '#0a84ff', '#ffffff', '#111111'];
|
|
51
|
+
|
|
52
|
+
let canvas = null;
|
|
53
|
+
let ctx2d = null;
|
|
54
|
+
let drawing = null; // in-progress shape during a pointer drag
|
|
55
|
+
let markerDrag = null; // {shape, startX, startY, moved} while dragging a badge
|
|
56
|
+
let hoverPt = null; // cursor position for the marker ghost preview
|
|
57
|
+
let _keyHandler = null;
|
|
58
|
+
let _discardArmed = false; // ESC/Cancel while dirty arms a confirm; second one discards
|
|
59
|
+
let _discardTimer = null; // auto-disarm timeout
|
|
60
|
+
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
+
// GEOMETRY / DRAWING
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
|
|
65
|
+
/** Scale factor so strokes stay visible on large images. */
|
|
66
|
+
function sizeK() {
|
|
67
|
+
return Math.max(1, (canvas?.width || 1000) / 1000);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function strokeWidth(choice = state.stroke) {
|
|
71
|
+
const k = sizeK();
|
|
72
|
+
return { s: 2.5, m: 5, l: 9 }[choice] * k;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fontSize(choice = state.stroke) {
|
|
76
|
+
const k = sizeK();
|
|
77
|
+
return Math.round({ s: 18, m: 28, l: 42 }[choice] * k);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function markerRadius(choice = state.stroke) {
|
|
81
|
+
const k = sizeK();
|
|
82
|
+
return Math.round({ s: 11, m: 15, l: 21 }[choice] * k);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function markerShapes() {
|
|
86
|
+
return state.shapes.filter(s => s.type === 'marker');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Map a pointer event to canvas-internal coordinates. */
|
|
90
|
+
function canvasPoint(e) {
|
|
91
|
+
const r = canvas.getBoundingClientRect();
|
|
92
|
+
return {
|
|
93
|
+
x: (e.clientX - r.left) * canvas.width / r.width,
|
|
94
|
+
y: (e.clientY - r.top) * canvas.height / r.height,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function drawShape(s) {
|
|
99
|
+
const c = ctx2d;
|
|
100
|
+
c.strokeStyle = s.color;
|
|
101
|
+
c.fillStyle = s.color;
|
|
102
|
+
c.lineWidth = s.w;
|
|
103
|
+
c.lineCap = 'round';
|
|
104
|
+
c.lineJoin = 'round';
|
|
105
|
+
|
|
106
|
+
if (s.type === 'pen') {
|
|
107
|
+
if (s.points.length < 2) {
|
|
108
|
+
c.beginPath();
|
|
109
|
+
c.arc(s.points[0].x, s.points[0].y, s.w / 2, 0, Math.PI * 2);
|
|
110
|
+
c.fill();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
c.beginPath();
|
|
114
|
+
c.moveTo(s.points[0].x, s.points[0].y);
|
|
115
|
+
for (let i = 1; i < s.points.length; i++) c.lineTo(s.points[i].x, s.points[i].y);
|
|
116
|
+
c.stroke();
|
|
117
|
+
} else if (s.type === 'rect') {
|
|
118
|
+
c.strokeRect(Math.min(s.x1, s.x2), Math.min(s.y1, s.y2),
|
|
119
|
+
Math.abs(s.x2 - s.x1), Math.abs(s.y2 - s.y1));
|
|
120
|
+
} else if (s.type === 'ellipse') {
|
|
121
|
+
c.beginPath();
|
|
122
|
+
c.ellipse((s.x1 + s.x2) / 2, (s.y1 + s.y2) / 2,
|
|
123
|
+
Math.abs(s.x2 - s.x1) / 2, Math.abs(s.y2 - s.y1) / 2, 0, 0, Math.PI * 2);
|
|
124
|
+
c.stroke();
|
|
125
|
+
} else if (s.type === 'arrow') {
|
|
126
|
+
const angle = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
|
|
127
|
+
const head = Math.max(12, s.w * 3.5);
|
|
128
|
+
// Stop the shaft short of the tip so it doesn't poke through the head
|
|
129
|
+
const shaftX = s.x2 - head * 0.6 * Math.cos(angle);
|
|
130
|
+
const shaftY = s.y2 - head * 0.6 * Math.sin(angle);
|
|
131
|
+
c.beginPath();
|
|
132
|
+
c.moveTo(s.x1, s.y1);
|
|
133
|
+
c.lineTo(shaftX, shaftY);
|
|
134
|
+
c.stroke();
|
|
135
|
+
c.beginPath();
|
|
136
|
+
c.moveTo(s.x2, s.y2);
|
|
137
|
+
c.lineTo(s.x2 - head * Math.cos(angle - Math.PI / 7), s.y2 - head * Math.sin(angle - Math.PI / 7));
|
|
138
|
+
c.lineTo(s.x2 - head * Math.cos(angle + Math.PI / 7), s.y2 - head * Math.sin(angle + Math.PI / 7));
|
|
139
|
+
c.closePath();
|
|
140
|
+
c.fill();
|
|
141
|
+
} else if (s.type === 'text') {
|
|
142
|
+
c.font = `600 ${s.size}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
|
|
143
|
+
c.textBaseline = 'top';
|
|
144
|
+
c.shadowColor = 'rgba(0, 0, 0, 0.55)';
|
|
145
|
+
c.shadowBlur = Math.max(2, s.size / 8);
|
|
146
|
+
c.fillText(s.text, s.x, s.y);
|
|
147
|
+
c.shadowColor = 'transparent';
|
|
148
|
+
c.shadowBlur = 0;
|
|
149
|
+
} else if (s.type === 'marker') {
|
|
150
|
+
// Numbered comment badge — fixed yellow so markers read as one
|
|
151
|
+
// family regardless of the drawing color
|
|
152
|
+
c.beginPath();
|
|
153
|
+
c.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
|
154
|
+
c.fillStyle = '#ffcc00';
|
|
155
|
+
c.shadowColor = 'rgba(0, 0, 0, 0.45)';
|
|
156
|
+
c.shadowBlur = Math.max(3, s.r * 0.4);
|
|
157
|
+
c.fill();
|
|
158
|
+
c.shadowColor = 'transparent';
|
|
159
|
+
c.shadowBlur = 0;
|
|
160
|
+
c.lineWidth = Math.max(1.5, s.r * 0.11);
|
|
161
|
+
c.strokeStyle = 'rgba(0, 0, 0, 0.55)';
|
|
162
|
+
c.stroke();
|
|
163
|
+
c.fillStyle = '#111111';
|
|
164
|
+
c.font = `700 ${Math.round(s.r * 1.2)}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
|
|
165
|
+
c.textAlign = 'center';
|
|
166
|
+
c.textBaseline = 'middle';
|
|
167
|
+
c.fillText(String(s.n), s.x, s.y + s.r * 0.06);
|
|
168
|
+
c.textAlign = 'start';
|
|
169
|
+
c.textBaseline = 'alphabetic';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Translucent preview of the next badge, following the cursor. */
|
|
174
|
+
function drawGhostMarker(pt) {
|
|
175
|
+
const c = ctx2d;
|
|
176
|
+
c.save();
|
|
177
|
+
c.globalAlpha = 0.5;
|
|
178
|
+
drawShape({ type: 'marker', x: pt.x, y: pt.y, r: markerRadius(), n: markerShapes().length + 1 });
|
|
179
|
+
c.restore();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function redraw() {
|
|
183
|
+
if (!ctx2d || !state.img) return;
|
|
184
|
+
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
|
|
185
|
+
ctx2d.drawImage(state.img, 0, 0, canvas.width, canvas.height);
|
|
186
|
+
for (const s of state.shapes) drawShape(s);
|
|
187
|
+
if (drawing) drawShape(drawing);
|
|
188
|
+
syncMarkerNotes();
|
|
189
|
+
// Ghost of the next marker under the cursor — never while dragging,
|
|
190
|
+
// editing a comment, or hovering an existing badge (that click edits)
|
|
191
|
+
if (state.tool === 'marker' && hoverPt && !drawing && !markerDrag && !_markerEditing) {
|
|
192
|
+
const overBadge = markerShapes().some(m =>
|
|
193
|
+
Math.hypot(hoverPt.x - m.x, hoverPt.y - m.y) <= m.r * 1.3);
|
|
194
|
+
if (!overBadge) drawGhostMarker(hoverPt);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
199
|
+
// POINTER INPUT
|
|
200
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
201
|
+
|
|
202
|
+
function onPointerDown(e) {
|
|
203
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
204
|
+
disarmDiscard(); // drawing again cancels a pending discard confirm
|
|
205
|
+
commitTextInput();
|
|
206
|
+
commitMarkerInput();
|
|
207
|
+
|
|
208
|
+
const pt = canvasPoint(e);
|
|
209
|
+
if (state.tool === 'marker') {
|
|
210
|
+
// Same focus-steal cancel as the text tool — the comment input must
|
|
211
|
+
// survive the trusted click's default focus change
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
const hit = [...markerShapes()].reverse().find(m =>
|
|
214
|
+
Math.hypot(pt.x - m.x, pt.y - m.y) <= m.r * 1.3);
|
|
215
|
+
if (hit) {
|
|
216
|
+
// Begin a potential drag; a click (no movement) opens the comment
|
|
217
|
+
// box on pointerup, a drag repositions the badge instead
|
|
218
|
+
markerDrag = { shape: hit, startX: pt.x, startY: pt.y, moved: false };
|
|
219
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* synthetic */ }
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const marker = {
|
|
223
|
+
type: 'marker',
|
|
224
|
+
x: pt.x,
|
|
225
|
+
y: pt.y,
|
|
226
|
+
r: markerRadius(),
|
|
227
|
+
n: markerShapes().length + 1,
|
|
228
|
+
note: '',
|
|
229
|
+
};
|
|
230
|
+
state.shapes.push(marker);
|
|
231
|
+
state.redoStack = [];
|
|
232
|
+
hoverPt = null; // the badge now sits here; drop the ghost
|
|
233
|
+
redraw();
|
|
234
|
+
updateToolbar();
|
|
235
|
+
openMarkerInput(marker, true);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (state.tool === 'text') {
|
|
239
|
+
// Cancel the click's default focus change — it lands right after we
|
|
240
|
+
// focus the floating input and would blur-commit it empty (real
|
|
241
|
+
// clicks only; synthetic test events have no default actions)
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
openTextInput(pt);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
// Capture can throw if the pointer is already gone (or synthetic in tests)
|
|
249
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* draw uncaptured */ }
|
|
250
|
+
const w = strokeWidth();
|
|
251
|
+
drawing = state.tool === 'pen'
|
|
252
|
+
? { type: 'pen', points: [pt], color: state.color, w }
|
|
253
|
+
: { type: state.tool, x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y, color: state.color, w };
|
|
254
|
+
redraw();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function onPointerMove(e) {
|
|
258
|
+
if (markerDrag) {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
const pt = canvasPoint(e);
|
|
261
|
+
if (!markerDrag.moved
|
|
262
|
+
&& Math.hypot(pt.x - markerDrag.startX, pt.y - markerDrag.startY) > 3) {
|
|
263
|
+
markerDrag.moved = true;
|
|
264
|
+
}
|
|
265
|
+
if (markerDrag.moved) {
|
|
266
|
+
markerDrag.shape.x = Math.max(0, Math.min(canvas.width, pt.x));
|
|
267
|
+
markerDrag.shape.y = Math.max(0, Math.min(canvas.height, pt.y));
|
|
268
|
+
redraw();
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Marker ghost preview — track the cursor when nothing is in progress
|
|
273
|
+
if (state.tool === 'marker' && !drawing) {
|
|
274
|
+
hoverPt = canvasPoint(e);
|
|
275
|
+
redraw();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!drawing) return;
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
const pt = canvasPoint(e);
|
|
281
|
+
if (drawing.type === 'pen') {
|
|
282
|
+
const last = drawing.points[drawing.points.length - 1];
|
|
283
|
+
if (Math.hypot(pt.x - last.x, pt.y - last.y) > 1.5) drawing.points.push(pt);
|
|
284
|
+
} else {
|
|
285
|
+
drawing.x2 = pt.x;
|
|
286
|
+
drawing.y2 = pt.y;
|
|
287
|
+
}
|
|
288
|
+
redraw();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function onPointerLeave() {
|
|
292
|
+
if (hoverPt) { hoverPt = null; redraw(); }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function onPointerUp(e) {
|
|
296
|
+
if (markerDrag) {
|
|
297
|
+
const { shape, moved } = markerDrag;
|
|
298
|
+
markerDrag = null;
|
|
299
|
+
try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
300
|
+
if (moved) redraw(); // repositioned — note preserved
|
|
301
|
+
else openMarkerInput(shape, false); // a plain click edits the comment
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!drawing) return;
|
|
305
|
+
// Discard zero-size accidental taps for shape tools (pen keeps its dot)
|
|
306
|
+
const degenerate = drawing.type !== 'pen'
|
|
307
|
+
&& Math.hypot(drawing.x2 - drawing.x1, drawing.y2 - drawing.y1) < 3;
|
|
308
|
+
if (!degenerate) {
|
|
309
|
+
state.shapes.push(drawing);
|
|
310
|
+
state.redoStack = [];
|
|
311
|
+
}
|
|
312
|
+
drawing = null;
|
|
313
|
+
redraw();
|
|
314
|
+
updateToolbar();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
318
|
+
// TEXT TOOL
|
|
319
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
320
|
+
|
|
321
|
+
function currentTextInput() {
|
|
322
|
+
return document.querySelector('#widget-image-annotate .ia-text-input');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Commit the floating text input (if any) as a text shape. */
|
|
326
|
+
function commitTextInput() {
|
|
327
|
+
const input = currentTextInput();
|
|
328
|
+
// The closing guard breaks blur re-entrancy: input.remove() fires blur,
|
|
329
|
+
// whose handler calls commit again while the node is still attached —
|
|
330
|
+
// without the guard that double-commits the shape and the second
|
|
331
|
+
// remove() throws NotFoundError
|
|
332
|
+
if (!input || input.dataset.closing) return;
|
|
333
|
+
input.dataset.closing = '1';
|
|
334
|
+
const text = input.value.trim();
|
|
335
|
+
if (text) {
|
|
336
|
+
state.shapes.push({
|
|
337
|
+
type: 'text',
|
|
338
|
+
x: parseFloat(input.dataset.x),
|
|
339
|
+
y: parseFloat(input.dataset.y),
|
|
340
|
+
text,
|
|
341
|
+
color: input.dataset.color,
|
|
342
|
+
size: parseFloat(input.dataset.size),
|
|
343
|
+
});
|
|
344
|
+
state.redoStack = [];
|
|
345
|
+
}
|
|
346
|
+
input.remove();
|
|
347
|
+
redraw();
|
|
348
|
+
updateToolbar();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function cancelTextInput() {
|
|
352
|
+
const input = currentTextInput();
|
|
353
|
+
if (!input || input.dataset.closing) return;
|
|
354
|
+
input.dataset.closing = '1';
|
|
355
|
+
input.remove();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function openTextInput(pt) {
|
|
359
|
+
const wrap = canvas.closest('.ia-canvas-wrap');
|
|
360
|
+
const r = canvas.getBoundingClientRect();
|
|
361
|
+
const wr = wrap.getBoundingClientRect();
|
|
362
|
+
const scale = r.width / canvas.width;
|
|
363
|
+
const size = fontSize();
|
|
364
|
+
|
|
365
|
+
const input = document.createElement('input');
|
|
366
|
+
input.type = 'text';
|
|
367
|
+
input.className = 'ia-text-input';
|
|
368
|
+
// Claim Enter/Escape so the app's global Escape shortcut doesn't close
|
|
369
|
+
// the whole editor out from under the input (shortcuts.js honors this)
|
|
370
|
+
input.dataset.shortcutsDisabled = 'enter,escape';
|
|
371
|
+
input.placeholder = S.widgets.image_annotate.text_placeholder;
|
|
372
|
+
input.style.left = `${r.left - wr.left + pt.x * scale}px`;
|
|
373
|
+
input.style.top = `${r.top - wr.top + pt.y * scale}px`;
|
|
374
|
+
input.style.fontSize = `${Math.max(12, size * scale)}px`;
|
|
375
|
+
input.style.color = state.color;
|
|
376
|
+
input.dataset.x = pt.x;
|
|
377
|
+
input.dataset.y = pt.y;
|
|
378
|
+
input.dataset.color = state.color;
|
|
379
|
+
input.dataset.size = size;
|
|
380
|
+
|
|
381
|
+
input.addEventListener('keydown', (e) => {
|
|
382
|
+
e.stopPropagation();
|
|
383
|
+
if (e.key === 'Enter') commitTextInput();
|
|
384
|
+
else if (e.key === 'Escape') cancelTextInput();
|
|
385
|
+
});
|
|
386
|
+
input.addEventListener('blur', () => commitTextInput());
|
|
387
|
+
|
|
388
|
+
wrap.appendChild(input);
|
|
389
|
+
input.focus();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
393
|
+
// MARKER TOOL — numbered badges whose comments go to the stash, not the image
|
|
394
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
395
|
+
|
|
396
|
+
let _markerEditing = null; // {shape, isNew} while the comment box is open
|
|
397
|
+
let _markerIdSeq = 0; // stable DOM keys for the comment bubbles
|
|
398
|
+
|
|
399
|
+
function currentMarkerInput() {
|
|
400
|
+
return document.querySelector('#widget-image-annotate .ia-marker-input');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Place a floating element beside a badge; flip left when it would overflow. */
|
|
404
|
+
function positionBesideMarker(el, shape, wrap) {
|
|
405
|
+
const r = canvas.getBoundingClientRect();
|
|
406
|
+
const wr = wrap.getBoundingClientRect();
|
|
407
|
+
const scale = r.width / canvas.width;
|
|
408
|
+
const bx = r.left - wr.left + shape.x * scale;
|
|
409
|
+
const by = r.top - wr.top + shape.y * scale;
|
|
410
|
+
const pad = shape.r * scale + 8;
|
|
411
|
+
if (bx + pad + 240 > wr.width) {
|
|
412
|
+
el.style.left = 'auto';
|
|
413
|
+
el.style.right = `${wr.width - bx + pad}px`;
|
|
414
|
+
} else {
|
|
415
|
+
el.style.right = 'auto';
|
|
416
|
+
el.style.left = `${bx + pad}px`;
|
|
417
|
+
}
|
|
418
|
+
el.style.top = `${by}px`;
|
|
419
|
+
el.style.transform = 'translateY(-50%)';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Keep a read-only comment bubble beside every badge that has a note, so
|
|
424
|
+
* committed comments stay visible and clickable (click re-opens the edit
|
|
425
|
+
* box). DOM overlay only — never rasterized into the exported PNG. Runs on
|
|
426
|
+
* every redraw; nodes are keyed by a per-shape id and updated in place.
|
|
427
|
+
*/
|
|
428
|
+
function syncMarkerNotes() {
|
|
429
|
+
const wrap = canvas?.closest('.ia-canvas-wrap');
|
|
430
|
+
if (!wrap) return;
|
|
431
|
+
const live = new Set();
|
|
432
|
+
for (const m of markerShapes()) {
|
|
433
|
+
// No bubble while empty or while its edit box replaces it
|
|
434
|
+
if (!m.note || _markerEditing?.shape === m) continue;
|
|
435
|
+
if (!m.id) m.id = String(++_markerIdSeq);
|
|
436
|
+
live.add(m.id);
|
|
437
|
+
let el = wrap.querySelector(`.ia-marker-note[data-marker-id="${m.id}"]`);
|
|
438
|
+
if (!el) {
|
|
439
|
+
el = document.createElement('div');
|
|
440
|
+
el.className = 'ia-marker-note';
|
|
441
|
+
el.dataset.markerId = m.id;
|
|
442
|
+
el.setAttribute('data-tooltip', S.widgets.image_annotate.marker_note_edit);
|
|
443
|
+
// Keep focus where it is so a peer comment box's blur→commit
|
|
444
|
+
// runs before our click (same trick as the delete button)
|
|
445
|
+
el.addEventListener('pointerdown', (e) => e.preventDefault());
|
|
446
|
+
el.addEventListener('mousedown', (e) => e.preventDefault());
|
|
447
|
+
el.addEventListener('click', (e) => {
|
|
448
|
+
e.stopPropagation();
|
|
449
|
+
openMarkerInput(m, false);
|
|
450
|
+
});
|
|
451
|
+
wrap.appendChild(el);
|
|
452
|
+
}
|
|
453
|
+
if (el.textContent !== m.note) el.textContent = m.note;
|
|
454
|
+
positionBesideMarker(el, m, wrap);
|
|
455
|
+
}
|
|
456
|
+
wrap.querySelectorAll('.ia-marker-note').forEach(el => {
|
|
457
|
+
if (!live.has(el.dataset.markerId)) el.remove();
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Remove the whole comment box (input + delete button), not just the input. */
|
|
462
|
+
function removeMarkerBox(input) {
|
|
463
|
+
(input.closest('.ia-marker-box') || input).remove();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Renumber remaining markers so badges always read 1, 2, 3… with no gaps. */
|
|
467
|
+
function renumberMarkers() {
|
|
468
|
+
markerShapes().forEach((m, i) => { m.n = i + 1; });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Save the open comment box (if any) onto its marker shape. */
|
|
472
|
+
function commitMarkerInput() {
|
|
473
|
+
const input = currentMarkerInput();
|
|
474
|
+
// Same blur re-entrancy guard as commitTextInput
|
|
475
|
+
if (!input || input.dataset.closing) return;
|
|
476
|
+
input.dataset.closing = '1';
|
|
477
|
+
if (_markerEditing) _markerEditing.shape.note = input.value.trim();
|
|
478
|
+
_markerEditing = null;
|
|
479
|
+
removeMarkerBox(input);
|
|
480
|
+
syncMarkerNotes(); // the committed comment reappears as a bubble
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Close the comment box; a brand-new marker with no comment is removed. */
|
|
484
|
+
function cancelMarkerInput() {
|
|
485
|
+
const input = currentMarkerInput();
|
|
486
|
+
if (!input || input.dataset.closing) return;
|
|
487
|
+
input.dataset.closing = '1';
|
|
488
|
+
if (_markerEditing?.isNew && !_markerEditing.shape.note) {
|
|
489
|
+
const idx = state.shapes.indexOf(_markerEditing.shape);
|
|
490
|
+
if (idx !== -1) state.shapes.splice(idx, 1);
|
|
491
|
+
renumberMarkers();
|
|
492
|
+
redraw();
|
|
493
|
+
updateToolbar();
|
|
494
|
+
}
|
|
495
|
+
_markerEditing = null;
|
|
496
|
+
removeMarkerBox(input);
|
|
497
|
+
syncMarkerNotes(); // an untouched existing note gets its bubble back
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Delete the marker whose comment box is open, then renumber + redraw. */
|
|
501
|
+
function deleteMarkerInput() {
|
|
502
|
+
const input = currentMarkerInput();
|
|
503
|
+
if (!input || input.dataset.closing) return;
|
|
504
|
+
input.dataset.closing = '1';
|
|
505
|
+
if (_markerEditing) {
|
|
506
|
+
const idx = state.shapes.indexOf(_markerEditing.shape);
|
|
507
|
+
if (idx !== -1) state.shapes.splice(idx, 1);
|
|
508
|
+
renumberMarkers();
|
|
509
|
+
redraw();
|
|
510
|
+
updateToolbar();
|
|
511
|
+
}
|
|
512
|
+
_markerEditing = null;
|
|
513
|
+
removeMarkerBox(input);
|
|
514
|
+
syncMarkerNotes();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function openMarkerInput(shape, isNew) {
|
|
518
|
+
commitMarkerInput();
|
|
519
|
+
_markerEditing = { shape, isNew };
|
|
520
|
+
syncMarkerNotes(); // hide this marker's bubble — the edit box replaces it
|
|
521
|
+
|
|
522
|
+
const wrap = canvas.closest('.ia-canvas-wrap');
|
|
523
|
+
const box = document.createElement('div');
|
|
524
|
+
box.className = 'ia-marker-box';
|
|
525
|
+
|
|
526
|
+
// Textarea, not <input> — longer descriptions are normal here since the
|
|
527
|
+
// comment travels as prompt text. Enter commits, Shift+Enter adds a line.
|
|
528
|
+
const input = document.createElement('textarea');
|
|
529
|
+
input.rows = 1;
|
|
530
|
+
input.className = 'ia-marker-input';
|
|
531
|
+
// Claim Enter/Escape from the app's global shortcut handler (see above)
|
|
532
|
+
input.dataset.shortcutsDisabled = 'enter,escape';
|
|
533
|
+
input.placeholder = S.widgets.image_annotate.marker_placeholder.replace('{n}', shape.n);
|
|
534
|
+
input.value = shape.note || '';
|
|
535
|
+
// Auto-grow with content up to the CSS max-height, then scroll
|
|
536
|
+
const autogrow = () => {
|
|
537
|
+
input.style.height = 'auto';
|
|
538
|
+
input.style.height = `${Math.min(input.scrollHeight, 132)}px`;
|
|
539
|
+
};
|
|
540
|
+
input.addEventListener('input', autogrow);
|
|
541
|
+
|
|
542
|
+
const del = document.createElement('button');
|
|
543
|
+
del.type = 'button';
|
|
544
|
+
del.className = 'ia-marker-delete';
|
|
545
|
+
del.innerHTML = TRASH_ICON;
|
|
546
|
+
del.setAttribute('data-tooltip', S.widgets.image_annotate.marker_delete);
|
|
547
|
+
del.setAttribute('aria-label', S.widgets.image_annotate.marker_delete);
|
|
548
|
+
// Keep focus on the input so its blur→commit doesn't fire before the click
|
|
549
|
+
del.addEventListener('pointerdown', (e) => e.preventDefault());
|
|
550
|
+
del.addEventListener('mousedown', (e) => e.preventDefault());
|
|
551
|
+
del.addEventListener('click', (e) => { e.stopPropagation(); deleteMarkerInput(); });
|
|
552
|
+
|
|
553
|
+
box.appendChild(input);
|
|
554
|
+
box.appendChild(del);
|
|
555
|
+
|
|
556
|
+
// Sit beside the badge; flip to the left when it would overflow
|
|
557
|
+
positionBesideMarker(box, shape, wrap);
|
|
558
|
+
|
|
559
|
+
input.addEventListener('keydown', (e) => {
|
|
560
|
+
e.stopPropagation();
|
|
561
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
562
|
+
e.preventDefault(); // don't insert a newline into the dying textarea
|
|
563
|
+
commitMarkerInput();
|
|
564
|
+
} else if (e.key === 'Escape') cancelMarkerInput();
|
|
565
|
+
});
|
|
566
|
+
input.addEventListener('blur', () => commitMarkerInput());
|
|
567
|
+
|
|
568
|
+
wrap.appendChild(box);
|
|
569
|
+
autogrow(); // size to any existing note (needs the DOM for scrollHeight)
|
|
570
|
+
input.focus();
|
|
571
|
+
// Caret at the end — re-editing usually means appending
|
|
572
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Markers that carry a comment, for the onDone callback. */
|
|
576
|
+
function collectMarkers() {
|
|
577
|
+
return markerShapes()
|
|
578
|
+
.filter(m => m.note)
|
|
579
|
+
.map(m => ({ n: m.n, note: m.note }));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
583
|
+
// UNDO / REDO
|
|
584
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
585
|
+
|
|
586
|
+
function undoOp() {
|
|
587
|
+
if (!state.shapes.length) return;
|
|
588
|
+
state.redoStack.push(state.shapes.pop());
|
|
589
|
+
redraw();
|
|
590
|
+
updateToolbar();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function redoOp() {
|
|
594
|
+
if (!state.redoStack.length) return;
|
|
595
|
+
state.shapes.push(state.redoStack.pop());
|
|
596
|
+
redraw();
|
|
597
|
+
updateToolbar();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
601
|
+
// OUTCOMES
|
|
602
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
603
|
+
|
|
604
|
+
/** Fire one of onSkip/onCancel exactly once and close. */
|
|
605
|
+
function settle(kind) {
|
|
606
|
+
if (state.settled) return;
|
|
607
|
+
state.settled = true;
|
|
608
|
+
const cbs = state.callbacks || {};
|
|
609
|
+
if (kind === 'skip') cbs.onSkip?.();
|
|
610
|
+
else cbs.onCancel?.();
|
|
611
|
+
WidgetManager.close('image-annotate');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** True once the user has drawn/placed anything — closing would lose work. */
|
|
615
|
+
function isDirty() {
|
|
616
|
+
return state.shapes.length > 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Clear the armed-discard confirmation and restore the Cancel button label. */
|
|
620
|
+
function disarmDiscard() {
|
|
621
|
+
if (_discardTimer) { clearTimeout(_discardTimer); _discardTimer = null; }
|
|
622
|
+
if (!_discardArmed) return;
|
|
623
|
+
_discardArmed = false;
|
|
624
|
+
const btn = document.querySelector('#widget-image-annotate .ia-cancel');
|
|
625
|
+
if (btn) { btn.classList.remove('armed'); btn.textContent = S.widgets.image_annotate.cancel; }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Cancel intent from ESC or the Cancel button. Clean (no drawings) → discard
|
|
630
|
+
* immediately. Dirty → first press arms an inline "Discard changes?" confirm
|
|
631
|
+
* (window.confirm() silently no-ops in the iPad PWA — use the two-step
|
|
632
|
+
* armed-button pattern instead); a second press within the window discards.
|
|
633
|
+
*/
|
|
634
|
+
function requestCancel() {
|
|
635
|
+
if (!isDirty()) { settle('cancel'); return; }
|
|
636
|
+
if (_discardArmed) { disarmDiscard(); settle('cancel'); return; }
|
|
637
|
+
_discardArmed = true;
|
|
638
|
+
const btn = document.querySelector('#widget-image-annotate .ia-cancel');
|
|
639
|
+
if (btn) { btn.classList.add('armed'); btn.textContent = S.widgets.image_annotate.confirm_discard; }
|
|
640
|
+
_discardTimer = setTimeout(disarmDiscard, 4000);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Export the annotated canvas as PNG, fire onDone, close. */
|
|
644
|
+
function finishDone() {
|
|
645
|
+
if (state.settled) return;
|
|
646
|
+
commitTextInput();
|
|
647
|
+
commitMarkerInput();
|
|
648
|
+
state.settled = true;
|
|
649
|
+
drawing = null;
|
|
650
|
+
markerDrag = null;
|
|
651
|
+
hoverPt = null; // never burn the ghost preview into the exported PNG
|
|
652
|
+
redraw();
|
|
653
|
+
const cbs = state.callbacks || {};
|
|
654
|
+
const markers = collectMarkers();
|
|
655
|
+
canvas.toBlob((blob) => {
|
|
656
|
+
if (blob) cbs.onDone?.(blob, markers);
|
|
657
|
+
else cbs.onSkip?.(); // export failed — fall back to the original
|
|
658
|
+
WidgetManager.close('image-annotate');
|
|
659
|
+
}, 'image/png');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
663
|
+
// KEYBOARD
|
|
664
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
665
|
+
|
|
666
|
+
function attachKeyboard() {
|
|
667
|
+
detachKeyboard();
|
|
668
|
+
_keyHandler = (e) => {
|
|
669
|
+
if (!WidgetManager.isOpen('image-annotate')) return;
|
|
670
|
+
const inText = e.target.classList?.contains('ia-text-input');
|
|
671
|
+
const inMarker = e.target.classList?.contains('ia-marker-input');
|
|
672
|
+
if (e.key === 'Escape') {
|
|
673
|
+
e.preventDefault();
|
|
674
|
+
e.stopPropagation();
|
|
675
|
+
if (inText) cancelTextInput();
|
|
676
|
+
else if (inMarker) cancelMarkerInput();
|
|
677
|
+
else requestCancel();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (inText || inMarker) return;
|
|
681
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
682
|
+
if (mod && e.key.toLowerCase() === 'z') {
|
|
683
|
+
e.preventDefault();
|
|
684
|
+
e.stopPropagation();
|
|
685
|
+
if (e.shiftKey) redoOp(); else undoOp();
|
|
686
|
+
} else if (e.key === 'Enter') {
|
|
687
|
+
// Plain Enter accepts (attach annotated) — Cmd/Ctrl+Enter too.
|
|
688
|
+
// The floating text/marker inputs claim Enter themselves and
|
|
689
|
+
// stopPropagation, so this only fires with no input focused.
|
|
690
|
+
e.preventDefault();
|
|
691
|
+
e.stopPropagation();
|
|
692
|
+
finishDone();
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
// Capture phase so app-level shortcuts don't fire while the editor is up
|
|
696
|
+
document.addEventListener('keydown', _keyHandler, true);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function detachKeyboard() {
|
|
700
|
+
if (_keyHandler) {
|
|
701
|
+
document.removeEventListener('keydown', _keyHandler, true);
|
|
702
|
+
_keyHandler = null;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
707
|
+
// TOOLBAR
|
|
708
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
709
|
+
|
|
710
|
+
const TOOL_ICONS = {
|
|
711
|
+
marker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M10 9.5l2.5-2v9"/></svg>',
|
|
712
|
+
pen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>',
|
|
713
|
+
arrow: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="19" y2="5"/><polyline points="9 5 19 5 19 15"/></svg>',
|
|
714
|
+
rect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1"/></svg>',
|
|
715
|
+
ellipse: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>',
|
|
716
|
+
text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 7 5 4 19 4 19 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="9" y1="20" x2="15" y2="20"/></svg>',
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// Not a tool — used inside the marker comment box's delete button
|
|
720
|
+
const TRASH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
|
721
|
+
|
|
722
|
+
const UNDO_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>';
|
|
723
|
+
const REDO_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>';
|
|
724
|
+
|
|
725
|
+
function toolbarHtml() {
|
|
726
|
+
const T = S.widgets.image_annotate;
|
|
727
|
+
const toolTips = { marker: T.tool_marker, pen: T.tool_pen, arrow: T.tool_arrow, rect: T.tool_rect, ellipse: T.tool_ellipse, text: T.tool_text };
|
|
728
|
+
const tools = Object.keys(TOOL_ICONS).map(t =>
|
|
729
|
+
`<button class="ia-tool-btn" data-tool="${t}" data-tooltip="${toolTips[t]}">${TOOL_ICONS[t]}</button>`
|
|
730
|
+
).join('');
|
|
731
|
+
const colors = COLORS.map(c =>
|
|
732
|
+
`<button class="ia-color-btn" data-color="${c}" style="--ia-swatch: ${c}" data-tooltip="${T.color}"></button>`
|
|
733
|
+
).join('');
|
|
734
|
+
const strokeTips = { s: T.stroke_small, m: T.stroke_medium, l: T.stroke_large };
|
|
735
|
+
const strokes = ['s', 'm', 'l'].map(w =>
|
|
736
|
+
`<button class="ia-stroke-btn" data-stroke="${w}" data-tooltip="${strokeTips[w]}"><span class="ia-stroke-dot ia-stroke-${w}"></span></button>`
|
|
737
|
+
).join('');
|
|
738
|
+
|
|
739
|
+
return `
|
|
740
|
+
<div class="ia-toolbar">
|
|
741
|
+
<div class="ia-tool-group">${tools}</div>
|
|
742
|
+
<span class="ia-sep"></span>
|
|
743
|
+
<div class="ia-tool-group">${colors}</div>
|
|
744
|
+
<span class="ia-sep"></span>
|
|
745
|
+
<div class="ia-tool-group">${strokes}</div>
|
|
746
|
+
<span class="ia-sep"></span>
|
|
747
|
+
<div class="ia-tool-group">
|
|
748
|
+
<button class="ia-tool-btn ia-undo" data-tooltip="${withChords(T.undo)}">${UNDO_ICON}</button>
|
|
749
|
+
<button class="ia-tool-btn ia-redo" data-tooltip="${withChords(T.redo)}">${REDO_ICON}</button>
|
|
750
|
+
</div>
|
|
751
|
+
<span class="ia-spacer"></span>
|
|
752
|
+
<label class="ia-paste-toggle" data-tooltip="${T.paste_toggle_tip}">
|
|
753
|
+
<input type="checkbox" class="ia-paste-cb"${isAnnotateOnPasteEnabled() ? ' checked' : ''}>
|
|
754
|
+
<span>${T.paste_toggle}</span>
|
|
755
|
+
</label>
|
|
756
|
+
<div class="ia-tool-group ia-actions">
|
|
757
|
+
<button class="ia-btn ia-cancel">${T.cancel}</button>
|
|
758
|
+
<button class="ia-btn ia-skip">${T.skip}</button>
|
|
759
|
+
<button class="ia-btn ia-btn-primary ia-done">${T.done}</button>
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function updateToolbar() {
|
|
766
|
+
const root = document.querySelector('#widget-image-annotate .image-annotate-modal');
|
|
767
|
+
if (!root) return;
|
|
768
|
+
root.querySelectorAll('.ia-tool-btn[data-tool]').forEach(b =>
|
|
769
|
+
b.classList.toggle('active', b.dataset.tool === state.tool));
|
|
770
|
+
root.querySelectorAll('.ia-color-btn').forEach(b =>
|
|
771
|
+
b.classList.toggle('active', b.dataset.color === state.color));
|
|
772
|
+
root.querySelectorAll('.ia-stroke-btn').forEach(b =>
|
|
773
|
+
b.classList.toggle('active', b.dataset.stroke === state.stroke));
|
|
774
|
+
const undoBtn = root.querySelector('.ia-undo');
|
|
775
|
+
const redoBtn = root.querySelector('.ia-redo');
|
|
776
|
+
if (undoBtn) undoBtn.disabled = state.shapes.length === 0;
|
|
777
|
+
if (redoBtn) redoBtn.disabled = state.redoStack.length === 0;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
781
|
+
// WIDGET REGISTRATION
|
|
782
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
783
|
+
|
|
784
|
+
export function registerImageAnnotateWidget() {
|
|
785
|
+
WidgetManager.register('image-annotate', {
|
|
786
|
+
title: S.widgets.titles.image_annotate,
|
|
787
|
+
icon: 'image',
|
|
788
|
+
type: 'modal',
|
|
789
|
+
hideHeader: true,
|
|
790
|
+
closeOnBackdrop: false, // a stray click must not discard drawings
|
|
791
|
+
closeOnEscape: false, // own Escape handling (text input vs cancel)
|
|
792
|
+
maxWidth: '96vw',
|
|
793
|
+
maxHeight: '96vh',
|
|
794
|
+
scope: 'global',
|
|
795
|
+
sessionAware: false,
|
|
796
|
+
hiddenInPicker: true,
|
|
797
|
+
|
|
798
|
+
onClose() {
|
|
799
|
+
// Closed without an explicit outcome (safety net) → cancel
|
|
800
|
+
if (!state.settled) {
|
|
801
|
+
state.settled = true;
|
|
802
|
+
state.callbacks?.onCancel?.();
|
|
803
|
+
}
|
|
804
|
+
cancelTextInput();
|
|
805
|
+
_markerEditing = null;
|
|
806
|
+
const mi = currentMarkerInput();
|
|
807
|
+
if (mi) removeMarkerBox(mi);
|
|
808
|
+
if (_discardTimer) { clearTimeout(_discardTimer); _discardTimer = null; }
|
|
809
|
+
_discardArmed = false;
|
|
810
|
+
detachKeyboard();
|
|
811
|
+
drawing = null;
|
|
812
|
+
markerDrag = null;
|
|
813
|
+
hoverPt = null;
|
|
814
|
+
if (state.objectUrl) {
|
|
815
|
+
URL.revokeObjectURL(state.objectUrl);
|
|
816
|
+
state.objectUrl = null;
|
|
817
|
+
}
|
|
818
|
+
// Return focus to the chat input so typing can resume immediately.
|
|
819
|
+
window.app?.focusInput?.();
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
render(container) {
|
|
823
|
+
if (!state.img) return;
|
|
824
|
+
|
|
825
|
+
container.innerHTML = `
|
|
826
|
+
<div class="image-annotate-modal">
|
|
827
|
+
${toolbarHtml()}
|
|
828
|
+
<div class="ia-canvas-wrap">
|
|
829
|
+
<canvas class="ia-canvas"></canvas>
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
`;
|
|
833
|
+
|
|
834
|
+
canvas = container.querySelector('.ia-canvas');
|
|
835
|
+
const scale = Math.min(1, MAX_CANVAS_DIM / Math.max(state.img.naturalWidth, state.img.naturalHeight));
|
|
836
|
+
canvas.width = Math.max(1, Math.round(state.img.naturalWidth * scale));
|
|
837
|
+
canvas.height = Math.max(1, Math.round(state.img.naturalHeight * scale));
|
|
838
|
+
ctx2d = canvas.getContext('2d');
|
|
839
|
+
|
|
840
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
841
|
+
canvas.addEventListener('pointermove', onPointerMove);
|
|
842
|
+
canvas.addEventListener('pointerup', onPointerUp);
|
|
843
|
+
canvas.addEventListener('pointercancel', onPointerUp);
|
|
844
|
+
canvas.addEventListener('pointerleave', onPointerLeave);
|
|
845
|
+
// Canceling pointerdown should suppress mousedown's focus-steal per
|
|
846
|
+
// spec, but not every browser links them — block it directly too so
|
|
847
|
+
// the text tool's floating input keeps focus
|
|
848
|
+
canvas.addEventListener('mousedown', (e) => e.preventDefault());
|
|
849
|
+
|
|
850
|
+
const root = container.querySelector('.image-annotate-modal');
|
|
851
|
+
root.querySelector('.ia-toolbar').addEventListener('click', (e) => {
|
|
852
|
+
const btn = e.target.closest('button');
|
|
853
|
+
if (!btn) return;
|
|
854
|
+
// Any action other than confirming the discard resumes editing
|
|
855
|
+
if (!btn.classList.contains('ia-cancel')) disarmDiscard();
|
|
856
|
+
if (btn.dataset.tool) { state.tool = btn.dataset.tool; hoverPt = null; redraw(); }
|
|
857
|
+
else if (btn.dataset.color) state.color = btn.dataset.color;
|
|
858
|
+
else if (btn.dataset.stroke) state.stroke = btn.dataset.stroke;
|
|
859
|
+
else if (btn.classList.contains('ia-undo')) return undoOp();
|
|
860
|
+
else if (btn.classList.contains('ia-redo')) return redoOp();
|
|
861
|
+
else if (btn.classList.contains('ia-cancel')) return requestCancel();
|
|
862
|
+
else if (btn.classList.contains('ia-skip')) return settle('skip');
|
|
863
|
+
else if (btn.classList.contains('ia-done')) return finishDone();
|
|
864
|
+
updateToolbar();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Persist the "open editor on paste" setting straight from the toolbar
|
|
868
|
+
root.querySelector('.ia-paste-cb')?.addEventListener('change', (e) => {
|
|
869
|
+
configState.setAnnotateOnPaste(e.target.checked);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
redraw();
|
|
873
|
+
updateToolbar();
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
879
|
+
// PUBLIC API
|
|
880
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Open the annotation editor for an image blob/file.
|
|
884
|
+
*
|
|
885
|
+
* @param {Blob|File} blob - The image to annotate
|
|
886
|
+
* @param {Object} callbacks
|
|
887
|
+
* @param {Function} callbacks.onDone - (pngBlob, markers) → user finished
|
|
888
|
+
* annotating; markers is [{n, note}] for badges with a comment, meant
|
|
889
|
+
* for the Comments Stash
|
|
890
|
+
* @param {Function} callbacks.onSkip - () → attach the original untouched
|
|
891
|
+
* @param {Function} callbacks.onCancel - () → discard entirely
|
|
892
|
+
*/
|
|
893
|
+
export async function openImageAnnotator(blob, callbacks = {}) {
|
|
894
|
+
const url = URL.createObjectURL(blob);
|
|
895
|
+
const img = new Image();
|
|
896
|
+
try {
|
|
897
|
+
await new Promise((resolve, reject) => {
|
|
898
|
+
img.onload = resolve;
|
|
899
|
+
img.onerror = reject;
|
|
900
|
+
img.src = url;
|
|
901
|
+
});
|
|
902
|
+
} catch {
|
|
903
|
+
// Undecodable image — let the normal upload path deal with it
|
|
904
|
+
URL.revokeObjectURL(url);
|
|
905
|
+
callbacks.onSkip?.();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
state.img = img;
|
|
910
|
+
state.objectUrl = url;
|
|
911
|
+
state.fileName = blob.name || null;
|
|
912
|
+
state.shapes = [];
|
|
913
|
+
state.redoStack = [];
|
|
914
|
+
state.callbacks = callbacks;
|
|
915
|
+
state.settled = false;
|
|
916
|
+
_markerEditing = null;
|
|
917
|
+
markerDrag = null;
|
|
918
|
+
hoverPt = null;
|
|
919
|
+
_discardArmed = false;
|
|
920
|
+
if (_discardTimer) { clearTimeout(_discardTimer); _discardTimer = null; }
|
|
921
|
+
|
|
922
|
+
WidgetManager.open('image-annotate');
|
|
923
|
+
// render() only runs on first creation — force a rebuild for this image
|
|
924
|
+
WidgetManager.update('image-annotate');
|
|
925
|
+
attachKeyboard();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function isImageAnnotatorOpen() {
|
|
929
|
+
return WidgetManager.isOpen('image-annotate');
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Handle ESC while the annotator is open. The app's global shortcut handler
|
|
934
|
+
* claims Escape (stopImmediatePropagation) before this widget's own capture
|
|
935
|
+
* listener can run, so app.handleEscape() routes here to keep the
|
|
936
|
+
* dirty-aware discard confirm working. Cancels an open text/marker input
|
|
937
|
+
* first; otherwise runs the armed-cancel. Returns true if it handled the key.
|
|
938
|
+
*/
|
|
939
|
+
export function handleAnnotatorEscape() {
|
|
940
|
+
if (!isImageAnnotatorOpen()) return false;
|
|
941
|
+
const el = document.activeElement;
|
|
942
|
+
if (el?.classList?.contains('ia-text-input')) { cancelTextInput(); return true; }
|
|
943
|
+
if (el?.classList?.contains('ia-marker-input')) { cancelMarkerInput(); return true; }
|
|
944
|
+
requestCancel();
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export const ImageAnnotateWidget = {
|
|
949
|
+
open: openImageAnnotator,
|
|
950
|
+
isOpen: isImageAnnotatorOpen,
|
|
951
|
+
};
|