@@ -2677,4 +2677,190 @@ _setupModalExpand() {
26772677 // divs, settings headers, etc) without depending on h3 internal flex.
26782678 const _injectModalControls = ( ) => {
26792679 document . querySelectorAll ( '.modal' ) . forEach ( modal => {
2680- // Skip promo/cent
2680+ // Skip promo/centered popups and the media gallery (which has its own
2681+ // header close button) — they're not regular modals (#5352)
2682+ if ( modal . classList . contains ( 'android-beta-promo' ) ||
2683+ modal . classList . contains ( 'desktop-promo' ) ||
2684+ modal . classList . contains ( 'donors-modal-box' ) ||
2685+ modal . classList . contains ( 'media-gallery-modal' ) ) return ;
2686+ // Idempotent — skip already-injected
2687+ if ( modal . dataset . modalControlsInjected === '1' ) return ;
2688+ modal . dataset . modalControlsInjected = '1' ;
2689+
2690+ // Settings/activities headers have their own close button — keep it
2691+ // but inject the expand toggle next to it.
2692+ const settingsClose = modal . querySelector ( '.settings-close-btn' ) ;
2693+
2694+ const expandBtn = document . createElement ( 'button' ) ;
2695+ expandBtn . type = 'button' ;
2696+ expandBtn . className = 'modal-expand-btn' ;
2697+ expandBtn . title = 'Expand / Restore' ;
2698+ expandBtn . textContent = '⛶' ;
2699+ expandBtn . addEventListener ( 'click' , ( e ) => {
2700+ e . stopPropagation ( ) ;
2701+ const isMax = modal . classList . toggle ( 'modal-maximized' ) ;
2702+ expandBtn . textContent = isMax ? '⊖' : '⛶' ;
2703+ expandBtn . title = isMax ? 'Restore size' : 'Expand' ;
2704+ } ) ;
2705+
2706+ // When a settings-style header is present, slot the expand button
2707+ // directly next to its close button so the two stay aligned on
2708+ // every viewport size. Otherwise drop both controls into a floating
2709+ // group at the top-right of the modal.
2710+ if ( settingsClose ) {
2711+ expandBtn . classList . add ( 'modal-expand-btn-inline' ) ;
2712+ settingsClose . parentElement . insertBefore ( expandBtn , settingsClose ) ;
2713+ } else {
2714+ const group = document . createElement ( 'div' ) ;
2715+ group . className = 'modal-controls' ;
2716+ group . appendChild ( expandBtn ) ;
2717+
2718+ const closeBtn = document . createElement ( 'button' ) ;
2719+ closeBtn . type = 'button' ;
2720+ closeBtn . className = 'modal-expand-btn' ;
2721+ closeBtn . title = 'Close' ;
2722+ closeBtn . textContent = '✕' ;
2723+ closeBtn . addEventListener ( 'click' , ( e ) => {
2724+ e . stopPropagation ( ) ;
2725+ const overlay = modal . closest ( '.modal-overlay' ) ;
2726+ if ( overlay ) overlay . style . display = 'none' ;
2727+ if ( modal . classList . contains ( 'modal-maximized' ) ) {
2728+ modal . classList . remove ( 'modal-maximized' ) ;
2729+ expandBtn . textContent = '⛶' ;
2730+ expandBtn . title = 'Expand / Restore' ;
2731+ }
2732+ } ) ;
2733+ group . appendChild ( closeBtn ) ;
2734+ modal . appendChild ( group ) ;
2735+ }
2736+ } ) ;
2737+ } ;
2738+ _injectModalControls ( ) ;
2739+ // Re-run if new modals get inserted later (some plugins/lazy templates)
2740+ this . _injectModalControls = _injectModalControls ;
2741+ } ,
2742+
2743+ /** Show a custom image context menu (Save / Copy / Open in tab) */
2744+ _showImageContextMenu ( e , src ) {
2745+ this . _hideImageContextMenu ( ) ;
2746+ const menu = document . createElement ( 'div' ) ;
2747+ menu . id = 'image-context-menu' ;
2748+ menu . className = 'image-context-menu' ;
2749+ menu . innerHTML = `
2750+ <button data-action="save">💾 Save Image</button>
2751+ <button data-action="copy">📋 Copy Image</button>
2752+ <button data-action="open">🔗 Open in New Tab</button>
2753+ ` ;
2754+ menu . style . left = e . clientX + 'px' ;
2755+ menu . style . top = e . clientY + 'px' ;
2756+ document . body . appendChild ( menu ) ;
2757+ // Clamp to viewport
2758+ const rect = menu . getBoundingClientRect ( ) ;
2759+ if ( rect . right > window . innerWidth ) menu . style . left = ( window . innerWidth - rect . width - 8 ) + 'px' ;
2760+ if ( rect . bottom > window . innerHeight ) menu . style . top = ( window . innerHeight - rect . height - 8 ) + 'px' ;
2761+
2762+ menu . addEventListener ( 'click' , async ( ev ) => {
2763+ const action = ev . target . dataset . action ;
2764+ if ( action === 'save' ) {
2765+ const a = document . createElement ( 'a' ) ;
2766+ a . href = src ;
2767+ a . download = src . split ( '/' ) . pop ( ) . split ( '?' ) [ 0 ] || 'image' ;
2768+ a . style . display = 'none' ;
2769+ document . body . appendChild ( a ) ;
2770+ a . click ( ) ;
2771+ a . remove ( ) ;
2772+ } else if ( action === 'copy' ) {
2773+ // Hide the menu immediately so it doesn't sit on screen during
2774+ // the async fetch + clipboard write. We still control the toast.
2775+ this . _hideImageContextMenu ( ) ;
2776+ ( async ( ) => {
2777+ const fetchAsBlob = async ( ) => {
2778+ const resp = await fetch ( src , { credentials : 'same-origin' } ) ;
2779+ if ( ! resp . ok ) throw new Error ( 'fetch ' + resp . status ) ;
2780+ return await resp . blob ( ) ;
2781+ } ;
2782+ const toPngBlob = async ( blob ) => {
2783+ if ( blob . type === 'image/png' ) return blob ;
2784+ const bitmap = await createImageBitmap ( blob ) ;
2785+ const canvas = document . createElement ( 'canvas' ) ;
2786+ canvas . width = bitmap . width ;
2787+ canvas . height = bitmap . height ;
2788+ canvas . getContext ( '2d' ) . drawImage ( bitmap , 0 , 0 ) ;
2789+ return await new Promise ( ( res , rej ) =>
2790+ canvas . toBlob ( b => b ? res ( b ) : rej ( new Error ( 'toBlob null' ) ) , 'image/png' ) ) ;
2791+ } ;
2792+ const blobToDataUrl = ( blob ) => new Promise ( ( res , rej ) => {
2793+ const r = new FileReader ( ) ;
2794+ r . onload = ( ) => res ( r . result ) ;
2795+ r . onerror = ( ) => rej ( r . error || new Error ( 'FileReader failed' ) ) ;
2796+ r . readAsDataURL ( blob ) ;
2797+ } ) ;
2798+
2799+ // Strategy 1: Electron desktop IPC (most reliable — main process
2800+ // clipboard has no user-gesture requirement).
2801+ if ( window . havenDesktop ?. clipboardWriteImage ) {
2802+ try {
2803+ const blob = await fetchAsBlob ( ) ;
2804+ const png = await toPngBlob ( blob ) ;
2805+ const dataUrl = await blobToDataUrl ( png ) ;
2806+ const res = await window . havenDesktop . clipboardWriteImage ( dataUrl ) ;
2807+ if ( res ?. ok ) { this . _showToast ( 'Image copied to clipboard' , 'success' ) ; return ; }
2808+ console . warn ( '[Haven] IPC clipboard write failed:' , res ?. reason ) ;
2809+ } catch ( err ) {
2810+ console . warn ( '[Haven] IPC clipboard path errored:' , err ) ;
2811+ }
2812+ }
2813+
2814+ // Strategy 2: web navigator.clipboard.write with promise-based
2815+ // ClipboardItem (preserves gesture chain across async fetch).
2816+ try {
2817+ if ( typeof ClipboardItem === 'undefined' || ! navigator . clipboard ?. write ) {
2818+ throw new Error ( 'Clipboard API unavailable' ) ;
2819+ }
2820+ const blobPromise = ( async ( ) => toPngBlob ( await fetchAsBlob ( ) ) ) ( ) ;
2821+ await navigator . clipboard . write ( [
2822+ new ClipboardItem ( { 'image/png' : blobPromise } )
2823+ ] ) ;
2824+ this . _showToast ( 'Image copied to clipboard' , 'success' ) ;
2825+ return ;
2826+ } catch ( err ) {
2827+ console . error ( '[Haven] Web clipboard.write failed:' , err ) ;
2828+ // Strategy 3: at least put the URL on the clipboard so the
2829+ // user has something to paste.
2830+ try {
2831+ await navigator . clipboard . writeText ( src ) ;
2832+ this . _showToast ( 'Copied image URL (browser blocked image copy)' , 'warning' ) ;
2833+ return ;
2834+ } catch ( err2 ) {
2835+ console . error ( '[Haven] writeText fallback failed:' , err2 ) ;
2836+ this . _showToast ( 'Failed to copy image: ' + ( err . message || err ) , 'error' ) ;
2837+ }
2838+ }
2839+ } ) ( ) ;
2840+ return ;
2841+ } else if ( action === 'open' ) {
2842+ window . open ( src , '_blank' , 'noopener,noreferrer' ) ;
2843+ }
2844+ this . _hideImageContextMenu ( ) ;
2845+ } ) ;
2846+
2847+ // Close on click elsewhere
2848+ const closer = ( ev ) => {
2849+ if ( ! menu . contains ( ev . target ) ) {
2850+ this . _hideImageContextMenu ( ) ;
2851+ document . removeEventListener ( 'click' , closer , true ) ;
2852+ document . removeEventListener ( 'contextmenu' , closer , true ) ;
2853+ }
2854+ } ;
2855+ setTimeout ( ( ) => {
2856+ document . addEventListener ( 'click' , closer , true ) ;
2857+ document . addEventListener ( 'contextmenu' , closer , true ) ;
2858+ } , 0 ) ;
2859+ } ,
2860+
2861+ _hideImageContextMenu ( ) {
2862+ const existing = document . getElementById ( 'image-context-menu' ) ;
2863+ if ( existing ) existing . remove ( ) ;
2864+ } ,
2865+
2866+ } ;
0 commit comments