1// Click-to-copy panel for "image needs replacement" placeholders inside the 2// Lexxy editor. The placeholder is an atomic action-text-attachment 3// (contenteditable=false with user-select: none under Lexical), so its 4// description text cannot be drag-selected in the editor, and Lexical's 5// mousedown handling selects the whole node before the browser can start a 6// text selection. Clicking the placeholder instead opens a small panel 7// anchored beneath it with the description in a read-only textarea, 8// pre-selected for an immediate Cmd/Ctrl+C, plus a Copy button wired to the 9// existing Stimulus clipboard controller (which supplies the "Copied!" 10// feedback). 11// 12// Mirrors the note_aside_extension click-to-edit panel: document-level 13// capture-phase click handler so we run before Lexxy's selection logic, 14// panel appended to the lexxy-editor element so it anchors correctly. 15 16const ATTACHMENT_SELECTOR = 'action-text-attachment[content-type$="profile=image-replacement"]' 17 18let activePanel = null 19 20function closeActivePanel() { 21 if (activePanel) { 22 activePanel.remove() 23 activePanel = null 24 } 25} 26 27document.addEventListener( 28 'click', 29 (event) => { 30 if (event.target.closest('lexxy-attachment-toolbar')) { 31 closeActivePanel() 32 return 33 } 34 35 if (event.target.closest('.image-replacement-panel')) return 36 37 const attachment = event.target.closest(ATTACHMENT_SELECTOR) 38 const editor = attachment?.closest('lexxy-editor') 39 if (!attachment || !editor) { 40 closeActivePanel() 41 return 42 } 43 44 openPanel(editor, attachment) 45 }, 46 true 47) 48 49document.addEventListener('keydown', (event) => { 50 if (event.key === 'Escape') closeActivePanel() 51}) 52 53// Pull the description out of the placeholder's rendered aside, dropping the 54// leading "Description:" label. Falls back to the attachment's textContent 55// for unexpected structures (legacy or hand-edited HTML). 56function descriptionOf(attachment) { 57 const p = attachment.querySelector('.image-needs-replacement__description') 58 if (!p) return attachment.textContent.trim() 59 60 const clone = p.cloneNode(true) 61 const label = clone.querySelector(':scope > strong:first-child') 62 if (label && /^\s*Description:?\s*$/i.test(label.textContent)) { 63 label.remove() 64 } 65 return clone.textContent.trim() 66} 67 68function pageOf(attachment) { 69 return attachment.querySelector('.image-needs-replacement')?.getAttribute('data-pdf-page') || '' 70} 71 72function openPanel(editorElement, attachment) { 73 closeActivePanel() 74 75 const page = pageOf(attachment) 76 const panel = document.createElement('div') 77 panel.className = 'image-replacement-panel' 78 panel.setAttribute('role', 'dialog') 79 panel.setAttribute('aria-label', 'Image description') 80 panel.dataset.controller = 'clipboard' 81 panel.dataset.clipboardSuccessContentValue = 'Copied!' 82 panel.innerHTML = ` 83 <label class="image-replacement-panel__row"> 84 <span>Image description${page ? ` (page ${escapeHtml(page)})` : ''}</span> 85 <textarea rows="4" readonly data-clipboard-target="source"></textarea> 86 </label> 87 <div class="image-replacement-panel__actions"> 88 <button type="button" data-action="image-replacement-close">Close</button> 89 <button type="button" class="image-replacement-panel__copy" data-clipboard-target="button" data-action="clipboard#copy"> 90 Copy 91 </button> 92 </div> 93 ` 94 95 // Assigned via the property, not interpolated into the markup, so the 96 // description can never be parsed as HTML. 97 const textarea = panel.querySelector('textarea') 98 textarea.value = descriptionOf(attachment) 99 100 // lexxy-editor is both the panel's containing block AND a scroll container 101 // (overflow-y: auto with a capped height), so absolute children are 102 // positioned in content coordinates and scroll with the text. Viewport 103 // rect differences alone would anchor the panel scrollTop pixels too high; 104 // add the editor's scroll offsets to land next to the placeholder. 105 const rect = attachment.getBoundingClientRect() 106 const editorRect = editorElement.getBoundingClientRect() 107 panel.style.top = `${rect.bottom - editorRect.top + editorElement.scrollTop + 4}px` 108 panel.style.left = `${Math.max(0, rect.left - editorRect.left + editorElement.scrollLeft)}px` 109 110 editorElement.appendChild(panel) 111 activePanel = panel 112 113 textarea.focus() 114 textarea.select() 115 116 panel.addEventListener('click', (event) => { 117 if (event.target.closest('button[data-action="image-replacement-close"]')) { 118 closeActivePanel() 119 } 120 }) 121} 122 123function escapeHtml(str) { 124 return String(str || '') 125 .replace(/&/g, '&') 126 .replace(/</g, '<') 127 .replace(/>/g, '>') 128 .replace(/"/g, '"') 129 .replace(/'/g, ''') 130}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.