Merge current dev and refresh PR #6741 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:28 +00:00
295 changed files with 24233 additions and 1587 deletions
+50 -2
View File
@@ -82,6 +82,28 @@
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
themes can override without touching the goal CSS. */
--accent-warm: #d19a66;
/* Device safe areas (notch, Dynamic Island, home indicator) */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
/* Full-height sheets start clear of the status-bar blur; flush when there is no inset */
--sheet-top: calc(var(--safe-top) + min(var(--safe-top), 24px));
--app-height: 100dvh;
}
/* The on-screen keyboard covers the home indicator */
:root.keyboard-open { --safe-bottom: 0px; }
/* iOS standalone web apps report dvh (and the viewport) short by the status bar
height; lvh still matches the screen. Body becomes the containing block for
fixed elements so overlays reach the real bottom edge as well. Containment
stops body's background reaching the canvas, so html paints it instead. */
@media (display-mode: standalone) {
@supports (-webkit-touch-callout: none) {
:root { --app-height: 100lvh; }
html { height: var(--app-height); background-color: var(--bg); }
body { contain: layout; overflow: clip; }
}
}
:root.light {
@@ -121,20 +143,46 @@ body {
font-family: var(--font-family, 'Fira Code', monospace);
display: flex;
height: 100%;
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
height: var(--app-height);
padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left);
overflow: hidden;
}
/* iOS keeps the full height when the keyboard opens; size the shell to what is visible */
:root.keyboard-open body { height: var(--keyboard-viewport-height, var(--app-height)); }
/* Self-hosted Fira Code font */
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text
tool renders distinct, expected faces on every device — phones (Android/iOS)
do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously
collapsed every font choice onto the same system fallback. */
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Bungee'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Bungee-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/InstrumentSerif-Regular.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
+53 -23
View File
@@ -562,8 +562,8 @@ body.bg-pattern-ascii-fireflies {
/* Fixed hamburger — always visible, toggles sidebar */
.hamburger-btn {
position: fixed;
top: 12px;
left: 9px;
top: calc(12px + var(--safe-top));
left: calc(9px + var(--safe-left));
right: auto;
z-index: 210;
width: 30px;
@@ -583,7 +583,7 @@ body.bg-pattern-ascii-fireflies {
}
body.hamburger-right .hamburger-btn {
left: auto;
right: 9px;
right: calc(9px + var(--safe-right));
}
.mobile-new-chat-btn {
display: none;
@@ -655,6 +655,25 @@ body.bg-pattern-ascii-fireflies {
border-right: none;
border-left: 1px solid var(--border);
}
/* Edge columns bleed into the safe-area strips instead of leaving bare background */
@media (min-width: 769px) {
.sidebar:not(.hidden) {
margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: var(--safe-bottom);
}
.sidebar:not(.hidden):not(.right-side) {
margin-left: calc(-1 * var(--safe-left));
padding-left: var(--safe-left);
}
.sidebar.right-side:not(.hidden) {
margin-right: calc(-1 * var(--safe-right));
padding-right: var(--safe-right);
}
.icon-rail {
margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: calc(8px + var(--safe-bottom));
}
}
.icon-rail-divider {
width: 24px;
height: 1px;
@@ -4800,7 +4819,7 @@ body.bg-pattern-ascii-fireflies {
.toast {
position:fixed;
top: 16px; right: 16px;
top: calc(16px + var(--safe-top)); right: calc(16px + var(--safe-right));
left: auto; bottom: auto;
background:var(--panel); color:var(--fg);
border:1px solid color-mix(in srgb, var(--accent) 30%, transparent);
@@ -4984,8 +5003,8 @@ body.bg-pattern-ascii-fireflies {
.toast.toast-panel-offset.exiting { transform: translateX(-120%); }
@media (max-width: 768px) {
.toast {
top: 12px;
right: 12px;
top: calc(12px + var(--safe-top));
right: calc(12px + var(--safe-right));
max-width: calc(100vw - 24px);
/* Receive touches so the swipe-to-dismiss gesture works (desktop keeps
pointer-events:none so the toast never blocks clicks). Horizontal pan
@@ -5109,6 +5128,7 @@ body.bg-pattern-ascii-fireflies {
--sidebar-bg. */
background: var(--sidebar-bg, var(--panel)) !important;
backdrop-filter: none !important;
padding: var(--safe-top) 0 var(--safe-bottom) var(--safe-left);
}
.sidebar.hidden {
width: 80% !important;
@@ -5122,6 +5142,8 @@ body.bg-pattern-ascii-fireflies {
.sidebar.right-side {
left: auto; right: 0;
box-shadow: -4px 0 20px rgba(0,0,0,0.5);
padding-left: 0;
padding-right: var(--safe-right);
}
html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar {
@@ -5315,9 +5337,9 @@ body.bg-pattern-ascii-fireflies {
.hamburger-btn {
width: 44px;
height: 44px;
top: 6px;
top: calc(6px + var(--safe-top));
left: auto !important;
right: 4px !important;
right: calc(4px + var(--safe-right)) !important;
-webkit-tap-highlight-color: transparent;
}
.hamburger-btn:hover,
@@ -5330,8 +5352,8 @@ body.bg-pattern-ascii-fireflies {
.mobile-new-chat-btn {
display: flex;
position: fixed;
top: 12px;
left: 8px;
top: calc(12px + var(--safe-top));
left: calc(8px + var(--safe-left));
z-index: 210;
width: 32px;
height: 32px;
@@ -5400,10 +5422,6 @@ body.bg-pattern-ascii-fireflies {
padding: 6px !important;
border-radius: 12px !important;
}
/* Safe area padding for notched devices */
.chat-input-bar {
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}
/* Mode toggle — larger touch targets */
.mode-toggle {
height: 34px;
@@ -5447,15 +5465,15 @@ body.bg-pattern-ascii-fireflies {
URL-bar show/hide. Order matters: later same-specificity rule
wins, so dvh must come after vh. */
max-height: 100vh !important;
max-height: 100dvh !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
height: 100vh;
height: 100dvh;
height: calc(var(--app-height) - var(--sheet-top));
border-radius: 14px 14px 0 0 !important;
border: none !important;
border-top: 1px solid var(--border) !important;
box-shadow: none !important;
padding: 6px !important;
padding-bottom: env(safe-area-inset-bottom, 6px) !important;
padding-bottom: var(--safe-bottom) !important;
margin: 0 !important;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
@@ -5536,7 +5554,7 @@ body.bg-pattern-ascii-fireflies {
(against the flex-sized grid) as a definite height, so grid/flex
children never filled. An explicit px height is unambiguous. */
margin: 0 !important;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px)) !important;
padding: 8px 10px calc(8px + var(--safe-bottom)) !important;
background: var(--bg) !important;
/* Flex column. JS (_fillSkillCardHeight) sets EXPLICIT px heights on
the preview + <pre>; in a flex column an explicit-height item
@@ -5615,7 +5633,7 @@ body.bg-pattern-ascii-fireflies {
/* Small bottom padding now that the load-more no longer steals space —
the action bar sits low, just clearing the home-indicator safe area. */
#doclib-modal [data-doclib-panel="documents"] .doclib-card.doclib-card-expanded .doclib-card-preview {
padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)) !important;
padding-bottom: calc(18px + var(--safe-bottom)) !important;
}
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .doclib-card-pdf-frame,
@@ -6127,6 +6145,7 @@ body.bg-pattern-ascii-fireflies {
z-index:250;
backdrop-filter:none;
pointer-events:none;
padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left);
}
/* Cookbook always sits above Gallery so the "Serve a model in
Cookbook…" flow (and any other "open Cookbook from inside another
@@ -6160,6 +6179,17 @@ body.bg-pattern-ascii-fireflies {
centered window glides instead of jumping when the nav toggles. */
transition: left 0.25s ease, width 0.25s ease;
}
/* A right-side nav reserves its width on the right instead */
body.hamburger-right :is(#calendar-modal, #gallery-modal, #tasks-modal, #memory-modal, #doclib-modal,
#compare-model-overlay, #research-overlay, #theme-modal, #settings-modal, #email-lib-modal) {
left: 0;
}
/* Windows never grow past the area between the nav and the safe areas */
:is(#calendar-modal, #gallery-modal, #tasks-modal, #memory-modal, #doclib-modal,
#compare-model-overlay, #research-overlay, #theme-modal, #settings-modal, #email-lib-modal)
> :is(.modal-content, .memory-modal-content, .settings-modal-content, .doclib-modal-content) {
max-width: calc(100vw - var(--safe-left) - var(--safe-right) - var(--icon-rail-w, 48px) - var(--sidebar-w, 0px));
}
}
.modal-content {
background:var(--panel);
@@ -6689,8 +6719,8 @@ body.bg-pattern-ascii-fireflies {
/* Agent indicator */
#agent-indicator {
position: fixed;
top: 20px;
right: 20px;
top: calc(20px + var(--safe-top));
right: calc(20px + var(--safe-right));
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
@@ -7016,7 +7046,7 @@ body.bg-pattern-ascii-fireflies {
#compare-model-overlay .modal-content {
display: flex;
flex-direction: column;
max-height: min(720px, calc(100dvh - 48px));
max-height: min(720px, calc(var(--app-height) - 48px));
overflow: hidden;
min-height: 180px;
}
@@ -7433,7 +7463,7 @@ body.bg-pattern-ascii-fireflies {
#recording-indicator {
position: fixed;
top: 10px;
top: calc(10px + var(--safe-top));
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
+11 -9
View File
@@ -172,9 +172,8 @@
position:fixed; height:100% = the layout viewport, whose bottom sits
UNDER Firefox Android's bottom URL bar — so flex-end parked the sheet
(and its footer / Start button) beneath the bar. dvh excludes the bar
so the footer lands above it. Extra safe-area pad for iOS home bar. */
height: 100dvh;
padding-bottom: env(safe-area-inset-bottom, 0px);
so the footer lands above it. */
height: var(--app-height);
}
/* Confirm dialog stays centered, not a bottom sheet */
#styled-confirm-overlay {
@@ -204,7 +203,7 @@
border-radius: 14px 14px 0 0;
border: none;
border-top: 1px solid var(--border);
padding: 6px 12px 12px;
padding: 6px 12px calc(12px + var(--safe-bottom));
animation: sheet-enter 0.2s ease-out forwards;
overflow-y: hidden !important;
}
@@ -252,12 +251,12 @@
#email-lib-modal .modal-content,
#doclib-modal .doclib-modal-content {
max-height: 90vh !important;
max-height: 90dvh !important;
max-height: calc(var(--app-height) * 0.9) !important;
height: auto !important;
/* Reserve the iOS home-indicator / bottom-bar strip so an expanded
skill card's footer (Delete / Edit / Run) isn't hidden under it.
dvh already excludes the URL bar; this handles the safe area. */
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
padding-bottom: var(--safe-bottom) !important;
}
/* Grab handle pill on the non-standard content classes too. Memory's
desktop rule defines a radial-glow ::before later in the file —
@@ -305,7 +304,7 @@
visible viewport on Chrome/Safari mobile (URL bar covered the
action buttons row). */
max-height: 90vh !important;
max-height: 90dvh !important;
max-height: calc(var(--app-height) * 0.9) !important;
height: auto !important;
}
/* Anchor those modals to the top so the full height is usable */
@@ -314,8 +313,11 @@
#email-lib-modal,
#doclib-modal,
#gallery-modal,
#tasks-modal {
padding-top: 0 !important;
#tasks-modal,
#memory-modal,
#settings-modal {
padding-top: var(--sheet-top) !important;
padding-bottom: 0 !important;
align-items: stretch !important;
}
/* Deep Research already gets the swipe grab-handle pill from the
+3 -3
View File
@@ -410,8 +410,8 @@ button.hamburger {
/* Agent indicator */
#agent-indicator {
position: fixed;
top: 20px;
right: 20px;
top: calc(20px + var(--safe-top));
right: calc(20px + var(--safe-right));
background: var(--sidebar-bg, var(--panel));
color: var(--fg);
border: 1px solid var(--border);
@@ -894,7 +894,7 @@ button.hamburger {
/* Recording indicator */
#recording-indicator {
position: fixed;
top: 10px;
top: calc(10px + var(--safe-top));
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
+3 -1
View File
@@ -2666,11 +2666,13 @@ mark.doc-find-mark.current {
render it as a narrow side pane ("sidebar") on a phone. */
position: fixed !important;
inset: 0 !important;
top: 0 !important; right: 0 !important; bottom: 0 !important; left: 0 !important;
top: var(--sheet-top) !important; right: 0 !important; bottom: 0 !important; left: 0 !important;
height: auto !important;
max-width: 100% !important;
width: 100% !important;
flex: none !important;
z-index: 170;
padding-bottom: var(--safe-bottom);
/* Stroke the top edge so the rounded corners read as a curved sheet edge. */
border: 1px solid var(--border);
border-bottom: none;
+16 -10
View File
@@ -1181,8 +1181,8 @@ span[id$="-logo"] svg:not([width]) {
opacity: 0;
transform-origin: center center;
position: absolute;
right: calc(16px + env(safe-area-inset-right, 0px));
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
right: calc(16px + var(--safe-right));
bottom: calc(18px + var(--safe-bottom));
height: var(--fab-size);
min-width: var(--fab-size);
padding: 0 20px 0 18px;
@@ -1298,17 +1298,23 @@ span[id$="-logo"] svg:not([width]) {
instead of becoming a side panel. */
#doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content {
position: fixed !important;
top: 0 !important;
left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: 0 !important;
bottom: 0 !important;
top: var(--safe-top) !important;
left: calc(var(--safe-left) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: var(--safe-right) !important;
bottom: var(--safe-bottom) !important;
width: auto !important;
max-width: none !important;
height: 100vh !important;
max-height: 100vh !important;
height: auto !important;
max-height: none !important;
border-radius: 0 !important;
transform: none !important;
}
@media (min-width: 769px) {
body.hamburger-right #doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content {
left: var(--safe-left) !important;
right: calc(var(--safe-right) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
}
}
#doclib-modal.doclib-fullscreen .modal-header,
#doclib-modal.doclib-fullscreen .lib-tabs,
#doclib-modal.modal-right-docked .modal-header,
@@ -1415,10 +1421,10 @@ span[id$="-logo"] svg:not([width]) {
panes reserve room with margins so left + right docks can be active at the
same time without skewing the entire body box. */
body.right-dock-active {
padding-right: 0;
padding-right: var(--safe-right);
}
body.left-dock-active {
padding-left: 0;
padding-left: var(--safe-left);
}
body.left-dock-active:not(.email-doc-split-active) .chat-container {
margin-left: var(--left-dock-w, 0px);
+25 -19
View File
@@ -447,13 +447,11 @@
edges and let the inner body absorb the remaining height. */
body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .modal-content {
position: fixed !important;
inset: 0 !important;
left: 0 !important;
top: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
inset: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left) !important;
width: auto !important;
max-width: none !important;
height: auto !important;
max-height: none !important;
border-radius: 0 !important;
transform: none !important;
display: flex !important;
@@ -472,17 +470,23 @@ body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .e
one of the two CSS vars is 0 at any given time, set by init.js). */
body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
position: fixed !important;
top: 0 !important;
left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: 0 !important;
bottom: 0 !important;
top: var(--safe-top) !important;
left: calc(var(--safe-left) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: var(--safe-right) !important;
bottom: var(--safe-bottom) !important;
width: auto !important;
max-width: none !important;
height: 100vh !important;
max-height: 100vh !important;
height: auto !important;
max-height: none !important;
border-radius: 0 !important;
transform: none !important;
}
@media (min-width: 769px) {
body.hamburger-right:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
left: var(--safe-left) !important;
right: calc(var(--safe-right) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
}
}
/* Mobile: the /email route adds .email-lib-fullscreen, but the desktop full-
bleed rule above squares off the corners and offsets the email by the icon
rail. On phones the email should be the normal bottom-sheet — full width,
@@ -490,12 +494,14 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
so it wins on mobile. */
@media (max-width: 768px) {
body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
left: 0 !important;
right: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
height: 100dvh !important;
max-height: 100dvh !important;
top: var(--sheet-top) !important;
left: var(--safe-left) !important;
right: var(--safe-right) !important;
bottom: 0 !important;
width: auto !important;
max-width: none !important;
height: auto !important;
max-height: none !important;
border-radius: 14px 14px 0 0 !important;
border-top: 1px solid var(--border) !important;
}
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -4638,13 +4638,14 @@ body.notes-view .chat-container { flex: 1; min-width: 0; }
@media (max-width: 768px) {
body.notes-view .notes-pane {
position: fixed;
inset: 0;
inset: var(--sheet-top) 0 0;
max-width: 100%;
width: 100% !important;
/* Desktop sets height: min(80vh, 820px) which wins over inset:0's bottom:0
and leaves a gap at the bottom of the viewport. Force full height here. */
height: 100dvh !important;
max-height: 100dvh !important;
height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
padding-bottom: var(--safe-bottom);
z-index: 170;
/* Bottom-sheet treatment like the document editor: stroked, rounded top,
slides up from the bottom. */
+2 -2
View File
@@ -1439,7 +1439,7 @@ body.theme-frosted .modal {
/* Bottom-sheet: 90dvh tall (a touch smaller than full-screen) anchored to the
bottom, so there's a small gap at the top and it reads as a sheet. The body
still scrolls within the bounded height. */
#research-overlay { align-items: flex-end !important; justify-content: stretch !important; }
#research-overlay { align-items: flex-end !important; justify-content: stretch !important; padding-bottom: 0 !important; }
#research-pane {
height: 90dvh !important;
max-height: 90dvh !important;
@@ -1451,7 +1451,7 @@ body.theme-frosted .modal {
browser's bottom toolbar / safe area and can always be scrolled into view
(otherwise the form fits the viewport and there's no room to reach them). */
#research-pane .research-pane-body {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px) !important;
padding-bottom: calc(var(--safe-bottom) + 40px) !important;
touch-action: pan-y;
overscroll-behavior: contain;
}
+4 -3
View File
@@ -1287,7 +1287,8 @@ body.ge-editor-open .ge-setup-btn:hover {
}
/* The options-bar checkbox overrode the shared dot with a square + a drawn
::after tick; force it back to the app's 14px circular dot (part 1). */
::after tick; force it back to the app's 14px circular dot, filled with the
highlight (accent) colour when checked (part 1). */
body.ge-editor-open .ge-opt input[type="checkbox"],
body.ge-editor-open .ge-opt input[type="radio"] {
border-radius: 50%;
@@ -1295,8 +1296,8 @@ body.ge-editor-open .ge-opt input[type="radio"] {
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked,
body.ge-editor-open .ge-opt input[type="radio"]:checked {
background: var(--fg);
border-color: var(--fg);
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked::after {
content: none;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

+7
View File
@@ -16,6 +16,13 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
(function(){
var ua = navigator.userAgent || '';
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var vp = document.querySelector('meta[name="viewport"]');
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
})();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
-7
View File
@@ -6929,13 +6929,6 @@ import { invalidateSettings } from './appConfig.js';
}
});
}
// Smooth viewport resize when keyboard opens/closes
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
});
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
}
}
// If the browser discarded and restored this tab, reload the current session
+33 -1
View File
@@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) {
onReorder: options.onReorder || null,
handleSelector: options.handleSelector || null,
excludeSelector: options.excludeSelector || null,
ignoreSelector: options.ignoreSelector || null,
// When > 0 the pointer must travel this many pixels before the drag
// starts, so rows that also handle plain clicks stay clickable.
threshold: Math.max(0, Number(options.threshold) || 0),
storageKey: options.storageKey || null,
};
let draggedEl = null;
let pending = null;
let placeholder = null;
let offsetY = 0;
let items = [];
@@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) {
draggedEl.style.top = snapTop + 'px';
setTimeout(() => {
// The list may have been re-rendered mid-drag (e.g. dropping a layer
// effect copies it and rebuilds the panel), detaching both nodes.
if (!placeholder.parentNode || !draggedEl.isConnected) {
placeholder.remove();
return;
}
placeholder.parentNode.insertBefore(draggedEl, placeholder);
placeholder.remove();
draggedEl.classList.remove('dragging');
@@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) {
function onMouseDown(e) {
if (e.button !== 0) return;
if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
if (config.ignoreSelector && e.target.closest(config.ignoreSelector)) return;
const item = e.target.closest(itemSelector);
if (!item || !container.contains(item)) return;
if (config.excludeSelector && item.matches(config.excludeSelector)) return;
if (config.threshold > 0) {
// Defer the lift until the pointer actually moves, so a plain click
// (select layer, open the rename field) is left untouched.
pending = { item, startY: e.clientY };
e.preventDefault();
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault();
e.stopPropagation();
startDrag(e.clientY, item);
@@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) {
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) { moveDrag(e.clientY); }
function onMouseMove(e) {
if (pending) {
if (Math.abs(e.clientY - pending.startY) < config.threshold) return;
const { item, startY } = pending;
pending = null;
e.preventDefault();
startDrag(startY, item);
}
moveDrag(e.clientY);
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
pending = null;
endDrag();
}
+4 -1
View File
@@ -215,7 +215,10 @@ export function wireAIToolsMisc({
function addEmptyLayer() {
saveState('Add layer');
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
state.layers.push(layer);
// Photoshop inserts the new layer directly above the selected one.
const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);
const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;
state.layers.splice(insertIndex, 0, layer);
state.activeLayerId = layer.id;
renderLayerPanel();
composite();
+301 -171
View File
@@ -19,6 +19,28 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
</div>
</div>
<div id="ge-liquify-section" style="display:none;">
<div class="ge-section-title">Liquify</div>
<div class="ge-control-row">
<label for="ge-liquify-mode">Mode</label>
<select id="ge-liquify-mode" class="ge-ctl-select">
<option value="push">Push</option>
<option value="twirl">Twirl</option>
<option value="pucker">Pucker</option>
<option value="bloat">Bloat</option>
<option value="smooth">Smooth</option>
</select>
</div>
<div class="ge-control-row ge-eraser-row">
<label><span id="ge-liquify-size-label">120px</span></label>
<input type="range" id="ge-liquify-size" min="10" max="600" value="120" />
</div>
<div class="ge-control-row ge-eraser-row">
<label>Strength <span id="ge-liquify-strength-label">50%</span></label>
<input type="range" id="ge-liquify-strength" min="1" max="100" value="50" />
</div>
<div class="ge-control-row ge-liquify-hint">Drag on the canvas to warp pixels. One undo step per stroke.</div>
</div>
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
<div class="ge-layer-geometry-grid">
@@ -33,31 +55,105 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Color</label>
<input type="color" class="ge-color-picker" value="${color}" />
</div>
<div class="ge-control-row">
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
<div class="ge-control-row ge-brush-size-row">
<label><span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
<span class="ge-brush-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-brush-more-btn" id="ge-brush-more" aria-haspopup="true" aria-expanded="false" title="More brush options (spacing, smoothing, blend, opacity, pen pressure)" aria-label="More brush options">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="ge-brush-more-menu" id="ge-brush-more-menu" hidden>
<div class="ge-section-title">Stroke</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-spacing" aria-hidden="true"></span>
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-smoothing" aria-hidden="true"></span>
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
</div>
<div class="ge-control-row">
<label for="ge-brush-blend">Blend</label>
<select id="ge-brush-blend">
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
<option value="screen">Screen</option><option value="overlay">Overlay</option>
<option value="soft-light">Soft Light</option><option value="color">Color</option>
</select>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="brush">
<div class="ge-section-title" id="ge-stroke-title-brush">Brush</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-opacity-row">
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-flow-row">
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
<div class="ge-section-title">Eraser</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="clone">
<div class="ge-section-title" id="ge-stroke-title-clone">Clone</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="smudge">
<div class="ge-section-title">Smudge</div>
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row">
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
</div>
</div>
<div class="ge-control-row ge-brush-pressure-row">
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
</div>
</div>
</span>
</div>
<div class="ge-section-title">Stroke</div>
<div class="ge-control-row ge-eraser-row">
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
</div>
<div class="ge-control-row ge-eraser-row">
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
</div>
<div class="ge-control-row">
<label for="ge-brush-blend">Blend</label>
<select id="ge-brush-blend">
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
<option value="screen">Screen</option><option value="overlay">Overlay</option>
<option value="soft-light">Soft Light</option><option value="color">Color</option>
</select>
</div>
<div class="ge-control-row ge-brush-pressure-row">
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
<div class="ge-control-row ge-eraser-row" id="ge-dodgeburn-intensity-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-dodgeburn-preview" aria-hidden="true"></span>
<label title="Intensity">Int <span id="ge-dodgeburn-intensity-label">25%</span></label>
<input type="range" id="ge-dodgeburn-intensity" min="0" max="100" value="25" title="How strongly Dodge lightens or Burn darkens each dab." />
</div>
<div class="ge-control-row ge-brush-preset-row">
<select id="ge-brush-preset" aria-label="Brush preset"></select>
@@ -113,11 +209,14 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '1:1' ? ' active' : ''}" data-crop-preset="1:1" title="Square (1:1)">1:1</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '4:5' ? ' active' : ''}" data-crop-preset="4:5" title="Portrait (4:5)">4:5</button>
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '16:9' ? ' active' : ''}" data-crop-preset="16:9" title="Widescreen (16:9)">16:9</button>
<label class="ge-crop-custom" title="Type any ratio, e.g. 3:2 or 21:9">
<input type="text" id="ge-crop-custom-ratio" inputmode="numeric" placeholder="W:H" aria-label="Custom crop ratio" spellcheck="false" autocomplete="off" />
</label>
</div>
<p class="ge-section-hint">Pick a ratio, then drag on the canvas. Press Enter to apply.</p>
</div>
<div class="ge-text-section" id="ge-text-section" style="display:none;">
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
<textarea id="ge-text-content" rows="2" placeholder="Type text..." aria-label="Text content"></textarea>
<div class="ge-text-grid">
<label class="ge-text-field ge-text-font-field"><span>Font</span>
<select id="ge-text-font">
@@ -127,6 +226,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<option value="Lobster">Lobster</option>
<option value="Bebas Neue">Bebas Neue</option>
<option value="Roboto Mono">Roboto Mono</option>
<option value="Space Grotesk">Space Grotesk</option>
<option value="Fraunces">Fraunces</option>
<option value="Syne">Syne</option>
<option value="Bungee">Bungee</option>
<option value="Instrument Serif">Instrument Serif</option>
<option value="Georgia">Georgia</option>
<option value="Courier New">Courier New</option>
</select>
@@ -144,6 +248,7 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">&#8596;</button>
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">&#8677;</button>
</div>
<div class="ge-opt-more" id="ge-text-more" hidden>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Line</span>
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
@@ -200,6 +305,8 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label class="ge-text-field ge-text-inline-check"><span>Kerning</span><input type="checkbox" id="ge-text-kerning" checked /></label>
<label class="ge-text-field ge-text-inline-check"><span>Ligatures</span><input type="checkbox" id="ge-text-ligatures" checked /></label>
</div>
</div>
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-text-more-toggle" aria-expanded="false" aria-controls="ge-text-more">More</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
</div>
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
@@ -214,6 +321,7 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
</div>
<div class="ge-opt-more" id="ge-shape-more" hidden>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill type</span>
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
@@ -234,6 +342,8 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
</div>
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
</div>
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-shape-more-toggle" aria-expanded="false" aria-controls="ge-shape-more">More</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
</div>
<div class="ge-path-section" id="ge-path-section" style="display:none;">
@@ -264,14 +374,13 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<div class="ge-marquee-constraint-row">
<label for="ge-marquee-constraint">Style</label>
<select id="ge-marquee-constraint" title="Marquee sizing style">
<option value="free">Free</option>
<option value="ratio">Fixed ratio</option>
<option value="size">Fixed size</option>
</select>
<div class="ge-marquee-dimensions" hidden>
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
@@ -281,16 +390,18 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
</button>
</div>
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
<button class="ge-btn ge-btn-sm" id="ge-marquee-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection"><svg width="14" height="14" 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="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>Invert</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>To Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" 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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
</div>
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
@@ -298,61 +409,62 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
</div>
<div class="ge-control-row" style="margin-top:4px;">
<button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
Invert
<span class="ge-lasso-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-lasso-more-btn" id="ge-lasso-more" aria-haspopup="true" aria-expanded="false" aria-label="More lasso options (feather, edge stroke, magnetic)" title="More lasso options (feather, edge stroke, magnetic)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Delete
<div class="ge-lasso-more-menu" id="ge-lasso-more-menu" hidden>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
</div>
<div class="ge-control-row">
<button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
</div>
<p class="ge-lasso-tip">Draw a freehand selection. Esc to cancel.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-lasso-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to new layer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
<button class="ge-btn ge-btn-sm" id="ge-lasso-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to inpaint mask">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)"><svg width="14" height="14" 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="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>Invert</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Erase selected pixels"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to a new layer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Add selection to mask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>To Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" 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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
</div>
<div class="ge-wand-section" id="ge-wand-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
@@ -365,41 +477,38 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
<input type="range" id="ge-wand-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-wand-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-clear" title="Clear the selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Clear
<button class="ge-btn ge-btn-sm" id="ge-wand-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<svg width="14" height="14" 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="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to the inpaint mask">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" 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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
</div>
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
@@ -408,46 +517,120 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
Find
</button>
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<span class="ge-wand-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-sam-more" aria-haspopup="true" aria-expanded="false" aria-label="More SAM options (help)" title="More SAM options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<div class="ge-wand-more-menu" id="ge-sam-more-menu" hidden>
<p class="ge-wand-tip">Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask. Shift adds, Alt subtracts, Esc clears.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Clear
<button class="ge-btn ge-btn-sm" id="ge-sam-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="14" height="14" 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="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-copy" title="Copy selection to a new layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" 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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
</div>
<div class="ge-quickselect-section" id="ge-quickselect-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>QUICK SELECT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Quick Select help" title="Brush over a region to grow the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the brush combines with the current selection. Shift / Alt held during a stroke override this.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<label style="font-size:10px;opacity:.7;white-space:nowrap;">Brush</label>
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" style="flex:1 1 auto;min-width:0;" />
<span class="ge-adj-value" id="ge-quickselect-size-value">24</span>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-quickselect-size-preview" aria-hidden="true"></span>
<label>Brush <span id="ge-quickselect-size-label">24</span></label>
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" />
</div>
<span class="ge-wand-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-quickselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Quick Select options (help)" title="More Quick Select options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<div class="ge-wand-more-menu" id="ge-quickselect-more-menu" hidden>
<p class="ge-wand-tip">Paint over a region; the brush grows the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-quickselect-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-quickselect-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="14" height="14" 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="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-copy" title="Copy selection to a new layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" 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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Paint over a region; the brush grows the selection to similar nearby pixels.</p>
</div>
<div class="ge-objectselect-section" id="ge-objectselect-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>OBJECT SELECT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Object Select help" title="Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the object selection combines with the current selection. Shift / Alt held during a stroke override this.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
</div>
<span class="ge-wand-more-wrap">
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-objectselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Object Select options (help)" title="More Object Select options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
<div class="ge-wand-more-menu" id="ge-objectselect-more-menu" hidden>
<p class="ge-wand-tip">Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude. Shift adds, Esc clears.</p>
</div>
</span>
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-objectselect-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-objectselect-clear" title="Clear selection" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
<svg width="14" height="14" 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="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
Invert
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-delete" title="Erase selected pixels">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-copy" title="Copy selection to a new layer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-mask" title="Add selection to mask">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" 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" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a rough stroke over an object; SAM selects its boundary.</p>
</div>
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
<div class="ge-inpaint-popover-head" data-inpaint-drag>
@@ -550,72 +733,19 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
</p>
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
<span id="ge-clone-source-label">No source selected</span>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button>
<button type="button" class="ge-btn ge-btn-sm" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div>
<div class="ge-control-row">
<label for="ge-clone-sample-mode">Sample</label>
<select id="ge-clone-sample-mode">
<select id="ge-clone-sample-mode" class="ge-ctl-select">
<option value="active-layer">Active layer</option>
<option value="composite">All visible layers</option>
</select>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
</div>
</div>
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
<div class="ge-section-title"><span>Brush</span></div>
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-eraser-section" id="ge-eraser-section" style="display:none;">
<div class="ge-section-title">Eraser</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
</div>
</div>
<div class="ge-transform-section" id="ge-transform-section" style="display:none;"></div>
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
+59 -4
View File
@@ -23,7 +23,7 @@
* }} opts
* @returns {{ dock: HTMLDivElement, show: (id: string) => void, readonly activeId: string|null }}
*/
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false } = {}) {
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false, onCloseGroup = null } = {}) {
const dock = document.createElement('div');
dock.className = 'ge-dock';
if (compact) dock.classList.add('ge-dock--compact');
@@ -218,7 +218,7 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
divider.className = 'ge-dock-divider';
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-orientation', 'horizontal');
divider.title = 'Drag to resize panel group';
divider.title = 'Drag to resize · double-click to expand/collapse a panel group';
dock.appendChild(divider);
wireDivider(divider, groupIndex);
}
@@ -324,7 +324,9 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
// Reset any previous hiding so widths are measured at their natural size.
items.forEach(item => { item.style.display = ''; });
const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;
moreEl.hidden = !overflowing;
// Keep the menu reachable even when the tabs fit, so its "Close <group>"
// action (and every tab) stays available for every section.
moreEl.hidden = !(overflowing || typeof onCloseGroup === 'function');
if (!overflowing) return;
// Photoshop-style tab overflow: rather than clipping a label mid-word
// ("PATTERNS" -> "P…", "CHARACTER" -> "CHARA"), keep only the tabs that
@@ -378,6 +380,22 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
});
tabsMenu.appendChild(item);
});
// Close the whole section (panel group) from the same menu — Photoshop's
// "Close Tab Group". Only offered when the host supplies a handler.
if (typeof onCloseGroup === 'function') {
const sep = document.createElement('div');
sep.className = 'ge-dock-tabs-menu-sep';
tabsMenu.appendChild(sep);
const closeItem = document.createElement('button');
closeItem.type = 'button';
closeItem.className = 'ge-dock-tabs-menu-item ge-dock-tabs-menu-close';
closeItem.textContent = `Close ${group.name}`;
closeItem.addEventListener('click', () => {
closeTabsMenu();
try { onCloseGroup(group.name); } catch (_) {}
});
tabsMenu.appendChild(closeItem);
}
document.body.appendChild(tabsMenu);
const r = anchor.getBoundingClientRect();
tabsMenu.style.left = `${Math.max(4, Math.min(window.innerWidth - 160, r.left))}px`;
@@ -390,10 +408,12 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
let startY = 0;
let startH = 0;
let resizing = false;
let moved = false;
divider.addEventListener('pointerdown', (event) => {
const target = groupEl();
if (!target) return;
resizing = true;
moved = false;
startY = event.clientY;
startH = target.getBoundingClientRect().height;
divider.setPointerCapture(event.pointerId);
@@ -404,8 +424,14 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
if (!resizing) return;
const target = groupEl();
if (!target) return;
// A real drag supersedes any double-click expand state (a plain click,
// which a double-click fires, must not, or the toggle would never fire).
if (!moved) { moved = true; target.dataset.dockExpanded = '0'; target.classList.remove('is-expanded'); }
const next = Math.max(28, startH + (event.clientY - startY));
target.style.flex = `1 1 ${Math.round(next)}px`;
// Fixed basis (no grow/shrink): a `1 1` basis lets the height-constrained
// dock shrink the dragged group straight back to 0, so the divider looked
// dead. Keeping it `0 0` holds the size and lets the dock scroll instead.
target.style.flex = `0 0 ${Math.round(next)}px`;
target.style.height = `${Math.round(next)}px`;
});
const end = (event) => {
@@ -416,6 +442,35 @@ export function buildDock({ entries = [], persistKey = 'ge-dock-active', default
};
divider.addEventListener('pointerup', end);
divider.addEventListener('pointercancel', end);
// Double-click toggles the group above between "show everything" and the
// previous size. Handy on a cramped sidebar: one double-click unrolls the
// whole group (its body is normally height-clamped and scrolls), another
// restores exactly what it was.
divider.addEventListener('dblclick', (event) => {
event.preventDefault();
const target = groupEl();
if (!target) return;
const body = target.querySelector('.ge-dock-panels');
if (target.dataset.dockExpanded === '1') {
const prev = parseFloat(target.dataset.dockPrevSize || '');
const restore = Number.isFinite(prev) && prev > 0 ? prev : 200;
target.style.flex = `0 0 ${Math.round(restore)}px`;
target.style.height = `${Math.round(restore)}px`;
target.dataset.dockExpanded = '0';
target.classList.remove('is-expanded');
return;
}
const head = target.querySelector('.ge-dock-tabs');
const headH = head ? head.getBoundingClientRect().height : 0;
const bodyH = body ? body.scrollHeight : 0;
const full = Math.ceil(headH + bodyH + 2);
target.dataset.dockPrevSize = String(Math.round(target.getBoundingClientRect().height));
target.classList.remove('is-collapsed');
target.style.flex = `0 0 ${full}px`;
target.style.height = `${full}px`;
target.dataset.dockExpanded = '1';
target.classList.add('is-expanded');
});
}
// Move a panel group out of the dock into a floating, draggable
+38 -16
View File
@@ -13,7 +13,7 @@
* On phones (<= 820px) the bar is hidden by CSS and the right-panel
* bottom sheet remains the single surface, exactly as before.
*/
import { TOOLS } from './toolbar.js?v=20261008flyouts';
import { TOOLS } from './toolbar.js?v=20261010brushpresets';
import { state } from '../state.js';
// Per-tool option sources. Each value is a CSS selector for an existing
@@ -21,23 +21,29 @@ import { state } from '../state.js';
const BRUSH_TOOLS = {
brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' },
clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
};
const SELECTION_TOOLS = {
marquee: { modes: '#ge-marquee-section', feather: null },
lasso: { modes: '#ge-lasso-section', feather: '#ge-lasso-feather' },
wand: { modes: '#ge-wand-section', feather: '#ge-wand-feather' },
quickselect: { modes: '#ge-quickselect-section', feather: null },
objectselect: { modes: '#ge-objectselect-section', feather: null },
sam: { modes: '#ge-sam-section', feather: null },
marquee: { modes: null, feather: null },
lasso: { modes: null, feather: '#ge-lasso-feather' },
wand: { modes: null, feather: '#ge-wand-feather' },
quickselect: { modes: null, feather: null },
objectselect: { modes: null, feather: null },
sam: { modes: null, feather: null },
};
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', '∩']];
// Liquify is not a brush: it exposes its own mode plus dab size and strength.
const LIQUIFY_TOOLS = {
liquify: { size: '#ge-liquify-size', strength: '#ge-liquify-strength' },
};
const INTERSECT_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg>';
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', INTERSECT_ICON]];
function resolve(selector) {
if (!selector) return null;
@@ -75,7 +81,7 @@ function makeSelect(label, sourceSelector) {
function makeAutoSelect() {
return `
<label class="ge-opt ge-auto-select-option" data-opt="check">
<input type="checkbox" id="ge-auto-select-layer" />
<input type="checkbox" id="ge-auto-select-layer" class="ge-ctl-check" />
<span class="ge-opt-label">Auto-Select</span>
</label>
<div class="ge-opt" data-opt="select">
@@ -103,13 +109,16 @@ function buildToolControls(toolId) {
parts.push(makeRange('Opacity', src.opacity));
parts.push(makeRange('Flow', src.flow));
parts.push(makeRange('Softness', src.softness));
if (src.intensity) parts.push(makeRange('Intensity', src.intensity));
} else if (SELECTION_TOOLS[toolId]) {
const src = SELECTION_TOOLS[toolId];
parts.push(makeModes(src.modes));
if (src.modes) parts.push(makeModes(src.modes));
if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' }));
} else if (toolId === 'gradient') {
parts.push(makeSelect('Type', '#ge-gradient-type'));
parts.push(makeRange('Opacity', '#ge-gradient-opacity'));
} else if (LIQUIFY_TOOLS[toolId]) {
const src = LIQUIFY_TOOLS[toolId];
parts.push(makeSelect('Mode', '#ge-liquify-mode'));
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
parts.push(makeRange('Strength', src.strength));
} else if (toolId === 'move') {
parts.push(makeAutoSelect());
}
@@ -157,13 +166,26 @@ export function syncOptionsBar(bar) {
export function renderOptionsBar(bar, toolId) {
if (!bar) return;
bar.dataset.optionsTool = toolId;
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || LIQUIFY_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
// Keep the strip mounted at a stable height for every tool. Hiding it
// for tools without options reflows the centered canvas-area and makes
// the canvas jump vertically when switching tools; an empty strip is
// visually harmless.
bar.classList.toggle('ge-options-empty', !hasControls);
// Brush-family tools now expose their secondary rows (spacing / smoothing /
// blend / pen pressure) through their own caret dropdown in the strip, so
// the generic "..." overflow button is retired for every tool.
const moreBtn = bar.querySelector('#ge-options-more');
if (moreBtn) {
const showMore = false;
moreBtn.hidden = !showMore;
if (!showMore) {
bar.classList.remove('ge-more-open');
moreBtn.setAttribute('aria-expanded', 'false');
}
}
const icon = bar.querySelector('.ge-options-tool-icon');
const name = bar.querySelector('.ge-options-tool-name');
const meta = TOOLS[toolId];
+1 -1
View File
@@ -45,7 +45,7 @@ export function shortcutsPopupHTML() {
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> Save to Gallery</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>J</kbd> New Layer</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>G</kbd> Clipping Mask</div>
<div><kbd>Ctrl</kbd>+<kbd>T</kbd> Free Transform</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size…</div>
</div>
<div class="ge-shortcuts-col">
+2 -1
View File
@@ -28,7 +28,7 @@
import { state } from '../state.js';
import { buildDock } from './dock.js';
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] }) {
export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [], onCloseGroup = null }) {
const rightPanel = document.createElement('div');
rightPanel.className = 'ge-right-panel';
// Desktop: present Tool Options + Layers as tabs in a dock. Mobile
@@ -236,6 +236,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// Narrow desktop columns get the compact selector fallback; the
// multi-group stack needs a bit more room to be usable.
compact: window.innerWidth <= 820,
onCloseGroup,
});
rightPanel.appendChild(dock.dock);
// Layers stays pinned below the dock so the layer list is always
+50 -16
View File
@@ -36,16 +36,18 @@ export const TOOLS = {
text: { label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
shape: { label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
brush: { label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
gradient: { label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
gradient: { label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M6 18 18 6" opacity=".2"/><path d="M9 18 18 9" opacity=".5"/><path d="M12 18 18 12"/></svg>' },
bucket: { label: 'Paint Bucket', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11Z"/><path d="m5 2 5 5"/><path d="M2 13h15"/><path d="M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z"/></svg>' },
eraser: { label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
eyedropper: { label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.9.9a2.1 2.1 0 1 1-3 3l-2.1-2.1a2.1 2.1 0 0 1 0-3l-.9-.9Z"/></svg>' },
smudge: { label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
pixel: { label: 'Pixel', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><rect x="4" y="4" width="7" height="7"/><rect x="13" y="13" width="7" height="7"/><rect x="13" y="4" width="7" height="7" opacity="0.35"/><rect x="4" y="13" width="7" height="7" opacity="0.35"/></svg>' },
eyedropper: { label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.9.9a1 1 0 0 1 0 1.4l-1.6 1.6a1 1 0 0 1-1.4 0L12 8.4a1 1 0 0 1 0-1.4l1.6-1.6a1 1 0 0 1 1.4 0l.9.9Z"/></svg>' },
smudge: { label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.5 12V5.5a1.75 1.75 0 1 1 3.5 0V12"/><path d="M16 12v-1.5a1.5 1.5 0 0 1 3 0V16a5 5 0 0 1-5 5h-2.2a4.5 4.5 0 0 1-3.5-1.7l-3.2-4a1.6 1.6 0 0 1 2.4-2.1l1.8 1.9"/></svg>' },
clone: { label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
heal: { label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
patch: { label: 'Patch', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="10" height="10" rx="1" stroke-dasharray="3 2"/><path d="M13 13h8v8h-8z"/></svg>' },
dodge: { label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
dodge: { label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m9.5 14.5-6 6"/><circle cx="14" cy="10" r="6" fill="currentColor"/></svg>' },
burn: { label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
liquify: { label: 'Liquify', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8c2.5-3 5-3 7.5 0s5 3 7.5 0"/><path d="M3 14c2.5-3 5-3 7.5 0s5 3 7.5 0"/><circle cx="17.5" cy="19" r="2"/></svg>' },
marquee: { label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
lasso: { label: 'Lasso', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 22a5 5 0 0 1-2-4"/><path d="M3.3 13.9A6.9 6.9 0 0 1 2 9c0-4 4-7 10-7s10 3 10 7-4 7-10 7"/><path d="M5 18a4.6 4.6 0 0 1-1.5-3.5"/><circle cx="5" cy="19" r="2"/></svg>' },
pen: { label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
@@ -64,7 +66,7 @@ export const TOOLS = {
const SECTIONS = [
['move'],
[['marquee', 'lasso'], ['wand', 'quickselect', 'objectselect', 'sam']],
[['crop', 'transform'], ['brush', 'eraser'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn'], ['gradient', 'bucket'], 'eyedropper'],
[['crop', 'transform'], ['brush', 'eraser', 'pixel'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn', 'liquify'], ['gradient', 'bucket'], 'eyedropper'],
[['pen', 'path'], ['text', 'shape'], ['inpaint', 'rembg', 'sharpen']],
[['hand', 'zoom']],
];
@@ -72,6 +74,12 @@ const SECTIONS = [
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
const toolbar = document.createElement('div');
toolbar.className = 'ge-toolbar';
// The tool slots live in their own scroller so the colour swatches can sit
// in a fixed footer below it. With the swatches inside the scroller they
// were sticky over the last slots and swallowed their clicks.
const scroller = document.createElement('div');
scroller.className = 'ge-toolbar-scroll';
toolbar.appendChild(scroller);
const toolKeyMap = {};
const slots = [];
@@ -126,6 +134,17 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
flyout.style.left = (r.right + 4 - cbX) + 'px';
flyout.style.top = (Math.max(r.top, 4) - cbY) + 'px';
}
// Keep the list inside the window. The rail's lower slots (brush with its
// presets, clone, smudge) open a tall menu that would otherwise spill past
// the bottom edge, leaving the lower rows impossible to click.
const fw = flyout.offsetWidth;
const fh = flyout.offsetHeight;
const viewLeft = (parseFloat(flyout.style.left) || 0) + cbX;
const viewTop = (parseFloat(flyout.style.top) || 0) + cbY;
const maxLeft = Math.max(4, window.innerWidth - fw - 4);
const maxTop = Math.max(4, window.innerHeight - fh - 4);
if (viewLeft > maxLeft) flyout.style.left = (maxLeft - cbX) + 'px';
if (viewTop > maxTop) flyout.style.top = (maxTop - cbY) + 'px';
};
document.addEventListener('click', (ev) => {
@@ -137,7 +156,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
if (!first) {
const sep = document.createElement('div');
sep.className = 'ge-tool-sep';
toolbar.appendChild(sep);
scroller.appendChild(sep);
}
first = false;
for (const entry of section) {
@@ -158,9 +177,11 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
const slot = { el: slotEl, face, members, current: faceId, items: [] };
if (members.length > 1) {
// Photoshop-style corner triangle marks a slot whose family has
// more tools behind it.
face.insertAdjacentHTML('beforeend', '<span class="ge-tool-flyout-tri" aria-hidden="true"></span>');
const tri = face.querySelector('.ge-tool-flyout-tri');
// more tools behind it. It lives on the slot (not on the face) so
// re-rendering the face icon when a flyout member is picked never
// wipes the hint away.
slotEl.insertAdjacentHTML('beforeend', '<span class="ge-tool-flyout-tri" aria-hidden="true"></span>');
const tri = slotEl.querySelector('.ge-tool-flyout-tri');
tri?.addEventListener('click', (ev) => {
ev.stopPropagation();
openFlyout(slotEl, face);
@@ -189,12 +210,13 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
slot.items.push(item);
}
// Photoshop-style: the Brush slot's flyout also lists the brush
// presets, so the Pixel brush (and any saved presets) are reachable
// straight from the tool button. Picking one switches to the Brush
// and applies the preset through the options-bar picker so the size
// and stroke controls stay in sync.
// presets, so any saved presets are reachable straight from the tool
// button. Picking one switches to the Brush and applies the preset
// through the options-bar picker so the size and stroke controls stay
// in sync. Pixel is its own tool (above), so it is NOT listed here as a
// brush type.
if (members.includes('brush')) {
const brushPresets = loadBrushPresets();
const brushPresets = loadBrushPresets().filter((preset) => !preset.pixel);
if (brushPresets.length) {
const sep = document.createElement('div');
sep.className = 'ge-tool-flyout-sep';
@@ -208,7 +230,19 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
presetItem.type = 'button';
presetItem.className = 'ge-tool-btn ge-tool-flyout-item ge-tool-flyout-preset';
presetItem.dataset.brushPreset = preset.id;
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-flyout-label">${preset.name}</span>`);
// Each preset row shows a glyph that reflects the preset's tip:
// a 1px checker square for the pixel brush, a solid disc for a
// hard round, a soft-edged disc for a soft round, and a mid disc
// in between — so a row is recognisable at a glance.
const soft = typeof preset.softness === 'number' ? preset.softness : 100;
const presetIcon = preset.pixel
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><rect x="6" y="6" width="5" height="5"/><rect x="13" y="13" width="5" height="5"/><rect x="13" y="6" width="5" height="5" opacity="0.35"/><rect x="6" y="13" width="5" height="5" opacity="0.35"/></svg>'
: soft <= 20
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6.5"/></svg>'
: soft >= 70
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
presetItem.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, 'brush');
@@ -232,7 +266,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
});
}
slots.push(slot);
toolbar.appendChild(slotEl);
scroller.appendChild(slotEl);
}
}
+23 -8
View File
@@ -19,6 +19,16 @@ export function buildTopbar() {
<span class="ge-stacked-label">File</span>
</button>
<div class="ge-file-menu dropdown" id="ge-file-menu" hidden>
<div class="ge-menu-label">New</div>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="file-new" aria-haspopup="true" aria-expanded="false"><span>New Image</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="file-new">
<button class="dropdown-item-compact" data-file-action="new-default"><span>Default</span><span class="dropdown-shortcut">1920 × 1080</span></button>
<button class="dropdown-item-compact" data-file-action="new-square"><span>Square</span><span class="dropdown-shortcut">1080 × 1080</span></button>
<button class="dropdown-item-compact" data-file-action="new-portrait"><span>Portrait</span><span class="dropdown-shortcut">1080 × 1350</span></button>
<button class="dropdown-item-compact" data-file-action="new-story"><span>Story</span><span class="dropdown-shortcut">1080 × 1920</span></button>
<button class="dropdown-item-compact" data-file-action="new-clipboard"><span>From Clipboard</span></button>
<button class="dropdown-item-compact" data-file-action="new-custom"><span>Custom Size…</span><span class="dropdown-shortcut">Ctrl+N</span></button>
</div>
<div class="ge-menu-label">Open</div>
<button class="dropdown-item-compact" data-file-action="import"><span>Import Image…</span></button>
<button class="dropdown-item-compact" data-file-action="load-project"><span>Load Project…</span></button>
@@ -464,7 +474,19 @@ export function buildTopbar() {
<span class="ge-stacked-label">Window</span>
</button>
<div class="ge-window-menu dropdown" id="ge-window-menu" hidden>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="window-workspace">
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic &amp; Web</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-minimal"><span class="dropdown-icon ge-window-check">●</span><span>Minimal</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-custom"><span class="dropdown-icon ge-window-check">●</span><span>Custom</span></button>
<div class="ge-submenu-sep"></div>
<button type="button" class="dropdown-item-compact ge-window-save" data-window-action="workspace-save"><span class="dropdown-icon">+</span><span>Save Current</span></button>
</div>
<div class="ge-menu-label">Panels</div>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="quick-edit"><span class="dropdown-icon ge-window-check">●</span><span>Quick Edit</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="options"><span class="dropdown-icon ge-window-check">●</span><span>Tool Options</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="layers"><span class="dropdown-icon ge-window-check">●</span><span>Layers</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="properties"><span class="dropdown-icon ge-window-check">●</span><span>Properties</span></button>
@@ -480,13 +502,6 @@ export function buildTopbar() {
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="navigator"><span class="dropdown-icon ge-window-check">●</span><span>Navigator</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="history"><span class="dropdown-icon ge-window-check">●</span><span>History</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="actions"><span class="dropdown-icon ge-window-check">●</span><span>Actions</span></button>
<button type="button" class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace" aria-haspopup="true" aria-expanded="false"><span>Workspace</span><span class="ge-submenu-caret">›</span></button>
<div class="ge-submenu" data-submenu-panel="window-workspace">
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-essentials"><span class="dropdown-icon ge-window-check">●</span><span>Essentials</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-photography"><span class="dropdown-icon ge-window-check">●</span><span>Photography</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-painting"><span class="dropdown-icon ge-window-check">●</span><span>Painting</span></button>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="workspace-graphic"><span class="dropdown-icon ge-window-check">●</span><span>Graphic &amp; Web</span></button>
</div>
<div class="ge-menu-label">Help</div>
<button class="dropdown-item-compact ge-window-toggle" role="menuitemcheckbox" aria-checked="false" data-window-action="shortcuts"><span class="dropdown-icon ge-window-check">●</span><span>Keyboard Shortcuts</span></button>
</div>
@@ -509,7 +524,7 @@ export function buildTopbar() {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-maximize-toggle" title="Maximize editor" aria-label="Maximize editor" aria-pressed="false">
<svg class="ge-max-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg class="ge-max-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
+2 -2
View File
@@ -111,8 +111,8 @@ export function transformPopupHTML() {
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
</div>
<label class="ge-transform-preserve-field">
<input type="checkbox" id="ge-transform-preserve-source" />
<label class="ge-transform-preserve-field" title="Preserve source pixels (applies the transform non-destructively)">
<input type="checkbox" id="ge-transform-preserve-source" aria-label="Preserve source pixels" />
<span>Preserve source pixels</span>
</label>
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
+6
View File
@@ -93,6 +93,12 @@ export function buildCanvasMenuItems(tool) {
{ label: 'Expand', disabled: !sel, onSelect: () => invoke('selection.expand', { amount: 4 }) },
{ label: 'Contract', disabled: !sel, onSelect: () => invoke('selection.contract', { amount: 4 }) },
);
if (sel) {
push(
{ label: 'Fill Selection', onSelect: () => invoke('selection.fill', { color: state.color }) },
{ label: 'Delete Selection', hint: 'Del', onSelect: () => invoke('selection.delete') },
);
}
sep();
} else if (tool === 'crop' || state.cropRect) {
const cropping = !!(state.cropRect || state.cropping || state.cropMoving);
+11 -5
View File
@@ -136,12 +136,15 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
};
state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a lasso path in
// the empty space around the image. Other tools stay canvas-only.
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a path/rect in the empty
// space around the image. Other tools stay canvas-only.
canvasArea.addEventListener('mousedown', (e) => {
if (state.tool !== 'lasso') return;
if (state.tool !== 'lasso' && state.tool !== 'crop') return;
if (e.target === state.mainCanvas) return; // already handled
// Ignore presses on editor chrome inside the area (the crop Apply panel,
// popups, buttons) so clicking Apply does not start a fresh crop.
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
beginDraw(e);
});
state.mainCanvas.addEventListener('mouseenter', (e) => {
@@ -302,7 +305,10 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
};
canvasArea.addEventListener('pointerdown', (e) => {
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
if (state.tool === 'lasso' && !directPan) return;
// With the crop (or lasso) tool, an empty-area press draws a rect/path
// rather than panning — so the crop can be pulled out past the image.
// Space-held / middle-mouse (directPan) still pans.
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return;
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
if (onImage && !directPan) return;
+25 -15
View File
@@ -2,11 +2,12 @@
* Paste + drag-and-drop import handlers. Both add an image to the
* editor as a new layer:
*
* - Paste (Ctrl+V): checks `state.internalClipboard` first (set by
* lasso copy/cut), then falls back to the system clipboard's
* `image/*` items. Layer is named "Pasted Selection" or "Pasted"
* and becomes active; the tool snaps to Move so the user can
* reposition it immediately.
* - Paste (Ctrl+V): uses the system clipboard's `image/*` items first
* (so an external screenshot always wins), then falls back to
* `state.internalClipboard` (set by lasso copy/cut) when the OS
* clipboard has no image. Layer is named "Pasted Selection" or
* "Pasted" and becomes active; the tool snaps to Move so the user
* can reposition it immediately.
* - Drop: any `image/*` file dragged from the OS / another tab.
* Shows a "Drop image to add as new layer" overlay mid-drag. Each
* dropped image is routed through `handleImportedImage` so canvas-
@@ -62,6 +63,7 @@ export function wireClipboardAndDrop({
};
// ── Paste ──
let pasteKeyPending = false;
window.addEventListener('paste', (e) => {
if (!state.editorOpen || state.container !== container) return;
// Editable fields INSIDE the editor keep native paste (the Clipboard button
@@ -80,6 +82,7 @@ export function wireClipboardAndDrop({
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
if (imageItem) {
pasteKeyPending = false;
e.preventDefault();
e.stopImmediatePropagation();
const blob = imageItem.getAsFile();
@@ -95,6 +98,7 @@ export function wireClipboardAndDrop({
return;
}
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
pasteKeyPending = false;
e.preventDefault();
e.stopImmediatePropagation();
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
@@ -102,13 +106,13 @@ export function wireClipboardAndDrop({
}, { capture: true, signal });
// ── Ctrl/Cmd+V ──
// Paste the internal selection clipboard directly. On an insecure origin
// (the editor is served over http on a LAN IP) `navigator.clipboard` is
// unavailable, so copying a selection never reaches the OS clipboard and the
// native `paste` event above either carries stale `text/plain` or nothing —
// leaving Ctrl+V a silent no-op. Handling the keystroke here makes
// copy→paste work regardless of the platform clipboard (the toolbar buttons
// already did, which is why they worked).
// Do NOT intercept the keystroke: preventing the default here bypasses the
// browser's own `paste` event and always re-pasted `state.internalClipboard`,
// so a screenshot taken outside the gallery could never be pasted. The paste
// listener above always prefers real `image/*` clipboard items; this key
// handler only covers the insecure-origin case where copying a selection
// never reached the OS clipboard, so the paste event delivers no image (and
// possibly stale text) and nothing usable arrives.
window.addEventListener('keydown', (e) => {
if (!(state.editorOpen && state.container === container)) return;
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
@@ -116,9 +120,15 @@ export function wireClipboardAndDrop({
if (!state.internalClipboard) return;
const target = e.target;
if (target && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
e.preventDefault();
e.stopImmediatePropagation();
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
pasteKeyPending = true;
setTimeout(() => {
// The paste event runs before this timer and clears the flag whenever it
// received a real clipboard image (or already pasted the internal copy).
if (!pasteKeyPending) return;
pasteKeyPending = false;
if (!state.internalClipboard) return;
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
}, 0);
}, { capture: true, signal });
// ── Drag-and-drop ──
+11 -4
View File
@@ -198,10 +198,17 @@ function normalizeCropRect(editorState, rect) {
// Accept either {w,h} (internal) or {width,height} (control-op params).
const rawW = Number.isFinite(rect.w) ? rect.w : rect.width;
const rawH = Number.isFinite(rect.h) ? rect.h : rect.height;
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
// The rect may reach BEYOND the current image on any side: a crop that
// overshoots an edge EXPANDS the canvas there (the new region is
// transparent) instead of being clamped back inside. renderWindow() already
// places the source at (-x,-y) in an output of any size, so allowing
// negative x/y and out-of-bounds width/height is all that is needed.
// Only guard against absurd values.
const MAX = 20000;
const x = Math.max(-MAX, Math.min(MAX, Math.round(rect.x)));
const y = Math.max(-MAX, Math.min(MAX, Math.round(rect.y)));
const width = Math.max(1, Math.min(MAX, Math.round(Number.isFinite(rawW) ? rawW : editorState.imgWidth)));
const height = Math.max(1, Math.min(MAX, Math.round(Number.isFinite(rawH) ? rawH : editorState.imgHeight)));
return { x, y, width, height };
}
+18 -6
View File
@@ -24,17 +24,29 @@ function pixelSlots(snapshot) {
return slots;
}
function samePixels(image, other) {
if (image === other) return true;
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
const length = image.data.length;
// Compare 4 bytes at a time: the byte-by-byte loop dominated selection
// commits on large documents (every layer of the previous snapshot was
// re-scanned one byte at a time).
if (length % 4 === 0) {
const a = new Uint32Array(image.data.buffer, image.data.byteOffset, length >> 2);
const b = new Uint32Array(other.data.buffer, other.data.byteOffset, length >> 2);
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
return true;
}
for (let i = 0; i < length; i += 1) if (image.data[i] !== other.data[i]) return false;
return true;
}
export function shareSnapshotPixels(snapshot, previous) {
if (!previous) return snapshot;
const candidates = pixelSlots(previous).map(([object, key]) => object[key]);
for (const [object, key] of pixelSlots(snapshot)) {
const image = object[key];
const match = candidates.find(other => {
if (image === other) return true;
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
for (let i = 0; i < image.data.length; i++) if (image.data[i] !== other.data[i]) return false;
return true;
});
const match = candidates.find(other => samePixels(image, other));
if (match) object[key] = match;
}
return snapshot;
+38 -3
View File
@@ -101,9 +101,12 @@ export function wireKeyboardShortcuts(deps) {
if (e.target?.closest?.('#styled-confirm-overlay')) return;
// Fields and text layers own native editing, including undo and clipboard.
// Let Delete/Backspace out of non-text widgets (range/checkbox/button) so
// they still delete the selected layer.
// they still delete the selected layer. Enter still confirms an active
// transform even from a transform field, so typing a value then hitting
// Enter accepts the transform instead of doing nothing.
if (isTypingTarget(e.target) &&
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace'))) return;
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace')) &&
!(state.transformActive && e.key === 'Enter')) return;
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
@@ -185,6 +188,12 @@ export function wireKeyboardShortcuts(deps) {
return;
}
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
// Ctrl/Cmd+N — new document. The File menu owns the size dialog, so
// click its row rather than duplicating the prompt here.
if (!e.shiftKey && !e.altKey && e.code === 'KeyN') {
const row = document.querySelector('[data-file-action="new-custom"]');
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
}
// Ctrl/Cmd+T — free transform (Photoshop). Ctrl+Shift+T stays the canvas
// resize prompt and Ctrl+Alt+T remains a legacy alias.
if (!e.shiftKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
@@ -198,6 +207,13 @@ export function wireKeyboardShortcuts(deps) {
}
return;
}
// Ctrl+Alt+Shift+E — stamp visible: merge every visible layer into a new
// layer on top of the stack while keeping the originals (Photoshop).
// Flatten Copy in the layer footer already does exactly that, so click it.
if (e.altKey && e.shiftKey && e.code === 'KeyE') {
const stamp = document.getElementById('ge-flatten');
if (stamp) { e.preventDefault(); e.stopPropagation(); stamp.click(); return; }
}
// Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') {
e.preventDefault();
@@ -205,6 +221,12 @@ export function wireKeyboardShortcuts(deps) {
addEmptyLayer();
return;
}
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
// dialog, so click it instead of duplicating the adjustment here.
if (e.altKey && e.code === 'KeyL') {
const row = document.querySelector('[data-image-action="adj-levels"]');
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
}
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects.
if (!e.altKey && e.code === 'KeyJ') {
@@ -335,7 +357,20 @@ export function wireKeyboardShortcuts(deps) {
if (toolId) {
e.preventDefault();
e.stopPropagation();
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`) || toolbar.querySelector(`[data-tool-option="${toolId}"]`);
// A slot can host several tools (gradient + paint bucket, brush + eraser
// + pixel). The main key re-activates whatever member the slot is
// currently showing, so picking Paint Bucket and then tapping G keeps
// Paint Bucket instead of snapping back to the default. A key for a
// secondary member (e.g. E for eraser) still picks that exact tool.
const flyoutItem = toolbar.querySelector(`[data-tool-option="${toolId}"]`);
const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;
let toolBtn = null;
if (slotEl) {
const firstMember = slotEl.querySelector('.ge-tool-flyout-item[data-tool-option]');
const defaultTool = firstMember ? firstMember.dataset.toolOption : null;
if (toolId === defaultTool) toolBtn = slotEl.querySelector('.ge-tool-slot-face');
}
toolBtn = toolBtn || toolbar.querySelector(`[data-tool="${toolId}"]`) || flyoutItem;
if (toolBtn) toolBtn.click();
return;
}
+11 -1
View File
@@ -115,7 +115,7 @@ export function defaultAdjParams(type) {
/** Preset names for the retained adjustment popup. */
export function adjustmentPresetOptions(type) {
const options = {
'brightness-contrast': ['Default', 'High Contrast'],
'brightness-contrast': ['Default', 'Brighten', 'Darken', 'Punch', 'High Contrast', 'Low Contrast', 'Faded'],
'exposure': ['Default', 'Lift Exposure', 'Lower Exposure'],
'white-balance': ['Default', 'Warm', 'Cool'],
'hue-saturation': ['Default', 'Desaturate', 'Boost Color'],
@@ -135,8 +135,18 @@ export function adjustmentPresetOptions(type) {
export function adjustmentPresetParams(type, preset) {
const params = defaultAdjParams(type);
switch (`${type}:${preset}`) {
case 'brightness-contrast:Brighten':
return { ...params, brightness: 1.18 };
case 'brightness-contrast:Darken':
return { ...params, brightness: 0.82 };
case 'brightness-contrast:Punch':
return { ...params, brightness: 1.04, contrast: 1.22 };
case 'brightness-contrast:High Contrast':
return { ...params, contrast: 1.28 };
case 'brightness-contrast:Low Contrast':
return { ...params, contrast: 0.75 };
case 'brightness-contrast:Faded':
return { ...params, brightness: 1.1, contrast: 0.85 };
case 'exposure:Lift Exposure':
return { ...params, exposure: 0.45 };
case 'exposure:Lower Exposure':
+93 -7
View File
@@ -110,6 +110,88 @@ export function createLayerPanelRenderer(deps) {
return Date.now() < (window.__geSuppressLayerTapUntil || 0);
}
// Photoshop lets you drag a layer effect onto another layer to copy it.
// Rebuild the effect with a fresh id and its own mask canvas so the two
// layers never share mutable pixels.
function cloneEffectForCopy(effect) {
const copy = normalizeEffect({
...effect,
id: `effect-${Math.random().toString(36).slice(2, 9)}`,
mask: effect.mask
? { ...effect.mask, id: `effect-mask-${Math.random().toString(36).slice(2, 9)}` }
: null,
});
if (copy.mask && effect.mask?.canvas) {
const canvas = document.createElement('canvas');
canvas.width = effect.mask.canvas.width;
canvas.height = effect.mask.canvas.height;
canvas.getContext('2d').drawImage(effect.mask.canvas, 0, 0);
copy.mask.canvas = canvas;
copy.mask.ctx = canvas.getContext('2d');
copy.mask.canvasW = canvas.width;
copy.mask.canvasH = canvas.height;
}
return copy;
}
// Drag an effect sub-row onto any layer or group row to copy that effect.
// Pointer events (not HTML5 DnD) keep the existing click-to-edit behaviour.
function wireEffectDrag(sub, owner, effect) {
sub.addEventListener('pointerdown', (event) => {
if (event.button !== 0 || event.pointerType === 'touch') return;
if (event.target.closest('button, input, .ge-layer-btn, .ge-layer-opacity')) return;
if (shouldIgnoreLayerTap()) return;
const startX = event.clientX;
const startY = event.clientY;
let ghost = null;
let targetRow = null;
const highlight = (row) => {
if (targetRow && targetRow !== row) targetRow.classList.remove('is-effect-drop-target');
targetRow = row || null;
targetRow?.classList.add('is-effect-drop-target');
};
const move = (ev) => {
if (!ghost) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 5) return;
ghost = document.createElement('div');
ghost.className = 'ge-effect-drag-ghost';
ghost.textContent = effectLabel(effect.type);
document.body.appendChild(ghost);
sub.classList.add('is-effect-dragging');
// A drop is not a click on the effect name.
window.__geSuppressLayerTapUntil = Date.now() + 400;
}
ghost.style.left = `${ev.clientX + 12}px`;
ghost.style.top = `${ev.clientY + 12}px`;
const hit = document.elementFromPoint(ev.clientX, ev.clientY);
highlight(hit?.closest?.('.ge-layer-item[data-layer-id], .ge-layer-group-row[data-group-id]') || null);
};
const up = () => {
document.removeEventListener('pointermove', move, true);
document.removeEventListener('pointerup', up, true);
document.removeEventListener('pointercancel', up, true);
const row = targetRow;
highlight(null);
ghost?.remove();
sub.classList.remove('is-effect-dragging');
if (!ghost || !row) return;
const layer = row.dataset.layerId
? state.layers.find(item => item.id === row.dataset.layerId)
: (state.layerGroups || []).find(item => item.id === row.dataset.groupId);
if (!layer || layer === owner) return;
saveState(`Copy ${effectLabel(effect.type)}`);
if (!Array.isArray(layer.effects)) layer.effects = [];
layer.effects.push(cloneEffectForCopy(effect));
composite();
render();
uiModule?.showToast?.(`${effectLabel(effect.type)} copied to ${layer.name}`);
};
document.addEventListener('pointermove', move, true);
document.addEventListener('pointerup', up, true);
document.addEventListener('pointercancel', up, true);
});
}
function rowTargets(layer) {
const selected = selectedLayers(state);
return selected.length > 1 && selected.some(item => item.id === layer.id)
@@ -914,7 +996,7 @@ export function createLayerPanelRenderer(deps) {
effectName.className = 'ge-layer-name ge-adj-sub-name';
effectName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
effectName.lastElementChild.textContent = effectLabel(effect.type);
effectName.title = 'Edit group effect';
effectName.title = 'Edit group effect (drag onto another layer to copy)';
effectName.addEventListener('click', event => {
event.stopPropagation();
editRetainedEffect?.(group, effect, effectName);
@@ -974,6 +1056,7 @@ export function createLayerPanelRenderer(deps) {
render();
});
effectControls.appendChild(remove);
wireEffectDrag(sub, group, effect);
sub.append(visibility, effectName, effectOpacity, effectControls);
list.appendChild(sub);
}
@@ -1866,7 +1949,7 @@ export function createLayerPanelRenderer(deps) {
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
sName.title = 'Edit effect';
sName.title = 'Edit effect (drag onto another layer to copy)';
sName.addEventListener('click', event => {
event.stopPropagation();
editRetainedEffect?.(layer, effect, sName);
@@ -1971,6 +2054,7 @@ export function createLayerPanelRenderer(deps) {
render();
});
sControls.appendChild(remove);
wireEffectDrag(sub, layer, effect);
sub.append(sVis, sName, sOp, sControls);
list.appendChild(sub);
}
@@ -2140,14 +2224,16 @@ export function createLayerPanelRenderer(deps) {
}
}
// Wire the shared dragSort module — limit drag-init to the grip
// handle so row body clicks still activate. Called every render
// because `enable()` cleans up the previous instance keyed on
// instanceKey.
// Wire the shared dragSort module. The whole row is draggable (grip,
// thumbnail, name) with a small movement threshold so a plain click
// still activates the layer and a double-click still renames it;
// buttons/inputs keep their own behaviour. Called every render because
// `enable()` cleans up the previous instance keyed on instanceKey.
if (dragSortModule) {
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
instanceKey: 'ge-layers',
handleSelector: '.ge-layer-drag',
threshold: 4,
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
onReorder: (orderedItems) => {
// DOM is top→bottom = reverse of array order, so the new
// array is the reverse of the DOM order.
+42 -6
View File
@@ -47,8 +47,42 @@ export function transformedLayerGeometry(snapshot, sourceBounds, target) {
const perspectiveY = target.perspectiveY || 0;
const skewPadX = Math.abs(Math.tan(skewX * Math.PI / 180)) * scaledHeight;
const skewPadY = Math.abs(Math.tan(skewY * Math.PI / 180)) * scaledWidth;
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin + skewPadX));
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos + skewPadY));
// Free distort (Ctrl/Cmd+drag): the four corners as a quad in the layer's
// local (unrotated) space, plus its axis-aligned bounds.
const distort = target.distort || null;
let quad = null;
if (distort) {
const corners = [['tl', -0.5, -0.5], ['tr', 0.5, -0.5], ['br', 0.5, 0.5], ['bl', -0.5, 0.5]];
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const points = corners.map(([key, sx, sy]) => {
const delta = distort[key] || { x: 0, y: 0 };
const px = (sx + delta.x) * scaledWidth;
const py = (sy + delta.y) * scaledHeight;
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
return { x: px, y: py };
});
quad = {
points,
minX,
minY,
quadW: Math.max(1, maxX - minX),
quadH: Math.max(1, maxY - minY),
};
}
const quadW = quad ? quad.quadW : scaledWidth;
const quadH = quad ? quad.quadH : scaledHeight;
const quadCx = quad ? quad.minX + quadW / 2 : 0;
const quadCy = quad ? quad.minY + quadH / 2 : 0;
const width = Math.max(1, Math.round(quadW * absCos + quadH * absSin + skewPadX));
const height = Math.max(1, Math.round(quadW * absSin + quadH * absCos + skewPadY));
return {
scaleX,
scaleY,
@@ -60,13 +94,15 @@ export function transformedLayerGeometry(snapshot, sourceBounds, target) {
skewY,
perspectiveX,
perspectiveY,
distort,
quad,
width,
height,
centerX,
centerY,
centerX: centerX + (quadCx * cos - quadCy * sin),
centerY: centerY + (quadCx * sin + quadCy * cos),
offset: {
x: Math.round(centerX - width / 2),
y: Math.round(centerY - height / 2),
x: Math.round(centerX + (quadCx * cos - quadCy * sin) - width / 2),
y: Math.round(centerY + (quadCx * sin + quadCy * cos) - height / 2),
},
};
}
+87 -4
View File
@@ -99,6 +99,7 @@ export function createColorPanels({
zoomIn = null,
zoomOut = null,
zoomFit = null,
applyGradient = null,
} = {}) {
if (!state) throw new Error('createColorPanels requires editor state');
@@ -119,10 +120,24 @@ export function createColorPanels({
const swatchCol = el('div', 'ge-swatch-col');
const suggestRow = el('div', 'ge-swatch-row ge-swatch-suggest');
const recentRow = el('div', 'ge-swatch-row ge-swatch-recent');
const favRow = el('div', 'ge-swatch-row ge-swatch-fav');
const sugLabel = el('div', 'ge-swatch-row-label'); sugLabel.textContent = 'Suggestions';
const recLabel = el('div', 'ge-swatch-row-label'); recLabel.textContent = 'Recent';
swatchCol.append(sugLabel, suggestRow, recLabel, recentRow);
const favLabel = el('div', 'ge-swatch-row-label'); favLabel.textContent = 'Favorites';
swatchCol.append(sugLabel, suggestRow, recLabel, recentRow, favLabel, favRow);
swatchBody.appendChild(swatchCol);
const FAV_KEY = 'ge-color-favorites';
const getFavoriteColors = () => {
try {
const v = JSON.parse(localStorage.getItem(FAV_KEY) || '[]');
return Array.isArray(v) ? v.filter((h) => /^#[0-9a-f]{6}$/i.test(h)) : [];
} catch { return []; }
};
const setFavoriteColors = (list) => {
try { localStorage.setItem(FAV_KEY, JSON.stringify(list.slice(0, 24))); } catch { /* storage unavailable */ }
};
const isFavorite = (hex) => getFavoriteColors().some((h) => h.toLowerCase() === String(hex || '').toLowerCase());
let hexFav = null;
const _syncForeground = (hex) => {
setColor(hex);
// Mirror the pick into the rail's foreground swatch so the whole editor
@@ -141,6 +156,11 @@ export function createColorPanels({
recentRow.innerHTML = recs.length
? recs.map(h => `<button type="button" class="ge-mini-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
: '<span class="ge-mini-empty">none yet</span>';
const favs = getFavoriteColors();
favRow.innerHTML = favs.length
? favs.map(h => `<button type="button" class="ge-mini-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
: '<span class="ge-mini-empty">star a colour to save it</span>';
if (typeof hexFav !== 'undefined' && hexFav) hexFav.classList.toggle('active', isFavorite(cur));
};
// Inline picker: an always-visible SV square + hue strip so the panel reads
@@ -163,7 +183,46 @@ export function createColorPanels({
hexInput.autocomplete = 'off';
hexInput.setAttribute('aria-label', 'Hex colour code');
hexInput.value = String(state.color || '#000000').replace(/^#/, '');
hexRow.append(hexHash, hexInput);
const hexCopy = el('button', 'ge-hex-copy');
hexCopy.type = 'button';
hexCopy.title = 'Copy hex colour';
hexCopy.setAttribute('aria-label', 'Copy hex colour');
hexCopy.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>';
hexCopy.addEventListener('click', async () => {
const hex = '#' + String(hexInput.value || state.color || '').replace(/^#/, '');
try {
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(hex);
else throw new Error('clipboard unavailable');
} catch {
const ta = document.createElement('textarea');
ta.value = hex;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* ignore */ }
ta.remove();
}
hexCopy.classList.add('copied');
clearTimeout(hexCopy._copiedTimer);
hexCopy._copiedTimer = setTimeout(() => hexCopy.classList.remove('copied'), 900);
});
hexFav = el('button', 'ge-hex-copy ge-hex-fav');
hexFav.type = 'button';
hexFav.title = 'Save to favorites';
hexFav.setAttribute('aria-label', 'Save colour to favorites');
hexFav.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l2.6 5.3 5.9.86-4.25 4.14 1 5.86L12 17l-5.25 2.72 1-5.86L3.5 9.66l5.9-.86z"/></svg>';
hexFav.addEventListener('click', () => {
const hex = '#' + String(hexInput.value || state.color || '').replace(/^#/, '');
const h = hex.toLowerCase();
if (!/^#[0-9a-f]{6}$/.test(h)) return;
const list = getFavoriteColors().map((x) => x.toLowerCase());
const i = list.indexOf(h);
if (i >= 0) list.splice(i, 1); else list.unshift(h);
setFavoriteColors(list);
renderInlineSwatches();
});
hexRow.append(hexHash, hexInput, hexCopy, hexFav);
swatchBody.appendChild(hexRow);
const _setHex = (hex) => {
if (hexInput && document.activeElement !== hexInput) hexInput.value = String(hex || '').replace(/^#/, '');
@@ -262,6 +321,10 @@ export function createColorPanels({
btn.title = name;
btn.style.background = css;
btn.addEventListener('click', () => {
// Pick up the gradient tool first (so the colors land in its controls),
// then load the preset's stops and paint it across the layer — clicking a
// preset should actually apply the gradient, not just stage the colors.
document.querySelector('[data-tool="gradient"]')?.click();
const start = document.getElementById('ge-gradient-start');
const end = document.getElementById('ge-gradient-end');
const type = document.getElementById('ge-gradient-type');
@@ -270,9 +333,10 @@ export function createColorPanels({
end.value = stops[stops.length - 1];
start.dispatchEvent(new Event('input', { bubbles: true }));
end.dispatchEvent(new Event('input', { bubbles: true }));
if (type) { type.value = 'linear'; type.dispatchEvent(new Event('change', { bubbles: true })); }
if (type) { type.value = 'linear-gradient'; type.dispatchEvent(new Event('change', { bubbles: true })); }
requestRender();
}
applyGradient?.();
});
gradientGrid.appendChild(btn);
});
@@ -321,8 +385,27 @@ export function createColorPanels({
const histBody = el('div', 'ge-histogram-body');
histBody.appendChild(histCanvas);
const histogram = panel('histogram', '', histBody);
// Pick a layer that actually has raster pixels. The active layer can be a
// group/adjustment/text layer without a `canvas`, which used to make
// drawHistogram throw (and the panel stay blank); fall back to the topmost
// visible raster layer.
const histogramLayer = () => {
const layers = Array.isArray(state?.layers) ? state.layers : [];
const active = layers.find((l) => l.id === state.activeLayerId);
if (active && active.canvas && active.visible !== false) return active;
for (let i = layers.length - 1; i >= 0; i--) {
const l = layers[i];
if (l.canvas && l.visible !== false && l.kind !== 'adjustment') return l;
}
return active || layers[layers.length - 1] || null;
};
const redrawHistogram = () => {
try { drawHistogram(histCanvas, activeLayer(state)); } catch { /* not ready */ }
const layer = histogramLayer();
if (!layer || !layer.canvas) {
histCanvas.getContext('2d')?.clearRect(0, 0, histCanvas.width, histCanvas.height);
return;
}
try { drawHistogram(histCanvas, layer); } catch { /* not ready */ }
};
// ---- Info -------------------------------------------------------------
+8 -8
View File
@@ -144,15 +144,15 @@ export function createPrecisionGuides({ canvasArea, composite, saveState, schedu
function drawDocumentOverlay(ctx) {
if (!ctx || !state.imgWidth || !state.imgHeight) return;
ctx.save();
const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');
if (state.gridVisible || pixelBrush) {
const base = pixelBrush
? Math.max(1, Math.round(Number(state.brushSize) || 1))
: Math.max(2, Number(state.gridSize) || 16);
const multiplier = pixelBrush ? 1 : Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
// The pixel grid is shown only when the user turns on View > Grid. The
// Pixel tool used to force a strong blue grid over the whole document,
// which read as an unwanted blue tint over the image.
if (state.gridVisible) {
const base = Math.max(2, Number(state.gridSize) || 16);
const multiplier = Math.max(1, Math.ceil(5 / Math.max(0.0001, base * state.zoom)));
const spacing = base * multiplier;
const strong = pixelBrush ? 'rgba(122, 208, 255, 0.55)' : 'rgba(110, 180, 220, 0.42)';
const weak = pixelBrush ? 'rgba(122, 208, 255, 0.30)' : 'rgba(110, 180, 220, 0.22)';
const strong = 'rgba(110, 180, 220, 0.42)';
const weak = 'rgba(110, 180, 220, 0.22)';
ctx.lineWidth = 1 / state.zoom;
for (let x = spacing; x < state.imgWidth; x += spacing) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.imgHeight);
+102 -9
View File
@@ -285,18 +285,111 @@ export function selectionBoundaryPixels(mask, threshold = 128) {
return pixels;
}
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0) {
/**
* Boundary pixels AND the selected-flag grid for a mask, computed in one
* pass. The flags let the ants painter find each outline pixel's exposed
* edges without re-reading the mask.
*/
export function selectionBoundary(mask, threshold = 128) {
const fallback = { width: mask?.width || 0, height: mask?.height || 0, pixels: [], flags: null };
if (!mask?.width || !mask?.height) return fallback;
let data;
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
catch { return fallback; }
const width = mask.width;
const height = mask.height;
const flags = new Uint8Array(width * height);
// First/last selected pixel of each row, so the outline pass can skip the
// empty margins above, below and around the selection instead of testing
// every pixel of a large canvas.
const firsts = new Int32Array(height).fill(-1);
const lasts = new Int32Array(height).fill(-1);
for (let y = 0, i = 0, alpha = 3; y < height; y += 1) {
let first = -1, last = -1;
for (let x = 0; x < width; x += 1, i += 1, alpha += 4) {
if (data[alpha] < threshold) continue;
flags[i] = 1;
if (first < 0) first = x;
last = x;
}
firsts[y] = first;
lasts[y] = last;
}
// Direct index arithmetic instead of a closure call per neighbour: this runs
// over every pixel of the image on each new selection, and the five function
// calls per pixel were a measurable share of the pen-close cost on big docs.
const pixels = [];
for (let y = 0; y < height; y += 1) {
const first = firsts[y];
if (first < 0) continue;
const last = lasts[y];
const row = y * width;
const above = row - width;
const below = row + width;
const top = y === 0;
const bottom = y === height - 1;
for (let x = first; x <= last; x += 1) {
const index = row + x;
if (flags[index] !== 1) continue;
if ((x === 0 || flags[index - 1] !== 1)
|| (x === width - 1 || flags[index + 1] !== 1)
|| (top || flags[above + x] !== 1)
|| (bottom || flags[below + x] !== 1)) {
pixels.push(index);
}
}
}
return { width, height, pixels, flags };
}
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0, zoom = 1, flags = null) {
if (!ctx || !pixels?.length || !width || !height) return;
const image = ctx.createImageData(width, height);
if (!flags || typeof Path2D === 'undefined') {
// Fallback (no mask context / non-browser): per-pixel fill in image space.
const image = ctx.createImageData(width, height);
for (const index of pixels) {
const x = index % width;
const y = Math.floor(index / width);
const light = ((x + y + phase) % 8) < 4;
const offset = index * 4;
image.data[offset] = light ? 255 : 0;
image.data[offset + 1] = light ? 255 : 0;
image.data[offset + 2] = light ? 255 : 0;
image.data[offset + 3] = 255;
}
ctx.putImageData(image, 0, 0);
return;
}
// Screen-constant marching ants. `width`/`height` are the MASK (image)
// dimensions; the caller's overlay bitmap is rendered at SCREEN resolution
// (image size × zoom), so we multiply the image-space outline into screen
// pixels and stroke a flat 1px line — no bitmap resampling, so the outline
// stays the same thickness and opacity at every zoom level.
const scale = zoom > 0 ? zoom : 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.lineWidth = 1;
ctx.lineCap = 'butt';
ctx.setLineDash([]);
const white = new Path2D();
const black = new Path2D();
const selected = (x, y) => x >= 0 && y >= 0 && x < width && y < height && flags[y * width + x] === 1;
for (const index of pixels) {
const x = index % width;
const y = Math.floor(index / width);
const light = ((x + y + phase) % 8) < 4;
const offset = index * 4;
image.data[offset] = light ? 255 : 0;
image.data[offset + 1] = light ? 255 : 0;
image.data[offset + 2] = light ? 255 : 0;
image.data[offset + 3] = 255;
const x0 = x * scale;
const y0 = y * scale;
const x1 = (x + 1) * scale;
const y1 = (y + 1) * scale;
const sx = Math.floor((x + 0.5) * scale);
const sy = Math.floor((y + 0.5) * scale);
const path = ((sx + sy + phase) % 8) < 4 ? white : black;
if (!selected(x, y - 1)) { path.moveTo(x0, y0); path.lineTo(x1, y0); }
if (!selected(x, y + 1)) { path.moveTo(x0, y1); path.lineTo(x1, y1); }
if (!selected(x - 1, y)) { path.moveTo(x0, y0); path.lineTo(x0, y1); }
if (!selected(x + 1, y)) { path.moveTo(x1, y0); path.lineTo(x1, y1); }
}
ctx.putImageData(image, 0, 0);
ctx.strokeStyle = '#ffffff';
ctx.stroke(white);
ctx.strokeStyle = '#000000';
ctx.stroke(black);
}
+3
View File
@@ -129,6 +129,9 @@ export function createShortcutsPopover() {
outside = (e) => {
if (el.contains(e.target)) return;
if (e.target.closest('#ge-shortcuts-btn')) return;
// A click on a topbar dropdown row is an explicit toggle (e.g. the
// Window menu's "Keyboard Shortcuts" row), not a click-away.
if (e.target.closest && e.target.closest('.ge-topbar .dropdown')) return;
toggleShortcuts(false);
};
setTimeout(() => document.addEventListener('mousedown', outside, true), 0);
+3 -1
View File
@@ -163,8 +163,10 @@ export function wireSliderUx({ registerDocClickAway }) {
const crect = chip.getBoundingClientRect();
const rrect = row.getBoundingClientRect();
inp.style.left = (crect.left - rrect.left) + 'px';
inp.style.top = (crect.top - rrect.top - 1) + 'px';
inp.style.width = Math.max(40, crect.width + 8) + 'px';
// Vertically centre the editor on the chip it replaced (the input is
// not necessarily the same height as the chip).
inp.style.top = (crect.top - rrect.top + (crect.height - inp.offsetHeight) / 2) + 'px';
inp.focus();
inp.select();
const commit = () => {
+9
View File
@@ -54,6 +54,10 @@ export const state = {
transformPendingSkewY: 0,
transformPendingPerspX: 0,
transformPendingPerspY: 0,
// Free distort (Ctrl/Cmd+drag a handle): per-corner offsets in frame-normal
// units (each component is a fraction of the frame width/height; ±0.5 is
// the frame half-size). null when no handle has been distorted.
transformPendingDistort: null,
transformAspectLock: true,
// Keep raster transforms source-backed unless the user explicitly opts out
// in the Transform popup.
@@ -108,6 +112,11 @@ export const state = {
brushFlow: 100,
brushSoftness: 100,
smudgeStrength: 65,
dodgeBurnIntensity: 25,
// Liquify warp brush — mode plus the dab radius (image px) and strength.
liquifyMode: 'push',
liquifySize: 120,
liquifyStrength: 50,
eraserOpacity: 100,
eraserFlow: 100,
eraserSoftness: 100,
+19 -4
View File
@@ -3,7 +3,7 @@ import { state } from './state.js';
import { createBrushSampler, pressureValue } from './brush-engine.js';
import { isLayerTransparencyLocked } from './layer-groups.js';
const PAINT_TOOLS = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'inpaint']);
const PAINT_TOOLS = new Set(['brush', 'pixel', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'inpaint']);
function toolSettings(tool) {
if (tool === 'eraser') return { opacity: state.eraserOpacity, flow: state.eraserFlow, softness: state.eraserSoftness };
@@ -295,11 +295,26 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
color = 'rgba(255,255,255,1)';
alpha = 1;
softness = 0;
} else if (tool === 'dodge') { operation = 'screen'; color = 'rgba(255,255,255,1)'; }
else if (tool === 'burn') { operation = 'multiply'; color = 'rgba(0,0,0,1)'; }
} else if (tool === 'dodge') {
// Lighten the pixels already there rather than stamping flat white.
// Intensity scales the per-dab strength so a stroke can be gentle or
// punchy; the cap keeps it building up gradually like Photoshop.
const intensity = Number.isFinite(state.dodgeBurnIntensity) ? state.dodgeBurnIntensity / 100 : 0.25;
operation = 'screen';
color = 'rgba(255,255,255,1)';
alpha = Math.max(0.004, Math.min(0.6, alpha * intensity * 0.5));
} else if (tool === 'burn') {
// Darken the pixels already there rather than stamping flat black.
const intensity = Number.isFinite(state.dodgeBurnIntensity) ? state.dodgeBurnIntensity / 100 : 0.25;
operation = 'multiply';
color = 'rgba(0,0,0,1)';
alpha = Math.max(0.004, Math.min(0.6, alpha * intensity * 0.5));
}
else if (target.paintingMask) { color = 'rgba(255,255,255,1)'; alpha = 1; softness = 0; }
else operation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : state.brushBlendMode;
if (state.brushPixel && (tool === 'brush' || tool === 'eraser') && !target.paintingMask) {
// The Pixel tool always stamps hard squares; the bundle flag makes a
// regular Brush/Eraser do the same when the (legacy) pixel preset is on.
if ((tool === 'pixel' || (state.brushPixel && (tool === 'brush' || tool === 'eraser'))) && !target.paintingMask) {
// Square pixel brush: hard integer-aligned dabs at the chosen size.
// Freehand by default; hold Shift to constrain the stroke to the
// nearest of 8 directions (a straight line anchored at the stroke
+91 -4
View File
@@ -76,16 +76,42 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
if (preview) preview.style.opacity = (state.smudgeStrength / 100).toFixed(2);
});
const dodgeBurnIntensity = document.getElementById('ge-dodgeburn-intensity');
dodgeBurnIntensity?.addEventListener('input', (e) => {
state.dodgeBurnIntensity = parseInt(e.target.value, 10);
document.getElementById('ge-dodgeburn-intensity-label').textContent = `${state.dodgeBurnIntensity}%`;
const preview = document.getElementById('ge-dodgeburn-preview');
if (preview) preview.style.opacity = (0.2 + state.dodgeBurnIntensity / 125).toFixed(2);
});
const spacing = document.getElementById('ge-brush-spacing');
const smoothing = document.getElementById('ge-brush-smoothing');
const blend = document.getElementById('ge-brush-blend');
const spacingPrev = document.getElementById('ge-brush-preview-spacing');
const smoothingPrev = document.getElementById('ge-brush-preview-smoothing');
// Spacing preview: split the single dot into two dots whose gap grows with
// the spacing value, so the swatch reads as "dots spaced apart".
const paintSpacingPreview = () => {
if (!spacingPrev) return;
const gap = Math.min(9, 1 + state.brushSpacing * 0.08);
const c1 = (7 - gap / 2).toFixed(2);
const c2 = (7 + gap / 2).toFixed(2);
spacingPrev.style.opacity = '';
spacingPrev.style.background = `radial-gradient(circle 3.4px at ${c1}px 50%, var(--fg) 98%, transparent 100%), radial-gradient(circle 3.4px at ${c2}px 50%, var(--fg) 98%, transparent 100%)`;
};
spacing?.addEventListener('input', (e) => {
state.brushSpacing = parseInt(e.target.value, 10);
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
paintSpacingPreview();
});
smoothing?.addEventListener('input', (e) => {
state.brushSmoothing = parseInt(e.target.value, 10);
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
// More smoothing → softer edge; widen the radial falloff.
if (smoothingPrev) {
const innerStop = Math.max(0, 70 - state.brushSmoothing * 0.7);
smoothingPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
}
});
blend?.addEventListener('change', (e) => { state.brushBlendMode = e.target.value; });
document.getElementById('ge-pressure-size')?.addEventListener('change', e => { state.pressureSize = e.target.checked; });
@@ -99,11 +125,28 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
let presets = loadBrushPresets();
const presetSelect = document.getElementById('ge-brush-preset');
const presetSaveBtn = document.getElementById('ge-brush-preset-save');
const presetDeleteBtn = document.getElementById('ge-brush-preset-delete');
// Save and Delete only apply to custom brushes: they are hidden while a
// fixed built-in preset is selected. Save is available on the unsaved
// "Custom" slot (to create one) and on a saved custom (to save over it);
// Delete is available only for a saved (non built-in) custom preset.
const syncPresetButtons = () => {
const id = presetSelect?.value || '';
const preset = presets.find(item => item.id === id);
const isBuiltIn = !!preset && !!preset.builtIn;
const isUserCustom = !!preset && !preset.builtIn;
if (presetSaveBtn) presetSaveBtn.hidden = isBuiltIn;
if (presetDeleteBtn) presetDeleteBtn.hidden = !isUserCustom;
};
const renderPresets = (selectedId = '') => {
if (!presetSelect) return;
presetSelect.innerHTML = '<option value="">Brush presets</option>' + presets.map(preset =>
// Pixel is its own tool, so it is not offered as a brush preset here.
const list = presets.filter(preset => !preset.pixel);
presetSelect.innerHTML = '<option value="">Custom</option>' + list.map(preset =>
`<option value="${preset.id}">${preset.name}</option>`).join('');
presetSelect.value = selectedId;
syncPresetButtons();
};
const syncControls = () => {
if (spacing) spacing.value = String(state.brushSpacing);
@@ -114,14 +157,28 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
if (smudgeLabel) smudgeLabel.textContent = `${state.smudgeStrength}%`;
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
paintSpacingPreview();
onPresetApplied?.();
};
renderPresets();
// Restore the last preset the user picked instead of snapping back to the
// unsaved "Custom" slot on every editor open.
let savedPresetId = '';
try { savedPresetId = localStorage.getItem('ge-brush-preset') || ''; } catch (error) { /* storage unavailable */ }
renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');
presetSelect?.addEventListener('change', () => {
try { localStorage.setItem('ge-brush-preset', presetSelect.value || ''); } catch (error) { /* ignore */ }
const preset = presets.find(item => item.id === presetSelect.value);
if (!preset) return;
if (!preset) {
// "Custom" (no preset) — leave the current brush alone but drop pixel
// mode so the user can get back out of the 1px Pixel preset.
state.brushPixel = false;
onPresetApplied?.();
syncPresetButtons();
return;
}
applyBrushPreset(state, preset);
syncControls();
syncPresetButtons();
});
document.getElementById('ge-brush-preset-save')?.addEventListener('click', () => {
const name = window.prompt('Brush preset name');
@@ -129,6 +186,7 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
const preset = captureBrushPreset(state, name);
presets.push(preset);
saveCustomBrushPresets(presets);
try { localStorage.setItem('ge-brush-preset', preset.id); } catch (error) { /* ignore */ }
renderPresets(preset.id);
});
document.getElementById('ge-brush-preset-delete')?.addEventListener('click', () => {
@@ -136,6 +194,35 @@ export function wireStrokeToolSliders({ onPresetApplied } = {}) {
if (!preset || preset.builtIn) return;
presets = presets.filter(item => item.id !== preset.id);
saveCustomBrushPresets(presets);
renderPresets();
try { localStorage.setItem('ge-brush-preset', ''); } catch (error) { /* ignore */ }
renderPresets();
paintSpacingPreview();
});
// The caret ("v") next to the brush row drops down the less-used stroke
// controls (Spacing / Smoothing / Blend / pen pressure) so the options strip
// stays a single tidy line. Same pattern as the wand's caret menu.
const moreBtn = document.getElementById('ge-brush-more');
const moreMenu = document.getElementById('ge-brush-more-menu');
const closeBrushMore = () => {
if (!moreMenu || moreMenu.hidden) return;
moreMenu.hidden = true;
moreBtn?.setAttribute('aria-expanded', 'false');
moreBtn?.classList.remove('active');
};
moreBtn?.addEventListener('click', (event) => {
event.stopPropagation();
const open = moreMenu.hidden;
moreMenu.hidden = !open;
moreBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
moreBtn.classList.toggle('active', open);
});
document.addEventListener('click', (event) => {
if (!moreMenu || moreMenu.hidden) return;
if (moreMenu.contains(event.target) || moreBtn?.contains(event.target)) return;
closeBrushMore();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeBrushMore();
});
}
+66 -3
View File
@@ -9,7 +9,10 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
let input = null;
let layer = null;
let resizeObserver = null;
let handle = null;
let frame = 0;
let appliedWidth = 0;
let resizing = false;
function position() {
if (!input || !layer || !state.mainCanvas?.isConnected) return;
@@ -20,13 +23,27 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
const text = layer.text;
input.style.left = `${canvasRect.left + offset.x * scaleX}px`;
input.style.top = `${canvasRect.top + offset.y * scaleY}px`;
input.style.minWidth = `${Math.max(80, text.frameWidth * scaleX)}px`;
input.style.width = `${Math.max(80, text.frameWidth * scaleX)}px`;
// Only rewrite the width when the model's frame width (or the zoom) changed.
// Writing it every animation frame overwrote the native resize handle's
// width before the ResizeObserver could report it, so the box could not be
// resized at all.
const targetW = Math.max(80, text.frameWidth * scaleX);
if (!resizing && Math.abs(targetW - appliedWidth) > 0.5) {
input.style.width = `${targetW}px`;
appliedWidth = targetW;
}
input.style.minWidth = '40px';
input.style.minHeight = `${Math.max(36, layer.canvas.height * scaleY)}px`;
input.style.font = textFont({ ...text, fontSize: text.fontSize * scaleY });
input.style.letterSpacing = `${text.letterSpacing * scaleX}px`;
input.style.textAlign = text.align;
input.style.color = text.color;
if (handle) {
const rect = input.getBoundingClientRect();
handle.style.left = `${rect.right - 8}px`;
handle.style.top = `${rect.bottom - 8}px`;
handle.style.display = '';
}
}
function loop() {
@@ -39,6 +56,9 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
const value = input.value;
resizeObserver?.disconnect();
cancelAnimationFrame(frame);
handle?.remove();
handle = null;
resizing = false;
input.remove();
input = null;
const target = layer;
@@ -49,6 +69,7 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
function open(target, { selectAll = false } = {}) {
close(true);
layer = target;
appliedWidth = 0;
input = document.createElement('textarea');
input.className = 'ge-direct-text-editor';
input.value = target.text.content;
@@ -67,9 +88,51 @@ export function createTextEditOverlay({ state, onInput, onCommit, onCancel }) {
});
let ready = false;
input.addEventListener('blur', () => setTimeout(() => {
if (ready && input && document.activeElement !== input) close(true);
if (ready && input && !resizing && document.activeElement !== input) close(true);
}, 0));
document.body.appendChild(input);
// A visible grab square in the corner: the native textarea resize grip is
// invisible in this theme and only a few pixels wide, so most drags missed
// it. This handle also survives the animation-frame positioning pass.
handle = document.createElement('div');
handle.className = 'ge-direct-text-handle';
handle.title = 'Drag to resize';
handle.setAttribute('aria-hidden', 'true');
handle.style.display = 'none';
// Same dot-grip language as the layer-row drag handles, arranged as a
// corner wedge so it reads as "resize from this corner".
handle.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><circle cx="4" cy="9" r="1.1"/><circle cx="7" cy="9" r="1.1"/><circle cx="10" cy="9" r="1.1"/><circle cx="7" cy="6" r="1.1"/><circle cx="10" cy="6" r="1.1"/><circle cx="10" cy="3" r="1.1"/></svg>';
let dragStart = null;
handle.addEventListener('pointerdown', (event) => {
if (!input || !layer || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const canvasRect = state.mainCanvas.getBoundingClientRect();
const scale = canvasRect.width / Math.max(1, state.mainCanvas.width);
const rect = input.getBoundingClientRect();
dragStart = { x: event.clientX, y: event.clientY, w: rect.width, h: rect.height, scale };
resizing = true;
handle.setPointerCapture?.(event.pointerId);
});
handle.addEventListener('pointermove', (event) => {
if (!dragStart || !input || !layer) return;
const w = Math.max(80, Math.round(dragStart.w + (event.clientX - dragStart.x)));
const h = Math.max(36, Math.round(dragStart.h + (event.clientY - dragStart.y)));
input.style.width = `${w}px`;
input.style.height = `${h}px`;
appliedWidth = w;
onInput?.(layer, input.value, input, w / Math.max(0.01, dragStart.scale));
});
const endDrag = () => {
if (!dragStart) return;
dragStart = null;
resizing = false;
if (input) appliedWidth = input.getBoundingClientRect().width;
input?.focus({ preventScroll: true });
};
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
document.body.appendChild(handle);
resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => {
if (!input || !layer) return;
const canvasRect = state.mainCanvas.getBoundingClientRect();
+4 -3
View File
@@ -35,6 +35,7 @@ export const WARP_STYLES = Object.freeze([
system extras but are filtered out at runtime when unavailable. */
export const EDITOR_TEXT_FONTS = Object.freeze([
'Inter', 'Roboto', 'Playfair Display', 'Lobster', 'Bebas Neue', 'Roboto Mono',
'Space Grotesk', 'Fraunces', 'Syne', 'Bungee', 'Instrument Serif',
'Georgia', 'Courier New',
]);
@@ -42,9 +43,9 @@ export const EDITOR_TEXT_FONTS = Object.freeze([
re-render can pick up faces that were not yet loaded on first paint. */
export function loadEditorFonts() {
if (typeof document === 'undefined' || !document.fonts || !document.fonts.load) return Promise.resolve([]);
return Promise.all(EDITOR_TEXT_FONTS.map((family) => {
try { return document.fonts.load(`400 48px "${family}"`).catch(() => []); } catch (_) { return Promise.resolve([]); }
})).catch(() => []);
return Promise.all(EDITOR_TEXT_FONTS.flatMap((family) => ['400', '700'].map((weight) => {
try { return document.fonts.load(`${weight} 48px "${family}"`).catch(() => []); } catch (_) { return Promise.resolve([]); }
}))).catch(() => []);
}
/* Only offer families the browser can actually render distinctly. */
+1 -1
View File
@@ -2,7 +2,7 @@
export const TOOL_SHORTCUTS = Object.freeze({
move: 'V', hand: 'H', crop: 'C', text: 'T', shape: 'U',
brush: 'B', gradient: 'G', eraser: 'E', eyedropper: 'I',
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O',
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O', liquify: 'K',
marquee: 'M', lasso: 'L', wand: 'W', pen: 'P',
zoom: 'Z',
});
+20 -2
View File
@@ -127,9 +127,27 @@ export function createBucketTool({ activeLayer, saveState, composite, renderLaye
return count;
}
// The active layer can be an adjustment layer (no raster canvas) or a
// hidden/locked one; in that case fall back to the topmost visible raster
// layer so a click still fills something instead of silently doing nothing.
function paintableLayer() {
const direct = activeLayer();
if (direct?.canvas && direct.ctx && direct.kind !== 'adjustment') return direct;
for (let i = state.layers.length - 1; i >= 0; i--) {
const l = state.layers[i];
if (l?.canvas && l.ctx && l.kind !== 'adjustment' && l.visible !== false) return l;
}
return null;
}
function fill(e) {
const layer = activeLayer();
if (!layer?.canvas || !layer.ctx) return 0;
const layer = paintableLayer();
if (!layer) { showToast?.('Select a pixel layer to fill'); return 0; }
if (state.activeLayerId !== layer.id) {
state.activeLayerId = layer.id;
state.selectedLayerIds = [layer.id];
renderLayerPanel?.();
}
const point = canvasCoords(e, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const count = fillAtPoint(layer, point.x - off.x, point.y - off.y);
+16 -9
View File
@@ -12,6 +12,8 @@
* @param {{
* composite: () => void,
* showCropApply: () => void,
* syncCropFrame?: () => void,
* snapFrame?: (frame: object, center: object) => object,
* }} deps
*/
import { state } from '../state.js';
@@ -19,7 +21,7 @@ import { canvasCoords } from '../canvas-coords.js';
import { drawCheckerboard } from '../checkerboard.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createCropTool({ composite, showCropApply, snapFrame }) {
export function createCropTool({ composite, showCropApply, snapFrame, syncCropFrame }) {
const gesture = createDirectManipulationSession({
name: 'crop',
getContext: () => ({ canvas: state.mainCanvas, tool: state.tool }),
@@ -96,22 +98,26 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
} else {
state.activeSnapGuides = null;
}
// Clamp to canvas bounds so the rect stays fully visible.
nx = Math.max(0, Math.min(nx, state.mainCanvas.width - state.cropRect.w));
ny = Math.max(0, Math.min(ny, state.mainCanvas.height - state.cropRect.h));
// Allow the rect to be dragged beyond the image edge as well (that
// expands the canvas on Apply). Only guard against absurd coords.
const MAX = 20000;
nx = Math.max(-MAX, Math.min(nx, MAX));
ny = Math.max(-MAX, Math.min(ny, MAX));
state.cropRect = { ...state.cropRect, x: nx, y: ny };
composite();
syncCropFrame?.();
return;
}
if (!state.cropping) return;
e.preventDefault();
// Clamp the dragged corner to the document so a pointer that
// overshoots onto a panel (or past the canvas edge) still yields a
// rect inside the image rather than negative/off-canvas coords.
// The dragged corner may go OUTSIDE the image: a rect that reaches
// past an edge expands the canvas there (transparent fill) on Apply.
// Only clamp against absurd coordinates (pointer flung off-screen).
const end = canvasCoords(e, state.mainCanvas);
const MAX = 20000;
state.cropEnd = {
x: Math.max(0, Math.min(state.mainCanvas.width, end.x)),
y: Math.max(0, Math.min(state.mainCanvas.height, end.y)),
x: Math.max(-MAX, Math.min(MAX, end.x)),
y: Math.max(-MAX, Math.min(MAX, end.y)),
};
// Aspect lock. Shift-held snapshots the current aspect on first press
// and stays locked until released. Otherwise a chosen crop preset
@@ -187,6 +193,7 @@ export function createCropTool({ composite, showCropApply, snapFrame }) {
state.mainCtx.strokeRect(x, y, w, h);
state.mainCtx.setLineDash([]);
state.cropRect = { x, y, w, h };
syncCropFrame?.();
},
end(e) {
+7 -3
View File
@@ -1,6 +1,10 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
// Holding Alt over these tools borrows the eyedropper (Photoshop parity).
// Clone/Heal are excluded: Alt already sets their clone source.
export const ALT_EYEDROPPER_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'gradient', 'bucket']);
function hexByte(value) {
return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0');
}
@@ -60,11 +64,11 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
return {
preview(e) {
const result = sample(e);
syncPreview?.(result?.color || null, result);
syncPreview?.(result?.color || null, result, e);
return result?.color || null;
},
clearPreview() {
syncPreview?.(null, null);
syncPreview?.(null, null, null);
},
pick(e) {
const result = sample(e, true);
@@ -75,7 +79,7 @@ export function createEyedropperTool({ activeLayer, composite, syncColor, syncPr
}
state.color = color;
syncColor?.(state.color);
syncPreview?.(state.color, result);
syncPreview?.(state.color, result, e);
},
};
}
+177
View File
@@ -0,0 +1,177 @@
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
/**
* Liquify — Photoshop-style destructive warp brush for the active layer.
*
* Dragging samples the layer's pixels through a displacement field: Push
* drags pixels with the pointer, Twirl rotates them around the dab, Pucker
* pulls inward, Bloat pushes outward and Smooth softens the area. Each
* stroke is one undo step (the snapshot is taken on press) and the layer is
* re-composited at most once per animation frame, so the canvas shows the
* warp live without re-rendering the document on every mouse event.
*/
const MODES = new Set(['push', 'twirl', 'pucker', 'bloat', 'smooth']);
export function createLiquifyTool({ activeLayer, saveState, composite }) {
let layer = null, owner = null, last = null, live = false, raf = 0;
const radius = () => Math.max(4, Math.min(1000, Number(state.liquifySize) || 120));
const strength = () => Math.max(1, Math.min(100, Number(state.liquifyStrength) || 50)) / 100;
const mode = () => (MODES.has(state.liquifyMode) ? state.liquifyMode : 'push');
const requestComposite = () => {
if (raf) return;
raf = requestAnimationFrame(() => { raf = 0; composite(); });
};
// Pointer position in the active layer's own pixel space (layers can be
// offset from the document origin).
function layerPoint(event) {
const p = canvasCoords(event, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return { x: p.x - off.x, y: p.y - off.y };
}
function sampleBilinear(src, w, h, fx, fy, dst, di) {
const x = Math.max(0, Math.min(w - 1, fx - 0.5));
const y = Math.max(0, Math.min(h - 1, fy - 0.5));
const x1 = Math.floor(x), y1 = Math.floor(y);
const x2 = Math.min(w - 1, x1 + 1), y2 = Math.min(h - 1, y1 + 1);
const tx = x - x1, ty = y - y1;
const i11 = (y1 * w + x1) * 4, i21 = (y1 * w + x2) * 4;
const i12 = (y2 * w + x1) * 4, i22 = (y2 * w + x2) * 4;
const wx1 = 1 - tx, wy1 = 1 - ty, wx2 = tx, wy2 = ty;
for (let c = 0; c < 4; c++) {
dst[di + c] = src[i11 + c] * wx1 * wy1 + src[i21 + c] * wx2 * wy1
+ src[i12 + c] * wx1 * wy2 + src[i22 + c] * wx2 * wy2;
}
}
function average3x3(src, w, h, px, py) {
let r = 0, g = 0, b = 0, a = 0, n = 0;
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
const x = Math.max(0, Math.min(w - 1, Math.round(px) + ox));
const y = Math.max(0, Math.min(h - 1, Math.round(py) + oy));
const i = (y * w + x) * 4;
r += src[i]; g += src[i + 1]; b += src[i + 2]; a += src[i + 3]; n++;
}
}
return { r: r / n, g: g / n, b: b / n, a: a / n };
}
function dab(cx, cy, dx, dy, spin) {
const canvas = layer.canvas, ctx = layer.ctx;
const w = canvas.width, h = canvas.height;
const r = radius();
const x0 = Math.max(0, Math.floor(cx - r - 1));
const y0 = Math.max(0, Math.floor(cy - r - 1));
const x1 = Math.min(w, Math.ceil(cx + r + 1));
const y1 = Math.min(h, Math.ceil(cy + r + 1));
const bw = x1 - x0, bh = y1 - y0;
if (bw <= 0 || bh <= 0) return;
const img = ctx.getImageData(x0, y0, bw, bh);
const out = ctx.createImageData(bw, bh);
const src = img.data, dst = out.data;
const m = mode(), s = strength();
for (let y = 0; y < bh; y++) {
for (let x = 0; x < bw; x++) {
const di = (y * bw + x) * 4;
const px = x0 + x + 0.5, py = y0 + y + 0.5;
const ox = px - cx, oy = py - cy;
const dist = Math.hypot(ox, oy);
if (dist >= r) {
dst[di] = src[di]; dst[di + 1] = src[di + 1]; dst[di + 2] = src[di + 2]; dst[di + 3] = src[di + 3];
continue;
}
const t = 1 - dist / r;
const fall = t * t * (3 - 2 * t);
let sx = px, sy = py;
if (m === 'push') {
sx = px - dx * fall * s;
sy = py - dy * fall * s;
} else if (m === 'twirl') {
const ang = spin * s * 2.6 * fall * fall;
const ca = Math.cos(ang), sa = Math.sin(ang);
sx = cx + ox * ca - oy * sa;
sy = cy + ox * sa + oy * ca;
} else if (m === 'pucker') {
const k = 1 - 0.55 * s * fall;
sx = cx + ox * k;
sy = cy + oy * k;
} else if (m === 'bloat') {
const k = 1 + 0.55 * s * fall;
sx = cx + ox * k;
sy = cy + oy * k;
} else if (m === 'smooth') {
const avg = average3x3(src, bw, bh, px - x0, py - y0);
const k = fall * s * 0.9;
dst[di] = src[di] * (1 - k) + avg.r * k;
dst[di + 1] = src[di + 1] * (1 - k) + avg.g * k;
dst[di + 2] = src[di + 2] * (1 - k) + avg.b * k;
dst[di + 3] = src[di + 3] * (1 - k) + avg.a * k;
continue;
}
sampleBilinear(src, bw, bh, sx - x0, sy - y0, dst, di);
}
}
ctx.putImageData(out, x0, y0);
}
function strokeTo(point, previous) {
const dx = point.x - previous.x, dy = point.y - previous.y;
const distance = Math.hypot(dx, dy);
// Twirl direction: the pointer's motion around the dab decides clockwise
// vs counter-clockwise, matching how Photoshop's twirl brush feels.
const ox = previous.x - point.x, oy = previous.y - point.y;
const spin = (ox * dy - oy * dx) >= 0 ? 1 : -1;
const step = Math.max(1, radius() * 0.25);
const steps = Math.max(1, Math.ceil(distance / step));
for (let i = 1; i <= steps; i++) {
dab(previous.x + (dx * i) / steps, previous.y + (dy * i) / steps, dx / steps, dy / steps, spin);
}
requestComposite();
}
return {
begin(event) {
const target = activeLayer();
if (!target || !target.canvas || !target.ctx) return;
if (event.button != null && event.button !== 0) return;
event.preventDefault();
owner = target;
layer = target;
saveState('Liquify');
last = layerPoint(event);
live = true;
// A single click still warps (twirl/pucker/bloat do something useful
// on press); Push needs movement, so it is a no-op until the drag.
if (mode() !== 'push') { dab(last.x, last.y, 0, 0, 1); requestComposite(); }
state.drawing = true;
},
drag(event) {
if (!live || !layer || owner !== activeLayer() || state.tool !== 'liquify') return;
event.preventDefault();
const point = layerPoint(event);
strokeTo(point, last);
last = point;
},
end() {
if (!live) return;
live = false;
last = null;
owner = null;
state.drawing = false;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
composite();
},
cancel() {
live = false; last = null; owner = null; state.drawing = false;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
},
};
}
+2 -1
View File
@@ -3,6 +3,7 @@
// The stroke is sampled into positive/negative points plus a bounding
// box, both already supported by the backend endpoint.
import { state } from '../state.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createObjectSelectTool({
@@ -47,7 +48,7 @@ export function createObjectSelectTool({
active = true;
points = [];
box = null;
mode = selectionModeForEvent(event, 'replace');
mode = selectionModeForEvent(event, state.wandMode || 'replace');
const coords = canvasCoords(event);
if (coords) addPoint(coords, event);
};
+59 -11
View File
@@ -2,13 +2,17 @@ import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi }) {
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi, redraw, refreshSelection }) {
let points = [], dragging = false, owner = null, canvas = null, mode = 'replace';
const current = () => canvas === state.mainCanvas && state.tool === 'pen' && owner === activeLayer();
// The in-progress path lives on its own overlay canvas, so repainting it must
// not re-composite the whole document (that is what made every pen click flash
// the entire canvas). Fall back to a composite only if no overlay hook exists.
const repaint = () => { if (typeof redraw === 'function') redraw(); else composite(); };
function cancel() {
const hadPath = points.length > 0;
points = []; dragging = false; owner = canvas = null;
if (hadPath) composite();
if (hadPath) repaint();
return hadPath;
}
function trace(ctx, closed) {
@@ -27,12 +31,18 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
const ctx = mask.getContext('2d');
trace(ctx, true);
ctx.fillStyle = '#fff'; ctx.fill();
saveState('Pen selection');
saveState('Pen selection', { selectionOnly: true });
commitSelectionMask(mask, owner, mode, 'pen', { x: 0, y: 0 });
state.wandMaskVisible = true;
state.wandLastSeed = null;
state.lassoPoints = []; state.lassoActive = false;
cancel(); syncSelectionUi();
// Only the selection changed: repainting the marching ants is enough, so
// skip the full document composite (which is the most expensive part of
// closing a path on a large canvas).
cancel();
if (typeof refreshSelection === 'function') refreshSelection();
else composite();
syncSelectionUi();
return true;
}
return {
@@ -50,14 +60,14 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
mode = selectionModeForEvent(event, state.wandMode || 'replace');
}
points.push({ ...p, dx: 0, dy: 0 }); dragging = true;
composite();
repaint();
},
drag(event) {
if (!dragging || !current()) return;
event.preventDefault();
const p = canvasCoords(event, state.mainCanvas), anchor = points.at(-1);
anchor.dx = p.x - anchor.x; anchor.dy = p.y - anchor.y;
composite();
repaint();
},
end() { dragging = false; },
key(event) {
@@ -65,19 +75,48 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
if (event.key === 'Escape') cancel();
else if (event.key === 'Enter') commit();
else if (event.key === 'Backspace' || event.key === 'Delete' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z')) {
points.pop(); dragging = false; composite();
points.pop(); dragging = false; repaint();
} else return false;
event.preventDefault(); event.stopImmediatePropagation(); return true;
},
draw() {
if (!points.length || !current()) return;
const ctx = state.mainCtx, unit = 1 / state.zoom;
const overlay = state.penOverlay, ctx = state.penOverlayCtx;
if (!overlay || !ctx) return;
if (!points.length || !current()) {
if (overlay.style.display !== 'none') {
overlay.style.display = 'none';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, overlay.width, overlay.height);
}
return;
}
const main = state.mainCanvas;
const w = Math.max(1, Math.round(state.imgWidth * state.zoom));
const h = Math.max(1, Math.round(state.imgHeight * state.zoom));
if (overlay.width !== w || overlay.height !== h) { overlay.width = w; overlay.height = h; }
overlay.style.width = w + 'px';
overlay.style.height = h + 'px';
overlay.style.left = main.offsetLeft + 'px';
overlay.style.top = main.offsetTop + 'px';
overlay.style.transform = main.style.transform || '';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, w, h);
// The stylesheet hides this overlay by default, so clearing the inline
// value would leave it at display:none; show it explicitly.
overlay.style.display = 'block';
const unit = 1 / state.zoom;
ctx.save();
ctx.scale(state.zoom, state.zoom);
trace(ctx, false);
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
for (const p of points) {
if (p.dx || p.dy) {
// Photoshop only shows the bezier handles of the anchor being shaped.
// Drawing them for every point turned the preview into a mesh of long
// straight lines across the shape.
const shaped = dragging ? points.length - 1 : -1;
for (let i = 0; i < points.length; i += 1) {
const p = points[i];
if (i === shaped && (p.dx || p.dy)) {
ctx.beginPath(); ctx.moveTo(p.x - p.dx, p.y - p.dy); ctx.lineTo(p.x + p.dx, p.y + p.dy); ctx.stroke();
for (const sign of [-1, 1]) {
ctx.beginPath(); ctx.arc(p.x + sign * p.dx, p.y + sign * p.dy, 2.5 * unit, 0, Math.PI * 2);
@@ -88,6 +127,15 @@ export function createPenSelectionTool({ activeLayer, saveState, commitSelection
ctx.strokeStyle = '#000'; ctx.strokeRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
ctx.strokeStyle = '#fff';
}
// Ring the starting anchor once the path can be closed, so it reads as
// "click here to close" instead of just another point.
if (points.length >= 3) {
const first = points[0];
ctx.beginPath(); ctx.arc(first.x, first.y, 6 * unit, 0, Math.PI * 2);
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
ctx.strokeStyle = '#fff';
}
ctx.restore();
},
};
+12 -5
View File
@@ -3,6 +3,7 @@
// stroke; intermediate samples skip the undo snapshot (retune mode) so a
// whole drag collapses to one history entry (taken on begin).
import { state } from '../state.js';
import { selectionModeForEvent } from '../selection-modifiers.js';
export function createQuickSelectTool({
@@ -16,13 +17,16 @@ export function createQuickSelectTool({
}) {
let painting = false;
let last = null;
let strokeMode = 'replace';
const modeFor = (event) => selectionModeForEvent(event, 'add');
// Honour the New / Add / Subtract mode toggle (the wand's segmented
// control); Shift/Alt still override for the duration of the dab.
const modeFor = (event) => selectionModeForEvent(event, state.wandMode || 'replace');
const sample = (event) => {
const sample = (event, mode) => {
const coords = canvasCoords(event);
if (!coords) return;
runMagicWand(coords.x, coords.y, modeFor(event), { retune: true });
runMagicWand(coords.x, coords.y, mode || modeFor(event), { retune: true });
};
const begin = (event) => {
@@ -31,7 +35,8 @@ export function createQuickSelectTool({
saveState();
painting = true;
last = null;
sample(event);
strokeMode = modeFor(event);
sample(event, strokeMode);
const coords = canvasCoords(event);
if (coords) last = coords;
};
@@ -42,7 +47,9 @@ export function createQuickSelectTool({
if (!coords) return;
const gap = Math.max(3, (brushSize?.() || 24) / 4);
if (last && Math.hypot(coords.x - last.x, coords.y - last.y) < gap) return;
sample(event);
// Once the stroke has replaced the old selection, later dabs must
// accumulate onto it instead of wiping the stroke's progress.
sample(event, strokeMode === 'replace' ? 'add' : selectionModeForEvent(event, strokeMode));
last = coords;
};
+4 -3
View File
@@ -34,10 +34,11 @@ import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { isLayerPixelLocked, isLayerTransparencyLocked } from '../layer-groups.js';
const STROKE_TOOLS = new Set(['brush', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
const STROKE_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'inpaint']);
function strokeLabel(tool) {
if (tool === 'brush') return 'Brush stroke';
if (tool === 'pixel') return 'Pixel stroke';
if (tool === 'eraser') return 'Eraser stroke';
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
if (tool === 'smudge') return 'Smudge stroke';
@@ -72,11 +73,11 @@ export function createStrokeTool({
const activeMask = state.quickMaskActive && state.wandMask
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
: getActiveMaskLayer();
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && parent?.kind === 'placed') {
if (['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && parent?.kind === 'placed') {
showToast?.('Rasterize the placed layer before painting its pixels');
return true;
}
if (['brush', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
if (['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn'].includes(state.tool) && !activeMask && isLayerPixelLocked(state, parent)) {
showToast?.('Unlock image pixels before painting');
return true;
}
+55 -1
View File
@@ -24,7 +24,7 @@
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { resizeTransformFrame } from '../transform-frame-geometry.js';
import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';
import { createDirectManipulationSession } from '../direct-manipulation-session.js';
export function createTransformDragTool({
@@ -67,6 +67,9 @@ export function createTransformDragTool({
state.transformStartRotation = state.transformPendingRot;
state.transformStartFlipH = state.transformPendingFlipH;
state.transformStartFlipV = state.transformPendingFlipV;
state.transformStartSkewX = state.transformPendingSkewX || 0;
state.transformStartSkewY = state.transformPendingSkewY || 0;
state.transformStartDistort = state.transformPendingDistort || null;
state.transformStartOffX = center.x - state.transformOrigW / 2;
state.transformStartOffY = center.y - state.transformOrigH / 2;
gesture.begin(e, { handle: state.transformHandle }, { captureTarget: e.currentTarget });
@@ -156,6 +159,57 @@ export function createTransformDragTool({
}
return true;
}
// Ctrl/Cmd + drag a handle = FREE DISTORT (Photoshop). Only the
// dragged point moves: a corner handle moves that corner alone
// (the frame becomes a quad); an edge handle slides that edge's
// two corners together. Size, centre, rotation and skew are left
// untouched — distort is a separate slot on the transform target.
if (e.ctrlKey || e.metaKey) {
const distorted = distortTransformFrame({
centerX: state.transformStartCenter.x,
centerY: state.transformStartCenter.y,
width: state.transformOrigW,
height: state.transformOrigH,
rotation: state.transformStartRotation,
}, state.transformHandle, {
x: state.transformStartX,
y: state.transformStartY,
}, coords, {
startDistort: state.transformStartDistort || null,
});
if (distorted) {
state.transformPendingDistort = distorted;
reapplyTransform();
return true;
}
}
// Ctrl/Cmd with no distortable handle (rotation knob) keeps the old
// edge-shear skew so the popup's Skew X / Skew Y fields stay live.
if ((e.ctrlKey || e.metaKey) && state.transformHandle === 'rot') {
const skewed = skewTransformFrame({
centerX: state.transformStartCenter.x,
centerY: state.transformStartCenter.y,
width: state.transformOrigW,
height: state.transformOrigH,
rotation: state.transformStartRotation,
}, state.transformHandle, {
x: state.transformStartX,
y: state.transformStartY,
}, coords, {
startSkewX: state.transformStartSkewX || 0,
startSkewY: state.transformStartSkewY || 0,
});
state.transformPendingSkewX = skewed.skewX;
state.transformPendingSkewY = skewed.skewY;
reapplyTransform();
if (state.transformPopup) {
const skewXIn = state.transformPopup.querySelector('#ge-transform-skew-x');
const skewYIn = state.transformPopup.querySelector('#ge-transform-skew-y');
if (skewXIn) skewXIn.value = String(Math.round(skewed.skewX));
if (skewYIn) skewYIn.value = String(Math.round(skewed.skewY));
}
return true;
}
// Resize in the frame's LOCAL axes. This keeps rotated side handles
// attached to the pointer and fixes the opposite edge in document space.
const resized = resizeTransformFrame({
+74 -17
View File
@@ -30,16 +30,43 @@ import {
* Position the transform overlay canvas + size its backing bitmap.
* Margin is screen-space slack on each side so controls remain visible
* at every zoom. It is converted to image pixels for the backing canvas.
*
* `extents` (image px, optional) is an extra rectangle the overlay must
* also cover — used so the skeleton frame + handles stay inside the
* bitmap once the frame is scaled/moved OUTSIDE the image bounds. When
* it is omitted the overlay only covers the image (+margin), matching
* the original behaviour. Returns the overlay's top-left in image
* coordinates (the draw translate origin), or null when hidden.
*/
export function syncOverlay(margin) {
if (!state.transformOverlay || !state.mainCanvas) return;
export function syncOverlay(margin, extents = null) {
if (!state.transformOverlay || !state.mainCanvas) return null;
if (!state.transformActive) {
state.transformOverlay.style.display = 'none';
return;
return null;
}
const imageMargin = margin / Math.max(0.0001, state.zoom);
const W = Math.ceil(state.mainCanvas.width + 2 * imageMargin);
const H = Math.ceil(state.mainCanvas.height + 2 * imageMargin);
const imgW = state.mainCanvas.width;
const imgH = state.mainCanvas.height;
// Union of the image and any extra extent the frame reaches, plus the
// handle slack on every side. Cap the bitmap so an extreme pending
// size can never allocate a runaway canvas.
const MAX_BITMAP = 8192;
let left = Math.min(0, extents?.left ?? 0) - imageMargin;
let top = Math.min(0, extents?.top ?? 0) - imageMargin;
let right = Math.max(imgW, extents?.right ?? imgW) + imageMargin;
let bottom = Math.max(imgH, extents?.bottom ?? imgH) + imageMargin;
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
if (right - left > MAX_BITMAP) {
left = centerX - MAX_BITMAP / 2;
right = centerX + MAX_BITMAP / 2;
}
if (bottom - top > MAX_BITMAP) {
top = centerY - MAX_BITMAP / 2;
bottom = centerY + MAX_BITMAP / 2;
}
const W = Math.ceil(right - left);
const H = Math.ceil(bottom - top);
if (state.transformOverlay.width !== W) state.transformOverlay.width = W;
if (state.transformOverlay.height !== H) state.transformOverlay.height = H;
// Overlay must scale with state.zoom so its handles render at the
@@ -53,15 +80,35 @@ export function syncOverlay(margin) {
state.transformOverlay.style.pointerEvents = 'none';
state.transformOverlay.style.zIndex = '5';
// Position the overlay at the main canvas's LAYOUT position
// (offsetLeft/Top — unaffected by CSS transforms), shifted up-left by
// the overlay's `margin` image-px of handle slack. Then SHARE the
// canvas's transform (the pan handler writes the same translate3d to
// both canvas + overlay), so pan moves them together. Reading the
// layout offset (not getBoundingClientRect, which includes the pan
// transform) is what avoids the double-pan "bounce".
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + 'px';
// (offsetLeft/Top — unaffected by CSS transforms), shifted by the
// covered extent in screen px. Then SHARE the canvas's transform (the
// pan handler writes the same translate3d to both canvas + overlay),
// so pan moves them together. Reading the layout offset (not
// getBoundingClientRect, which includes the pan transform) is what
// avoids the double-pan "bounce".
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft + left * state.zoom) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop + top * state.zoom) + 'px';
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
return { left, top };
}
/** Image-space AABB of the frame (incl. rotation knob + skew) or null. */
function frameExtents(geometry) {
if (!geometry) return null;
const points = [
geometry.corners?.tl, geometry.corners?.tr,
geometry.corners?.br, geometry.corners?.bl,
geometry.rotationHandle,
].filter(Boolean);
if (!points.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return { left: minX, top: minY, right: maxX, bottom: maxY };
}
@@ -103,6 +150,9 @@ function transformFrame() {
width: state.transformPendingW || state.transformSelectionBounds.width,
height: state.transformPendingH || state.transformSelectionBounds.height,
rotation: state.transformPendingRot || 0,
skewX: state.transformPendingSkewX || 0,
skewY: state.transformPendingSkewY || 0,
distort: state.transformPendingDistort || null,
};
}
const layer = state.transformLayer;
@@ -118,6 +168,9 @@ function transformFrame() {
width: state.transformPendingW || layer.canvas.width,
height: state.transformPendingH || layer.canvas.height,
rotation: state.transformPendingRot || 0,
skewX: state.transformPendingSkewX || 0,
skewY: state.transformPendingSkewY || 0,
distort: state.transformPendingDistort || null,
};
}
@@ -129,7 +182,6 @@ function transformFrame() {
*/
export function drawHandles(margin) {
if (!state.transformActive || (state.transformTarget !== 'selection' && !state.transformLayer)) return;
syncOverlay(margin);
if (!state.transformOverlayCtx) return;
const frame = transformFrame();
if (!frame) return;
@@ -138,13 +190,18 @@ export function drawHandles(margin) {
zoom: state.zoom,
rotationInside: rotInside,
});
// Grow the overlay so the whole frame + handles fit even when the
// frame extends well past the image bounds (otherwise the skeleton
// gets clipped at the bitmap edge during a resize).
const cover = syncOverlay(margin, frameExtents(geometry));
if (!cover) return;
const ctx = state.transformOverlayCtx;
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
const imageMargin = margin / Math.max(0.0001, state.zoom);
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
// Clear + shift drawing so image (cover.left, cover.top) maps to
// overlay (0, 0).
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
ctx.save();
ctx.translate(imageMargin, imageMargin);
ctx.translate(-cover.left, -cover.top);
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
const sz = 10 / state.zoom;
const stroke = 1.5 / state.zoom;
+34 -11
View File
@@ -169,6 +169,7 @@ export function createTransformSession({
state.transformPendingSkewY = 0;
state.transformPendingPerspX = 0;
state.transformPendingPerspY = 0;
state.transformPendingDistort = null;
// Snapshot the layer so live preview can re-derive from the
// original pixels on every keystroke instead of stacking
// destructive edits.
@@ -239,7 +240,10 @@ export function createTransformSession({
mobileBoundsHandler = null;
}
if (state.transformPopup) {
try { state.transformPopup.remove(); } catch {}
try {
state.transformPopup.innerHTML = '';
state.transformPopup.style.display = 'none';
} catch {}
state.transformPopup = null;
}
}
@@ -277,15 +281,16 @@ export function createTransformSession({
// signal than those transient messages, so clear them first.
document.querySelector('.tour-hint .tour-hint-dismiss')?.click();
document.querySelector('#toast.show .toast-close-btn')?.click();
const pop = document.createElement('div');
pop.className = 'ge-transform-popup';
pop.innerHTML = transformPopupHTML();
// The transform controls live inline in the editor options strip (header
// bar) instead of a floating popup over the canvas.
const pop = document.getElementById('ge-transform-section');
if (!pop) return;
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
const title = pop.querySelector('.ge-adj-title');
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
else if (title && state.transformLayers.length > 1) title.textContent = `Transform ${state.transformLayers.length} layers`;
state.container.appendChild(pop);
pop.style.display = '';
state.transformPopup = pop;
wireTransformDrag(pop);
const wInput = pop.querySelector('#ge-transform-w');
const hInput = pop.querySelector('#ge-transform-h');
const rotInput = pop.querySelector('#ge-transform-rot');
@@ -438,9 +443,6 @@ export function createTransformSession({
});
});
attachSpinRepeat(pop);
applyMobileTransformBounds(pop);
mobileBoundsHandler = () => applyMobileTransformBounds(pop);
window.addEventListener('resize', mobileBoundsHandler);
}
// Header-drag for the Transform popup. Default position: over the
@@ -551,6 +553,19 @@ export function createTransformSession({
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
if (geometry.rotation) ctx.rotate(geometry.radians);
if (geometry.distort && geometry.quad) {
// Free distort: map the quad into the output bounds and warp.
const { points, minX, minY, quadW, quadH } = geometry.quad;
const normalized = points.map(point => ({
x: (point.x - minX) / quadW,
y: (point.y - minY) / quadH,
}));
const warped = perspectiveWarpCanvas(source, normalized, canvas.width, canvas.height);
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
ctx.drawImage(warped, -warped.width / 2, -warped.height / 2);
ctx.restore();
return canvas;
}
if (isPerspectiveActive(geometry.perspectiveX, geometry.perspectiveY)) {
const warped = perspectiveWarpCanvas(source, keystoneCorners(geometry.perspectiveX, geometry.perspectiveY), canvas.width, canvas.height);
ctx.scale(Math.sign(geometry.signedScaleX) || 1, Math.sign(geometry.signedScaleY) || 1);
@@ -576,6 +591,7 @@ export function createTransformSession({
skewY: state.transformPendingSkewY,
perspectiveX: state.transformPendingPerspX,
perspectiveY: state.transformPendingPerspY,
distort: state.transformPendingDistort,
centerX: state.transformCenter?.x,
centerY: state.transformCenter?.y,
};
@@ -592,6 +608,7 @@ export function createTransformSession({
state.transformPendingSkewY = target.skewY || 0;
state.transformPendingPerspX = target.perspectiveX || 0;
state.transformPendingPerspY = target.perspectiveY || 0;
state.transformPendingDistort = target.distort || null;
state.transformCenter = { x: target.centerX, y: target.centerY };
}
@@ -654,7 +671,12 @@ export function createTransformSession({
for (const { snapshot, geometry: plannedGeometry } of plan.items) {
const layer = snapshot.layer;
let geometry = plannedGeometry;
if (snapshot.placed && (snapshot.wasPlaced || state.transformPreserveSource !== false)) {
// A free-distort quad is a non-affine warp that placed-layer data cannot
// express, so bake it into the raster instead of the placed source.
const usePlaced = snapshot.placed
&& (snapshot.wasPlaced || state.transformPreserveSource !== false)
&& !plannedGeometry.distort;
if (usePlaced) {
layer.placed = transformPlacedData(snapshot.placed, documentTransform);
const rendered = renderPlacedLayer(layer);
geometry = {
@@ -664,7 +686,7 @@ export function createTransformSession({
offset: rendered.offset,
};
} else {
if (snapshot.kind === 'raster') {
if (snapshot.kind === 'raster' || plannedGeometry.distort) {
layer.kind = 'raster';
layer.placed = null;
}
@@ -791,6 +813,7 @@ export function createTransformSession({
state.transformPendingSkewY = 0;
state.transformPendingPerspX = 0;
state.transformPendingPerspY = 0;
state.transformPendingDistort = null;
state.transformActive = false;
state.transformTarget = null;
state.transformLayer = null;
+130 -9
View File
@@ -25,23 +25,56 @@ export function transformFrameGeometry(frame, options = {}) {
const radians = rotation * Math.PI / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const rotateLocal = (localX, localY) => ({
x: centerX + localX * cos - localY * sin,
y: centerY + localX * sin + localY * cos,
});
const resizeHandles = HANDLE_LAYOUT.map(([id, xFactor, yFactor]) => ({
// Skew (degrees) shears the frame in its LOCAL axes — the same order the
// canvas render pipeline uses (scale → skew → rotate). Zero skew leaves
// the geometry byte-for-byte identical to the un-skewed frame.
const skewX = finite(frame?.skewX);
const skewY = finite(frame?.skewY);
const shearX = Math.tan(skewX * Math.PI / 180);
const shearY = Math.tan(skewY * Math.PI / 180);
const localToWorld = (localX, localY) => {
const sx = localX + shearX * localY;
const sy = localY + shearY * localX;
return {
x: centerX + sx * cos - sy * sin,
y: centerY + sx * sin + sy * cos,
};
};
// Free distort (Ctrl/Cmd+drag): per-corner offsets in frame-normal units.
// When present, the corner/edge handles follow the quad so only the dragged
// point moves; every other point stays where it was.
const distort = frame?.distort || null;
const localOf = (() => {
if (!distort) {
return Object.fromEntries(HANDLE_LAYOUT.map(([id, xFactor, yFactor]) => [id, { x: width * xFactor, y: height * yFactor }]));
}
const delta = key => distort[key] || { x: 0, y: 0 };
const corner = (key, sx, sy) => ({
x: (sx + delta(key).x) * width,
y: (sy + delta(key).y) * height,
});
const tl = corner('tl', -0.5, -0.5);
const tr = corner('tr', 0.5, -0.5);
const br = corner('br', 0.5, 0.5);
const bl = corner('bl', -0.5, 0.5);
const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
return { tl, t: mid(tl, tr), tr, r: mid(tr, br), br, b: mid(br, bl), bl, l: mid(bl, tl) };
})();
const resizeHandles = HANDLE_LAYOUT.map(([id]) => ({
id,
...rotateLocal(width * xFactor, height * yFactor),
...localToWorld(localOf[id].x, localOf[id].y),
}));
const byId = Object.fromEntries(resizeHandles.map(handle => [handle.id, handle]));
const zoom = Math.max(0.0001, finite(options.zoom, 1));
const rotationOffset = Math.max(0, finite(options.rotationOffsetCss, 24)) / zoom;
const topMidX = localOf.t ? localOf.t.x : 0;
const topMidY = localOf.t ? localOf.t.y : -height / 2;
const rotationLocalY = options.rotationInside
? -Math.max(0, height / 2 - rotationOffset)
: -(height / 2 + rotationOffset);
? topMidY + Math.max(0, rotationOffset)
: topMidY - rotationOffset;
const rotationHandle = {
id: 'rot',
...rotateLocal(0, rotationLocalY),
...localToWorld(topMidX, rotationLocalY),
};
return {
@@ -51,6 +84,9 @@ export function transformFrameGeometry(frame, options = {}) {
height,
rotation,
radians,
skewX,
skewY,
distort,
resizeHandles,
handles: [...resizeHandles, rotationHandle],
rotationHandle,
@@ -149,3 +185,88 @@ export function resizeTransformFrame(frame, handle, startPoint, currentPoint, op
flipV: !!options.flipV !== (rawHeight < 0),
};
}
/**
* Skew a frame from a screen-space pointer delta on its rotated axes,
* mirroring Photoshop's Ctrl+drag on a handle.
*
* top / bottom handles → horizontal drag shears vertically-offset
* rows (skewX)
* left / right handles → vertical drag shears horizontally-offset
* columns (skewY)
* corner handles → both
*
* Returns { skewX, skewY } in degrees relative to `options.startSkew*`,
* clamped to ±85° so the tangent never blows up. Size, centre and
* rotation are untouched — skew is a separate slot in the transform
* target, exactly like the popup's Skew X / Skew Y fields.
*/
export function skewTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {
const geometry = transformFrameGeometry(frame);
const xSign = handle?.includes('l') ? -1 : handle?.includes('r') ? 1 : 0;
const ySign = handle?.includes('t') ? -1 : handle?.includes('b') ? 1 : 0;
const worldDx = finite(currentPoint?.x) - finite(startPoint?.x);
const worldDy = finite(currentPoint?.y) - finite(startPoint?.y);
const cos = Math.cos(geometry.radians);
const sin = Math.sin(geometry.radians);
const localDx = worldDx * cos + worldDy * sin;
const localDy = -worldDx * sin + worldDy * cos;
const toDegrees = 180 / Math.PI;
const LIMIT = 85;
const clamp = value => Math.max(-LIMIT, Math.min(LIMIT, value));
const startSkewX = finite(options.startSkewX, 0);
const startSkewY = finite(options.startSkewY, 0);
let skewX = startSkewX;
let skewY = startSkewY;
// Top/bottom edge: the dragged edge follows the horizontal delta.
if (ySign) skewX = clamp(startSkewX + Math.atan2(2 * localDx / ySign, geometry.height) * toDegrees);
// Left/right edge: the dragged edge follows the vertical delta.
if (xSign) skewY = clamp(startSkewY + Math.atan2(2 * localDy / xSign, geometry.width) * toDegrees);
return { skewX, skewY };
}
/**
* Free distort from a screen-space pointer delta on a handle, mirroring
* Photoshop's Ctrl+drag: ONLY the dragged point moves.
*
* corner handle → that corner alone follows the pointer (the frame
* becomes a quad)
* edge handle → both corners of that edge move together, so the edge
* slides/keeps its length but nothing else moves
*
* Corner offsets are returned in FRAME-NORMAL units (a fraction of the
* frame's width/height; ±0.5 is the frame half-size) relative to
* `options.startDistort`, so they compose with any existing distort.
* Size, centre, rotation and skew are untouched.
*/
export function distortTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {
const geometry = transformFrameGeometry(frame);
const corners = ['tl', 'tr', 'br', 'bl'];
const owners = {
tl: ['tl'], tr: ['tr'], br: ['br'], bl: ['bl'],
t: ['tl', 'tr'], b: ['bl', 'br'], l: ['tl', 'bl'], r: ['tr', 'br'],
}[handle];
if (!owners) return null;
const worldDx = finite(currentPoint?.x) - finite(startPoint?.x);
const worldDy = finite(currentPoint?.y) - finite(startPoint?.y);
const cos = Math.cos(geometry.radians);
const sin = Math.sin(geometry.radians);
const localDx = worldDx * cos + worldDy * sin;
const localDy = -worldDx * sin + worldDy * cos;
const ndx = localDx / geometry.width;
const ndy = localDy / geometry.height;
const start = options.startDistort || null;
const base = key => (start && start[key] ? { x: finite(start[key].x), y: finite(start[key].y) } : { x: 0, y: 0 });
const LIMIT = 4;
const clamp = value => Math.max(-LIMIT, Math.min(LIMIT, value));
const next = {};
for (const key of corners) {
if (owners.includes(key)) next[key] = { x: clamp(base(key).x + ndx), y: clamp(base(key).y + ndy) };
else next[key] = base(key);
}
return next;
}
+33 -3
View File
@@ -25,6 +25,10 @@
* combineShapes: (op: string) => void,
* syncWindowChecks?: (menu: HTMLElement | null) => void,
* togglePanel?: (id: string) => void,
* saveWorkspace?: () => void,
* newDocument?: (w: number, h: number) => void,
* promptNewDocument?: () => Promise<{ w: number, h: number } | null>,
* newFromClipboard?: () => void,
* }} deps
*/
import { wireSubmenuToggles } from './submenu-toggles.js';
@@ -88,8 +92,19 @@ function wireMenuKeyboard(container) {
});
}
export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel }) {
export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, saveWorkspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel, toggleQuickEdit, newDocument, promptNewDocument, newFromClipboard }) {
const click = (id) => document.getElementById(id)?.click();
// Picking a workspace is a one-shot choice, so it closes the Window menu
// (and any open flyout) unlike the panel toggles, which keep it open.
const closeWindowMenu = () => {
const menu = document.getElementById('ge-window-menu');
if (!menu) return;
menu.querySelectorAll('.ge-menu-parent.open').forEach((parent) => {
parent.classList.remove('open');
parent.setAttribute('aria-expanded', 'false');
});
menu.hidden = true;
};
// A menu row may synthesise a click on an external control (Window/Edit >
// History clicks #ge-history-btn). That click bubbles to the document and
// would trip our own click-away handler, closing the menu mid-toggle — so
@@ -169,16 +184,31 @@ export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, do
const action = item.dataset.windowAction;
if (action === 'history') click('ge-history-btn');
else if (action === 'shortcuts') click('ge-shortcuts-btn');
else if (action.startsWith('workspace-')) {
else if (action === 'workspace-save') {
saveWorkspace?.();
closeWindowMenu();
} else if (action.startsWith('workspace-')) {
const name = action.slice('workspace-'.length);
try { localStorage.setItem('ge-active-workspace', name); } catch {}
workspace?.(name);
} else if (WINDOW_PANEL_ACTIONS.includes(action)) togglePanel?.(action);
closeWindowMenu();
} else if (action === 'quick-edit') toggleQuickEdit?.();
else if (WINDOW_PANEL_ACTIONS.includes(action)) togglePanel?.(action);
}, () => syncWindowChecks?.(document.getElementById('ge-window-menu')), true);
bind('ge-file-menu-btn', 'ge-file-menu', (item) => {
const action = item.dataset.fileAction;
if (action === 'import') click('ge-import-topbar');
else if (action === 'new-default') newDocument?.(1920, 1080);
else if (action === 'new-square') newDocument?.(1080, 1080);
else if (action === 'new-portrait') newDocument?.(1080, 1350);
else if (action === 'new-story') newDocument?.(1080, 1920);
else if (action === 'new-clipboard') newFromClipboard?.();
else if (action === 'new-custom') {
Promise.resolve(promptNewDocument?.()).then((size) => {
if (size && size.w > 0 && size.h > 0) newDocument?.(size.w, size.h);
});
}
else if (action === 'save') click('ge-save');
else if (action === 'save-copy') click('ge-export-gallery');
else if (action === 'export') click('ge-download');
+83 -2
View File
@@ -168,6 +168,33 @@ export function wireSelectionControls({
});
document.getElementById('ge-lasso-invert')?.addEventListener('click', invertSelection);
// Lasso "More" dropdown holding the secondary controls (feather, edge
// stroke, magnetic + its width/contrast) so the lasso strip stays short.
const lassoMore = document.getElementById('ge-lasso-more');
const lassoMoreMenu = document.getElementById('ge-lasso-more-menu');
const closeLassoMore = () => {
if (!lassoMoreMenu || lassoMoreMenu.hidden) return;
lassoMoreMenu.hidden = true;
lassoMore?.setAttribute('aria-expanded', 'false');
lassoMore?.classList.remove('active');
};
lassoMore?.addEventListener('click', (e) => {
e.stopPropagation();
if (!lassoMoreMenu) return;
const open = lassoMoreMenu.hidden;
lassoMoreMenu.hidden = !open;
lassoMore.setAttribute('aria-expanded', open ? 'true' : 'false');
lassoMore.classList.toggle('active', open);
});
document.addEventListener('click', (e) => {
if (!lassoMoreMenu || lassoMoreMenu.hidden) return;
if (lassoMoreMenu.contains(e.target) || e.target === lassoMore) return;
closeLassoMore();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeLassoMore();
});
// ── Wand section ──
document.getElementById('ge-wand-feather')?.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10) || 0;
@@ -226,10 +253,10 @@ export function wireSelectionControls({
});
});
// Toggle the translucent red overlay for the wand selection.
// Toggle the translucent red overlay for the active selection.
const toggleSelectionOverlay = () => {
state.wandMaskVisible = !state.wandMaskVisible;
['ge-wand-vis', 'ge-marquee-vis', 'ge-sam-vis'].forEach(id => {
['ge-wand-vis', 'ge-marquee-vis', 'ge-lasso-vis', 'ge-sam-vis', 'ge-quickselect-vis', 'ge-objectselect-vis'].forEach(id => {
const btn = document.getElementById(id);
if (!btn) return;
btn.innerHTML = state.wandMaskVisible ? EYE_OPEN : EYE_OFF;
@@ -240,12 +267,66 @@ export function wireSelectionControls({
};
document.getElementById('ge-wand-vis')?.addEventListener('click', toggleSelectionOverlay);
document.getElementById('ge-marquee-vis')?.addEventListener('click', toggleSelectionOverlay);
document.getElementById('ge-lasso-vis')?.addEventListener('click', toggleSelectionOverlay);
document.getElementById('ge-wand-clear')?.addEventListener('click', wandClear);
document.getElementById('ge-lasso-clear')?.addEventListener('click', wandClear);
document.getElementById('ge-wand-invert')?.addEventListener('click', invertSelection);
document.getElementById('ge-wand-delete')?.addEventListener('click', wandDeleteSelection);
document.getElementById('ge-wand-copy')?.addEventListener('click', wandCopyToNewLayer);
document.getElementById('ge-wand-mask')?.addEventListener('click', wandToMask);
// SAM / Quick Select / Object Select share the wand's selection model, so
// they get the identical action row and the same caret "More" menu pattern
// (the shared CSS lives on the ge-wand-more-* classes).
const wireSelectionCaret = (prefix) => {
const btn = document.getElementById(`${prefix}-more`);
const menu = document.getElementById(`${prefix}-more-menu`);
if (!btn || !menu) return;
const closeMenu = () => {
if (menu.hidden) return;
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('active');
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = menu.hidden;
menu.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
btn.classList.toggle('active', open);
});
document.addEventListener('click', (e) => {
if (menu.hidden) return;
if (menu.contains(e.target) || e.target === btn) return;
closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
};
['ge-sam', 'ge-quickselect', 'ge-objectselect'].forEach((prefix) => {
document.getElementById(`${prefix}-vis`)?.addEventListener('click', toggleSelectionOverlay);
document.getElementById(`${prefix}-clear`)?.addEventListener('click', wandClear);
document.getElementById(`${prefix}-invert`)?.addEventListener('click', invertSelection);
document.getElementById(`${prefix}-delete`)?.addEventListener('click', wandDeleteSelection);
document.getElementById(`${prefix}-copy`)?.addEventListener('click', wandCopyToNewLayer);
document.getElementById(`${prefix}-mask`)?.addEventListener('click', wandToMask);
wireSelectionCaret(prefix);
});
// Quick Select brush size was an inert slider; wire it to the tool's brush
// size so the control actually changes the selection brush.
const quickSize = document.getElementById('ge-quickselect-size');
if (quickSize) {
const quickSizeValue = document.getElementById('ge-quickselect-size-label');
const syncQuickSize = () => {
state.quickSelectBrushSize = Math.max(4, Math.min(200, parseInt(quickSize.value, 10) || 24));
if (quickSizeValue) quickSizeValue.textContent = String(state.quickSelectBrushSize);
};
quickSize.addEventListener('input', syncQuickSize);
syncQuickSize();
}
// Selection-constrained Bg Remove — reuses the same path the toolbar
// Bg Remove button does. buildSelectionHintMask picks the active
// wand/lasso selection, so this just kicks off the existing flow.
+13 -2
View File
@@ -13,12 +13,23 @@ export const WORKSPACES = Object.freeze({
photography: Object.freeze({ label: 'Photography', tab: 'histogram', width: 320, panels: Object.freeze(['layers', 'options', 'histogram', 'info', 'navigator', 'adjustments']) }),
painting: Object.freeze({ label: 'Painting', tab: 'swatches', width: 300, panels: Object.freeze(['layers', 'options', 'swatches', 'gradients', 'patterns', 'character']) }),
graphic: Object.freeze({ label: 'Graphic & Web', tab: 'options', width: 320, panels: Object.freeze(['layers', 'options', 'character', 'properties', 'adjustments', 'paths', 'info', 'history']) }),
minimal: Object.freeze({ label: 'Minimal', tab: 'layers', width: 280, panels: Object.freeze(['layers', 'history']) }),
});
export function applyWorkspace(name, { dock = null, rightPanel = null, setPanelVisible = null, persistKey = 'ge-right-panel-width' } = {}) {
const workspace = WORKSPACES[name];
let workspace = WORKSPACES[name];
if (!workspace) {
// A user-saved workspace ("Save Current") is stored in localStorage under
// `ge-workspace-<name>` rather than in the frozen preset table.
try {
const raw = localStorage.getItem(`ge-workspace-${name}`);
if (raw) workspace = JSON.parse(raw);
} catch (error) {
/* storage unavailable — ignore */
}
}
if (!workspace) return false;
if (dock && typeof dock.show === 'function') dock.show(workspace.tab);
if (dock && typeof dock.show === 'function' && workspace.tab) dock.show(workspace.tab);
if (rightPanel && Number.isFinite(workspace.width)) {
rightPanel.style.width = `${workspace.width}px`;
try {
+207 -11
View File
@@ -22,7 +22,7 @@ import {
} from './utils.js';
import { state } from './state.js';
import { getSettings } from '../appConfig.js';
import { collapseSidebarToRail } from '../modalSnap.js';
import { clampWindowLeft, collapseSidebarToRail, leftNavRight, safeInsetPx } from '../modalSnap.js';
import { emailApiUrl } from '../emailShared.js';
import {
bindMenuDismiss,
@@ -823,7 +823,7 @@ function _readCssPx(name) {
}
function _emailSplitLeftEdge() {
return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w');
return leftNavRight();
}
function _setEmailDocumentSplit(leftEdge, emailWidth) {
@@ -899,7 +899,8 @@ function _clearEmailDocumentSplit() {
// rail. Used by the "try collapsing the sidebar first" path so we can decide
// whether collapsing recovers enough room before minimizing email.
function _emailSplitLeftEdgeIfSidebarCollapsed() {
return _readCssPx('--icon-rail-w');
const navOnRight = document.getElementById('sidebar')?.classList.contains('right-side');
return safeInsetPx('left') + (navOnRight ? 0 : _readCssPx('--icon-rail-w'));
}
function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) {
@@ -1122,7 +1123,175 @@ function _filenameFromContentDisposition(header) {
return bare ? bare[1].trim() : '';
}
export async function _exportSelectedAttachments() {
async function _printSelectedEmails(messages) {
if (messages.length > 20) throw new Error('Print / save PDF supports up to 20 emails at a time');
if (typeof window.print !== 'function') throw new Error('Browser printing is unavailable');
const loaded = new Array(messages.length);
let nextIndex = 0;
const readWorker = async () => {
while (nextIndex < messages.length) {
const index = nextIndex++;
const message = messages[index];
try {
const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : '';
const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`;
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data?.error) throw new Error(data.error);
loaded[index] = { ok: true, message, data };
} catch (err) {
loaded[index] = { ok: false, message, error: err?.message || 'Failed to load' };
}
}
};
await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker));
const successful = loaded.filter(item => item && item.ok);
const omitted = loaded.filter(item => !item || !item.ok);
if (successful.length === 0) {
throw new Error('None of the selected emails could be loaded for printing');
}
// Build sanitized HTML for the print frame -- scripts and event handlers
// cannot execute inside the sandboxed iframe.
const articles = [];
if (omitted.length > 0) {
const count = omitted.length;
articles.push(
`<div class="email-export-pdf-omitted-notice">Note: ${count} selected email${count === 1 ? ' was' : 's were'} unavailable and omitted from this print.</div>`
);
}
for (const { message, data } of successful) {
const subject = String(data.subject || message.subject || '(no subject)');
const from = data.from_name && data.from_address
? `${data.from_name} <${data.from_address}>`
: data.from_name || data.from_address;
const safeBody = new DOMParser().parseFromString(_safeRenderEmailBody(data), 'text/html');
// Replace interactive image placeholder components with clean print-safe notices
safeBody.querySelectorAll('.email-inline-image-placeholder').forEach(ph => {
const title = ph.querySelector('.email-inline-image-title')?.textContent?.trim();
const ariaLabel = ph.getAttribute('aria-label')?.trim();
const label = title || ariaLabel || '';
const isGeneric = !label || /^inline image$|^remote image$|^image unavailable$|^image$/i.test(label);
const span = safeBody.createElement('span');
span.className = 'email-export-image-notice';
span.textContent = isGeneric ? '[Image omitted]' : `[Image: ${label}]`;
ph.replaceWith(span);
});
// Replace any remaining raw <img> tags
safeBody.querySelectorAll('img').forEach(img => {
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
const span = safeBody.createElement('span');
span.className = 'email-export-image-notice';
span.textContent = alt ? `[Image: ${alt}]` : '[Image omitted]';
img.replaceWith(span);
});
// Strip interactive controls (buttons, inputs, actions) from the print document
safeBody.querySelectorAll('button, input, select, textarea, .email-inline-image-actions, .email-inline-image-btn').forEach(el => el.remove());
safeBody.querySelectorAll('details').forEach(detail => { detail.open = true; });
const bodyHtml = safeBody.body.innerHTML;
const fieldParts = [];
for (const [lbl, val] of [['From', from], ['To', data.to], ['Cc', data.cc], ['Date', data.date_display || data.date || message.date]]) {
if (!val) continue;
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
fieldParts.push(`<div class="email-export-pdf-field"><span class="email-export-pdf-label">${esc(lbl)}</span><span>${esc(val)}</span></div>`);
}
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
articles.push(
`<article class="email-export-pdf-message">` +
`<h1>${esc(subject)}</h1>` +
`<div class="email-export-pdf-fields">${fieldParts.join('')}</div>` +
`<div class="email-export-pdf-body">${bodyHtml}</div>` +
`</article>`
);
}
const printCss = [
'html,body{background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;margin:0;padding:0}',
'.email-export-pdf{max-width:794px;margin:0 auto;padding:38px;box-sizing:border-box}',
'.email-export-pdf-message{page-break-after:always;break-after:page}',
'.email-export-pdf-message:last-child{page-break-after:auto;break-after:auto}',
'.email-export-pdf-message h1{margin:0 0 18px;font-size:20px;line-height:1.3;overflow-wrap:anywhere}',
'.email-export-pdf-fields{margin:0 0 20px;padding:0 0 14px;border-bottom:1px solid #d8d8d8}',
'.email-export-pdf-field{display:flex;gap:10px;margin:3px 0;overflow-wrap:anywhere}',
'.email-export-pdf-label{width:54px;flex:0 0 54px;color:#666}',
'.email-export-pdf-body{overflow-wrap:anywhere;word-break:break-word}',
'.email-export-pdf-body img{max-width:100%}',
'.email-export-pdf-body a{color:#135f9b;text-decoration:underline}',
'.email-export-pdf-body blockquote{margin-left:12px;padding-left:10px;border-left:2px solid #ccc;color:#555}',
'.email-export-pdf-body pre{white-space:pre-wrap}',
'.email-inline-image-placeholder,.email-inline-image-actions,.email-inline-image-btn{display:none !important}',
'.email-export-image-notice{display:inline-block;color:#666;font-style:italic;font-size:11px;margin:4px 0}',
'.email-export-pdf-omitted-notice{margin:0 0 16px;padding:8px 12px;background:#f8f9fa;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;color:#64748b}',
'@page{margin:10mm}',
].join('\n');
const safeBaseName = successful.length === 1
? String(successful[0]?.data?.subject || successful[0]?.message?.subject || 'email').replace(/[^\p{L}\p{N} ._-]+/gu, '_').trim().slice(0, 100) || 'email'
: 'selected-emails';
// Use a sandboxed same-origin iframe following the established document.js
// Print / save PDF pattern (SAN-157). The browser opens a print dialog;
// saving, cancellation and pagination belong to the browser.
document.getElementById('email-browser-print-frame')?.remove();
const frame = document.createElement('iframe');
frame.id = 'email-browser-print-frame';
frame.title = 'Email print view';
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;';
document.body.appendChild(frame);
let cleanedUp = false;
const cleanupFrame = () => {
if (cleanedUp) return;
cleanedUp = true;
try {
frame.remove();
} catch (_) {}
window.removeEventListener('afterprint', cleanupFrame);
};
try {
const printDoc = frame.contentDocument;
printDoc.open();
printDoc.write('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
printDoc.close();
printDoc.title = safeBaseName;
const printStyle = printDoc.createElement('style');
printStyle.textContent = printCss;
printDoc.head.appendChild(printStyle);
const container = printDoc.createElement('div');
container.className = 'email-export-pdf';
container.innerHTML = articles.join('');
printDoc.body.appendChild(container);
await printDoc.fonts.ready;
frame.contentWindow?.addEventListener('afterprint', cleanupFrame, { once: true });
window.addEventListener('afterprint', cleanupFrame, { once: true });
setTimeout(cleanupFrame, 60000);
frame.contentWindow.focus();
frame.contentWindow.print();
if (omitted.length > 0) {
const msg = `Prepared ${successful.length} email${successful.length === 1 ? '' : 's'} for printing; ${omitted.length} unavailable`;
showToast(msg);
}
} catch (error) {
cleanupFrame();
throw error;
}
}
async function _exportSelectedBundle(kind) {
const messages = _selectedEmailExportPayload();
if (!messages.length) {
showToast('Select emails first');
@@ -1150,8 +1319,14 @@ export async function _exportSelectedAttachments() {
if (deleteBtn) deleteBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`;
const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, {
const isEmailExport = kind === 'emails';
const isPrintExport = kind === 'print';
if (countEl) countEl.textContent = `${isPrintExport ? 'Preparing print' : 'Exporting'} ${isPrintExport ? 'for' : isEmailExport ? 'emails from' : 'attachments from'} ${messages.length}…`;
if (isPrintExport) {
await _printSelectedEmails(messages);
return;
}
const res = await fetch(`${API_BASE}/api/email/${isEmailExport ? 'messages' : 'attachments'}-download-bulk`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1169,12 +1344,21 @@ export async function _exportSelectedAttachments() {
throw new Error(detail || `HTTP ${res.status}`);
}
const blob = await res.blob();
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip';
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition'))
|| (isEmailExport
? (messages.length === 1 ? 'selected-email.eml' : 'selected-emails.zip')
: 'selected-email-attachments.zip');
_downloadBlob(blob, filename);
showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
const exported = Number(res.headers.get('X-Exported-Emails')) || messages.length;
const skipped = Number(res.headers.get('X-Skipped-Emails')) || 0;
showToast(skipped
? `Exported ${exported} email${exported === 1 ? '' : 's'}; ${skipped} unavailable`
: isEmailExport
? `Exported ${exported} email${exported === 1 ? '' : 's'} as ${exported === 1 ? 'an .eml file' : '.eml files'}`
: `Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
} catch (err) {
console.error('Failed to export selected email attachments:', err);
showToast(err?.message || 'Failed to export attachments');
console.error(`Failed to export selected email ${kind}:`, err);
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : kind === 'print' ? 'Could not open browser print' : 'Failed to export attachments'));
} finally {
if (busySpinner) busySpinner.destroy();
if (actionsBtn) {
@@ -1190,6 +1374,18 @@ export async function _exportSelectedAttachments() {
}
}
export async function _exportSelectedEmails() {
return _exportSelectedBundle('emails');
}
export async function _exportSelectedEmailPdf() {
return _exportSelectedBundle('print');
}
export async function _exportSelectedAttachments() {
return _exportSelectedBundle('attachments');
}
// URL-suffix helper — appends &account_id=... when an account is actively selected.
// Every email route call in this file goes through here so switching accounts
// is a single-variable flip.
@@ -2415,7 +2611,7 @@ export function openEmailLibrary(opts = {}) {
requestAnimationFrame(() => {
const w = content.offsetWidth;
const refH = window.innerHeight * 0.85;
content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px';
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2), w) + 'px';
content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px';
content.style.transform = 'none';
});
+44 -2
View File
@@ -25,6 +25,8 @@ import {
_animateEmailCardRemoval,
_clearDoneResponseTagsLocal,
_emailMutationQuery,
_exportSelectedEmailPdf,
_exportSelectedEmails,
_exportSelectedAttachments,
_findSiblingEmailCard,
_fitEmailDropdown,
@@ -585,7 +587,10 @@ export function _showBulkActionsMenu(anchor) {
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const _emailIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
const _pdfIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 15h8M8 18h5"/></svg>';
const items = [
{ label: 'Export Emails', icon: _exportIco, submenu: true },
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
@@ -594,8 +599,45 @@ export function _showBulkActionsMenu(anchor) {
for (const a of items) {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
it.className += a.submenu ? ' email-bulk-submenu-trigger' : '';
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>${a.submenu ? '<span style="margin-left:auto;opacity:0.55;">›</span>' : ''}`;
it.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu) {
dropdown.replaceChildren();
const back = document.createElement('div');
back.className = 'dropdown-item-compact email-bulk-submenu-back';
back.innerHTML = '<span style="opacity:.65;margin-right:4px;">‹</span><span>Export Emails</span>';
back.addEventListener('click', (ev) => {
ev.stopPropagation();
close();
_showBulkActionsMenu(anchor);
});
dropdown.appendChild(back);
const divider = document.createElement('div');
divider.className = 'dropdown-divider';
dropdown.appendChild(divider);
const options = [
{ label: 'Email file (.eml)', icon: _emailIco, action: _exportSelectedEmails },
{ label: 'Print / save PDF', icon: _pdfIco, action: _exportSelectedEmailPdf },
];
for (const option of options) {
const child = document.createElement('div');
child.className = 'dropdown-item-compact';
child.innerHTML = `<span class="dropdown-icon">${option.icon}</span><span>${option.label}</span>`;
child.addEventListener('click', (ev) => {
ev.stopPropagation();
close();
option.action();
});
dropdown.appendChild(child);
}
_fitEmailDropdown(dropdown, anchor.getBoundingClientRect());
return;
}
close();
a.action();
});
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
+2 -1
View File
@@ -11,6 +11,7 @@
import spinnerModule from '../spinner.js';
import * as Modals from '../modalManager.js';
import { clampWindowLeft } from '../modalSnap.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
@@ -428,7 +429,7 @@ export async function _openEmailWindow(em, folder) {
requestAnimationFrame(() => {
const w = content.offsetWidth, h = content.offsetHeight;
const off = (_emailWindowSeq % 6) * 28;
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2 + off), w) + 'px';
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
});
}
+1 -1
View File
@@ -190,7 +190,7 @@ export function createPanel({
// which left it as a side panel squeezing the chat).
if (window.innerWidth <= 768) {
pane.style.position = 'fixed';
pane.style.inset = '0';
pane.style.inset = 'var(--sheet-top) 0 0';
pane.style.width = '100%';
pane.style.maxWidth = '100%';
pane.style.zIndex = '170';
+893 -185
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -369,6 +369,31 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
/* ---- Mobile viewport fix — keep chat visible when virtual keyboard opens ---- */
{
if (window.visualViewport) {
const root = document.documentElement;
// iOS overlays the keyboard without resizing the layout viewport and scrolls
// the page to reveal the field, leaving it shifted after the keyboard closes.
const _syncKeyboardViewport = function() {
const vv = window.visualViewport;
if (vv.scale > 1.01) return;
const keyboardOpen = window.innerHeight - vv.height > 120;
root.classList.toggle('keyboard-open', keyboardOpen);
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
else root.style.removeProperty('--keyboard-viewport-height');
if (window.scrollY) window.scrollTo(0, 0);
};
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
// the tapped field already had focus (e.g. the composer after a new chat)
const _syncThroughKeyboardAnimation = () => {
_syncKeyboardViewport();
for (const delay of [100, 300, 600, 1000]) setTimeout(_syncKeyboardViewport, delay);
};
_syncKeyboardViewport();
window.visualViewport.addEventListener('resize', _syncThroughKeyboardAnimation);
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() {
const vv = window.visualViewport;
+45 -7
View File
@@ -110,7 +110,7 @@ function _clampRightDockWidth(width) {
const min = _minEdgeDockWidth();
const navRight = _leftNavRight();
const leftDockW = _activeDockWidth('left');
const maxByChat = window.innerWidth - navRight - leftDockW - MIN_CHAT_WIDTH;
const maxByChat = window.innerWidth - navRight - _rightNavInset() - leftDockW - MIN_CHAT_WIDTH;
const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat);
return _clampDockWidthToSpace(width, min, max);
}
@@ -118,7 +118,7 @@ function _clampRightDockWidth(width) {
function _clampLeftDockWidth(width, left = _leftNavRight()) {
const min = _minEdgeDockWidth();
const rightDockW = _activeDockWidth('right');
const available = Math.max(0, window.innerWidth - left - rightDockW);
const available = Math.max(0, window.innerWidth - left - _rightNavInset() - rightDockW);
const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH);
return _clampDockWidthToSpace(width, min, max);
}
@@ -170,13 +170,23 @@ function _shouldAutoCollapseSidebar(dockW) {
return remaining < MIN_CHAT_WIDTH;
}
function _navOnRight() {
return !!document.getElementById('sidebar')?.classList.contains('right-side');
}
// Body padding carries the device safe-area insets (notch, Dynamic Island).
function _bodyPaddingPx(side) {
return parseFloat(window.getComputedStyle(document.body)[side === 'left' ? 'paddingLeft' : 'paddingRight']) || 0;
}
// Right edge (px) of whatever left navigation is currently showing — the
// expanded sidebar if visible, otherwise the icon rail. Used to anchor the
// left dock so it always sits flush to the right of the nav.
function _leftNavRight() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
let x = 0;
let x = _bodyPaddingPx('left');
if (_navOnRight()) return x;
if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect();
if (r.width) x = Math.max(x, r.right);
@@ -188,8 +198,36 @@ function _leftNavRight() {
return x;
}
// Distance (px) from the viewport's right edge that a right-side nav and the
// safe area occupy. The right dock sits flush to the left of it.
function _rightNavInset() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
let inset = _bodyPaddingPx('right');
if (!_navOnRight()) return inset;
if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect();
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
}
if (rail && window.getComputedStyle(rail).display !== 'none') {
const r = rail.getBoundingClientRect();
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
}
return inset;
}
export function leftNavRight() { return _leftNavRight(); }
export function rightNavInset() { return _rightNavInset(); }
export function safeInsetPx(side) { return _bodyPaddingPx(side); }
// Keeps a floating window between the nav and the safe areas
export function clampWindowLeft(left, width) {
const max = window.innerWidth - _rightNavInset() - width;
return Math.max(_leftNavRight(), Math.min(left, max));
}
function _clampEmailDocSplitWidth(width, left = _leftNavRight()) {
const available = Math.max(0, window.innerWidth - left);
const available = Math.max(0, window.innerWidth - left - _rightNavInset());
if (!available) return 0;
const compact = available < 760;
const minEmail = compact ? 260 : 340;
@@ -235,7 +273,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) {
if (!docPane || window.innerWidth <= 768) return;
docPane.style.setProperty('position', 'fixed', 'important');
docPane.style.setProperty('left', `${x}px`, 'important');
docPane.style.setProperty('right', 'var(--right-dock-w, 0px)', 'important');
docPane.style.setProperty('right', `calc(var(--right-dock-w, 0px) + ${_rightNavInset()}px)`, 'important');
docPane.style.setProperty('top', '0px', 'important');
docPane.style.setProperty('bottom', '0px', 'important');
docPane.style.setProperty('width', 'auto', 'important');
@@ -497,7 +535,7 @@ function _applyDockInternal(modal, side, dockClass) {
} else {
w = _resolveRightDockWidth(modal, content);
content.style.left = 'auto';
content.style.right = '0';
content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
@@ -872,7 +910,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
w = _clampRightDockWidth(window.innerWidth - clientX);
content._userDockWidth = w;
content.style.left = 'auto';
content.style.right = '0';
content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
+5 -1
View File
@@ -75,12 +75,15 @@ export function initSidebarLayout(Storage, opts) {
_setSidebarModeClasses(mode);
}
// Phones in landscape get the desktop layout, but the icon rail is too small to use there
const _phoneLandscape = window.matchMedia('(pointer: coarse) and (max-height: 500px)');
function _syncRailSideCore() {
const sidebar = document.getElementById('sidebar');
if (!iconRail) return;
const isRight = sidebar.classList.contains('right-side');
const sidebarHidden = sidebar.classList.contains('hidden');
const railHidden = iconRail.classList.contains('rail-hidden');
const railHidden = iconRail.classList.contains('rail-hidden') || _phoneLandscape.matches;
const isMobileMini = iconRail.classList.contains('mobile-mini');
iconRail.classList.toggle('right-side', isRight);
// On mobile mini mode, JS already set inline styles — don't touch
@@ -118,6 +121,7 @@ export function initSidebarLayout(Storage, opts) {
// Set initial reference and expose globally
_syncRailSideFn = _syncRailSideCore;
window.syncRailSide = syncRailSide;
_phoneLandscape.addEventListener('change', () => syncRailSide());
// Restore sidebar side preference
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
+51 -2
View File
@@ -31,9 +31,43 @@ function _targetEl(target) {
return target && target.nodeType === 1 ? target : target?.parentElement || null;
}
function _editorPanelVisible() {
if (!document.body || !document.body.classList.contains('ge-editor-open')) return false;
const sel = '.ge-adj-popup, .ge-fx-popup, .ge-fx-menu, .ge-transform-popup, .ge-filter-overlay, .ge-history-panel';
return [...document.querySelectorAll(sel)].some((candidate) => {
if (!document.contains(candidate)) return false;
const style = getComputedStyle(candidate);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
}
function _positionToastForOpenPanel(el) {
if (!el) return;
// The Free Transform hint is pinned by CSS to one fixed spot under the
// canvas; letting the panel rules move it made it hop between the top-right
// corner and the bottom banner depending on which panel happened to be open.
if (el.classList.contains('toast-transform-hint')) {
el.classList.remove('toast-editor-bottom', 'toast-panel-offset');
el.style.left = '';
el.style.right = '';
el.style.maxWidth = '';
return;
}
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
el.classList.remove('toast-editor-bottom');
// In the editor, a floating panel (Adjust/Develop, FX, Transform, filter
// modal, History) is anchored to the top/right; drop the toast to the
// bottom-centre so it can never cover a control (EVAL: "never over an
// active panel"). On phones the CSS already anchors it at the bottom.
if (_editorPanelVisible()) {
el.style.left = '';
el.style.right = '';
el.style.maxWidth = '';
el.classList.add('toast-editor-bottom');
return;
}
const panel = [...document.querySelectorAll('.notes-pane')]
.filter((candidate) => {
if (!document.contains(candidate)) return false;
@@ -174,7 +208,9 @@ function _closeHoveredWindow() {
function _spaceIsBlocked(e, surface) {
const target = _targetEl(e.target);
if (!target) return false;
if (_isTextEditingTarget(target)) return !surface || surface.contains(target);
// Space must always type in a field, even when that field lives in a fixed
// overlay outside the hovered surface (e.g. the editor's on-canvas text box).
if (_isTextEditingTarget(target)) return true;
const blocked = target.closest?.(SPACE_BLOCKED_SELECTOR);
return !!(blocked && (!surface || surface.contains(blocked)));
}
@@ -221,6 +257,10 @@ function _initHoverCardSpaceToggle() {
}, true);
document.addEventListener('keydown', (e) => {
if (e.code !== 'Space' || e.repeat) return;
// The image editor owns Space (hold to pan, otherwise text fields type a
// space). A card/window toggle must never fire while it is open, or the
// hovered gallery card can click itself closed underneath the editor.
if (document.body?.classList.contains('ge-editor-open')) return;
if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {
if (_spaceIsBlocked(e, hoveredToggleCard)) return;
e.preventDefault();
@@ -342,7 +382,7 @@ export function showToast(msg, durationOrOpts) {
_wireToastSwipe(toastEl);
toastEl.textContent = '';
toastEl.classList.remove('error');
toastEl.classList.remove('toast-ai-reply-progress', 'toast-ai-reply-result', 'toast-sending', 'toast-message-sent');
toastEl.classList.remove('toast-ai-reply-progress', 'toast-ai-reply-result', 'toast-sending', 'toast-message-sent', 'toast-transform-hint');
let duration = 1200, actionLabel = null, onAction = null, actionHint = null, actionIcon = null, leadingIcon = null, toastClass = null, aiReplyProgress = false, aiReplyResult = false;
if (typeof durationOrOpts === 'object' && durationOrOpts) {
@@ -358,6 +398,14 @@ export function showToast(msg, durationOrOpts) {
} else if (typeof durationOrOpts === 'number') {
duration = durationOrOpts;
}
// While the editor is open, cap every toast so it auto-dismisses within
// ~2.5s and can never linger over the canvas or a panel (EVAL: "auto-dismiss
// in 2.5s"). Longer informational durations (persist warnings etc.) are
// still readable but do not pile up.
if (typeof document !== 'undefined' && document.body
&& document.body.classList.contains('ge-editor-open')) {
duration = Math.min(duration, 2500);
}
if (aiReplyProgress) toastEl.classList.add('toast-ai-reply-progress');
if (aiReplyResult) toastEl.classList.add('toast-ai-reply-result');
if (toastClass) toastEl.classList.add(toastClass);
@@ -473,6 +521,7 @@ export function showError(msg) {
_wireToastSwipe(toastEl);
toastEl.textContent = '';
toastEl.classList.add('error');
toastEl.classList.remove('toast-transform-hint');
toastEl.style.pointerEvents = '';
toastEl.style.transform = '';
toastEl.classList.remove('exiting');
+8 -1
View File
@@ -257,12 +257,19 @@ export function makeWindowResizable(content, options = {}) {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved && saved.w && saved.h) {
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
const bodyStyle = getComputedStyle(document.body);
const padLeft = parseFloat(bodyStyle.paddingLeft) || 0;
const padRight = parseFloat(bodyStyle.paddingRight) || 0;
const w = Math.max(minW, Math.min(saved.w, window.innerWidth - padLeft - padRight));
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
content.style.width = w + 'px';
content.style.height = h + 'px';
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
const left = parseFloat(content.style.left);
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
}
}
} catch (_) {}
});
+7 -2
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v660-photo-editor';
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -185,6 +185,10 @@ const PRECACHE = [
'/static/lib/katex/katex.min.js',
'/static/lib/katex/katex.min.css',
...KATEX_FONTS,
'/static/manifest.json',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/static/icons/icon-maskable-512.png',
];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
@@ -301,6 +305,7 @@ const PANEL_PRECACHE = [
'/static/js/editor/tools/move.js',
'/static/js/editor/tools/object-select.js',
'/static/js/editor/tools/pen-selection.js',
'/static/js/editor/tools/liquify.js',
'/static/js/editor/tools/pen-path.js',
'/static/js/editor/tools/quick-select.js',
'/static/js/editor/tools/stroke.js',
@@ -318,7 +323,7 @@ const PANEL_PRECACHE = [
'/static/js/editor/wire-topbar.js',
'/static/js/editor/wire-view-menu.js',
// Editor modules reachable only through the lazy galleryEditor.js graph.
'/static/js/editor/build/toolbar.js?v=20261008flyouts',
'/static/js/editor/build/toolbar.js?v=20261010brushpresets',
'/static/js/editor/canvas-context-menu.js',
'/static/js/editor/scroll-affordance.js',
'/static/js/editor/panel-visibility.js',