|
| 1 | +/** |
| 2 | + * Link Tooltip - CSS Anchor Positioning with index-based anchors |
| 3 | + * Shows a clickable tooltip when cursor is within a link |
| 4 | + * Uses CSS anchor positioning with dynamically selected anchor |
| 5 | + */ |
| 6 | + |
| 7 | +export class LinkTooltip { |
| 8 | + constructor(editor) { |
| 9 | + this.editor = editor; |
| 10 | + this.tooltip = null; |
| 11 | + this.currentLink = null; |
| 12 | + this.hideTimeout = null; |
| 13 | + |
| 14 | + this.init(); |
| 15 | + } |
| 16 | + |
| 17 | + init() { |
| 18 | + // Check for CSS anchor positioning support |
| 19 | + const supportsAnchor = |
| 20 | + CSS.supports("position-anchor: --x") && |
| 21 | + CSS.supports("position-area: center"); |
| 22 | + |
| 23 | + if (!supportsAnchor) { |
| 24 | + // Don't show anything if not supported |
| 25 | + return; |
| 26 | + } |
| 27 | + |
| 28 | + // Create tooltip element |
| 29 | + this.createTooltip(); |
| 30 | + |
| 31 | + // Listen for cursor position changes |
| 32 | + this.editor.textarea.addEventListener("selectionchange", () => |
| 33 | + this.checkCursorPosition() |
| 34 | + ); |
| 35 | + this.editor.textarea.addEventListener("keyup", (e) => { |
| 36 | + if (e.key.includes("Arrow") || e.key === "Home" || e.key === "End") { |
| 37 | + this.checkCursorPosition(); |
| 38 | + } |
| 39 | + }); |
| 40 | + |
| 41 | + // Hide tooltip when typing or scrolling |
| 42 | + this.editor.textarea.addEventListener("input", () => this.hide()); |
| 43 | + this.editor.textarea.addEventListener("scroll", () => this.hide()); |
| 44 | + |
| 45 | + // Keep tooltip visible on hover |
| 46 | + this.tooltip.addEventListener("mouseenter", () => this.cancelHide()); |
| 47 | + this.tooltip.addEventListener("mouseleave", () => this.scheduleHide()); |
| 48 | + } |
| 49 | + |
| 50 | + createTooltip() { |
| 51 | + // Create tooltip element |
| 52 | + this.tooltip = document.createElement("div"); |
| 53 | + this.tooltip.className = "overtype-link-tooltip"; |
| 54 | + |
| 55 | + // Add CSS anchor positioning styles |
| 56 | + const tooltipStyles = document.createElement("style"); |
| 57 | + tooltipStyles.textContent = ` |
| 58 | + @supports (position-anchor: --x) and (position-area: center) { |
| 59 | + .overtype-link-tooltip { |
| 60 | + position: absolute; |
| 61 | + position-anchor: var(--target-anchor, --link-0); |
| 62 | + position-area: block-end center; |
| 63 | + margin-top: 8px; |
| 64 | + |
| 65 | + background: #333; |
| 66 | + color: white; |
| 67 | + padding: 6px 10px; |
| 68 | + border-radius: 16px; |
| 69 | + font-size: 12px; |
| 70 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; |
| 71 | + display: none; |
| 72 | + z-index: 10000; |
| 73 | + cursor: pointer; |
| 74 | + box-shadow: 0 2px 8px rgba(0,0,0,0.3); |
| 75 | + max-width: 300px; |
| 76 | + white-space: nowrap; |
| 77 | + overflow: hidden; |
| 78 | + text-overflow: ellipsis; |
| 79 | + |
| 80 | + position-try: most-width block-end inline-end, flip-inline, block-start center; |
| 81 | + position-visibility: anchors-visible; |
| 82 | + } |
| 83 | + |
| 84 | + .overtype-link-tooltip.visible { |
| 85 | + display: flex; |
| 86 | + } |
| 87 | + } |
| 88 | + `; |
| 89 | + document.head.appendChild(tooltipStyles); |
| 90 | + |
| 91 | + // Add link icon and text container |
| 92 | + this.tooltip.innerHTML = ` |
| 93 | + <span style="display: flex; align-items: center; gap: 6px;"> |
| 94 | + <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink: 0;"> |
| 95 | + <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path> |
| 96 | + <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path> |
| 97 | + </svg> |
| 98 | + <span class="overtype-link-tooltip-url"></span> |
| 99 | + </span> |
| 100 | + `; |
| 101 | + |
| 102 | + // Click handler to open link |
| 103 | + this.tooltip.addEventListener("click", (e) => { |
| 104 | + e.preventDefault(); |
| 105 | + e.stopPropagation(); |
| 106 | + if (this.currentLink) { |
| 107 | + window.open(this.currentLink.url, "_blank"); |
| 108 | + this.hide(); |
| 109 | + } |
| 110 | + }); |
| 111 | + |
| 112 | + // Append tooltip to editor container |
| 113 | + this.editor.container.appendChild(this.tooltip); |
| 114 | + } |
| 115 | + |
| 116 | + checkCursorPosition() { |
| 117 | + const cursorPos = this.editor.textarea.selectionStart; |
| 118 | + const text = this.editor.textarea.value; |
| 119 | + |
| 120 | + // Find if cursor is within a markdown link |
| 121 | + const linkInfo = this.findLinkAtPosition(text, cursorPos); |
| 122 | + |
| 123 | + if (linkInfo) { |
| 124 | + if ( |
| 125 | + !this.currentLink || |
| 126 | + this.currentLink.url !== linkInfo.url || |
| 127 | + this.currentLink.index !== linkInfo.index |
| 128 | + ) { |
| 129 | + this.show(linkInfo); |
| 130 | + } |
| 131 | + } else { |
| 132 | + this.scheduleHide(); |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + findLinkAtPosition(text, position) { |
| 137 | + // Regex to find markdown links: [text](url) |
| 138 | + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; |
| 139 | + let match; |
| 140 | + let linkIndex = 0; |
| 141 | + |
| 142 | + while ((match = linkRegex.exec(text)) !== null) { |
| 143 | + const start = match.index; |
| 144 | + const end = match.index + match[0].length; |
| 145 | + |
| 146 | + if (position >= start && position <= end) { |
| 147 | + return { |
| 148 | + text: match[1], |
| 149 | + url: match[2], |
| 150 | + index: linkIndex, |
| 151 | + start: start, |
| 152 | + end: end, |
| 153 | + }; |
| 154 | + } |
| 155 | + linkIndex++; |
| 156 | + } |
| 157 | + |
| 158 | + return null; |
| 159 | + } |
| 160 | + |
| 161 | + show(linkInfo) { |
| 162 | + this.currentLink = linkInfo; |
| 163 | + this.cancelHide(); |
| 164 | + |
| 165 | + // Update tooltip content |
| 166 | + const urlSpan = this.tooltip.querySelector(".overtype-link-tooltip-url"); |
| 167 | + urlSpan.textContent = linkInfo.url; |
| 168 | + |
| 169 | + // Set the CSS variable to point to the correct anchor |
| 170 | + this.tooltip.style.setProperty( |
| 171 | + "--target-anchor", |
| 172 | + `--link-${linkInfo.index}` |
| 173 | + ); |
| 174 | + |
| 175 | + // Show tooltip (CSS anchor positioning handles the rest) |
| 176 | + this.tooltip.classList.add("visible"); |
| 177 | + } |
| 178 | + |
| 179 | + hide() { |
| 180 | + this.tooltip.classList.remove("visible"); |
| 181 | + this.currentLink = null; |
| 182 | + } |
| 183 | + |
| 184 | + scheduleHide() { |
| 185 | + this.cancelHide(); |
| 186 | + this.hideTimeout = setTimeout(() => this.hide(), 300); |
| 187 | + } |
| 188 | + |
| 189 | + cancelHide() { |
| 190 | + if (this.hideTimeout) { |
| 191 | + clearTimeout(this.hideTimeout); |
| 192 | + this.hideTimeout = null; |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + destroy() { |
| 197 | + this.cancelHide(); |
| 198 | + if (this.tooltip && this.tooltip.parentNode) { |
| 199 | + this.tooltip.parentNode.removeChild(this.tooltip); |
| 200 | + } |
| 201 | + this.tooltip = null; |
| 202 | + this.currentLink = null; |
| 203 | + } |
| 204 | +} |
0 commit comments