diff --git a/README.md b/README.md index b7cccea..7e789bb 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ sync'd July 30, 2025 | IndexedDB: `getAllRecords()` | Shows the benefits of the proposed `getAllRecords()` IndexedDB method to more conveniently and quickly read IDB records. | [/idb-getallrecords/](https://github.com/MicrosoftEdge/Demos/tree/main/idb-getallrecords) | [IndexedDB: getAllRecords()](https://microsoftedge.github.io/Demos/idb-getallrecords/) demo | | Notifications demo | Using incoming call notifications. | [/incoming-call-notifications/](https://github.com/MicrosoftEdge/Demos/tree/main/incoming-call-notifications) | [Notifications demo](https://microsoftedge.github.io/Demos/incoming-call-notifications/) | | JSON dummy data | Simple JSON files. Used for [View a JSON file or server response with formatting](https://learn.microsoft.com/microsoft-edge/web-platform/json-viewer). | [/json-dummy-data/](https://github.com/MicrosoftEdge/Demos/tree/main/json-dummy-data) | [JSON dummy data](https://microsoftedge.github.io/Demos/json-dummy-data/) (Readme) | +| OpaqueRange | Demonstrates the `OpaqueRange` API for creating ranges over ` + + +

Input

+
+ +
+ + + + + +
+

Use case 2: Search highlighting

+

+ Type a search term in the search box below. All matching occurrences + in the textarea are highlighted in real time using the CSS Custom + Highlight API with OpaqueRange. +

+ + +
+ +
+
+ + +
+

Use case 3: Live range tracking

+

+ OpaqueRange is live: it automatically updates its offsets as + the content changes via interactive edits (typing, backspace, delete) or + setRangeText(). Click Highlight "hello" to + highlight the word, then type before it and watch the highlight follow. +

+ +
+ +
+
+ + +
+

+
+ + +
+

Use case 4: Disconnecting an OpaqueRange

+

+ Calling range.disconnect() detaches the range from its element. + The offsets reset to 0, geometry becomes empty, and the range stops + receiving live updates. Create a range, then disconnect it and observe the + change. +

+ +
+ +
+
+ + +
+

+
+ + + + \ No newline at end of file diff --git a/opaque-range/script.js b/opaque-range/script.js new file mode 100644 index 0000000..d94a447 --- /dev/null +++ b/opaque-range/script.js @@ -0,0 +1,268 @@ +// ---- Feature detection ---- +const supportsOpaqueRange = typeof HTMLTextAreaElement.prototype.createValueRange === "function"; + +if (!supportsOpaqueRange) { + document.getElementById("feature-warning").hidden = false; +} + +// =========================================================================== +// Use Case 1: Caret Popup Positioning +// =========================================================================== + +const mentionPopup = document.getElementById("mention-popup"); +const textareaPopup = document.getElementById("textarea-popup"); +const inputPopup = document.getElementById("input-popup"); + +let mentionTriggerField = null; + +/** + * Show the mention popup at the caret position using OpaqueRange. + * @param {HTMLTextAreaElement | HTMLInputElement} element + */ +function showMentionPopup(element) { + if (!supportsOpaqueRange) { + return; + } + + const caretPos = element.selectionStart; + // Create a collapsed OpaqueRange at the caret. + const range = element.createValueRange(caretPos, caretPos); + const rect = range.getBoundingClientRect(); + + mentionPopup.style.left = `${rect.left}px`; + mentionPopup.style.top = `${rect.bottom + 4}px`; + mentionPopup.hidden = false; + mentionTriggerField = element; +} + +function hideMentionPopup() { + mentionPopup.hidden = true; + mentionTriggerField = null; +} + +/** + * Handle input in the mention fields. + * Shows the popup when the user types ':'. + */ +function handleMentionInput(event) { + const el = event.target; + const text = el.value; + const pos = el.selectionStart; + + if (pos > 0 && text[pos - 1] === ":") { + showMentionPopup(el); + } else { + hideMentionPopup(); + } +} + +textareaPopup.addEventListener("input", handleMentionInput); +inputPopup.addEventListener("input", handleMentionInput); + +// Insert selected emoji and close popup. +mentionPopup.addEventListener("click", (event) => { + const li = event.target.closest("li[data-emoji]"); + if (!li) { + return; + } + + const emoji = li.dataset.emoji; + const activeField = mentionTriggerField || textareaPopup; + const pos = activeField.selectionStart; + // Replace the trigger character ':' with the emoji. + const before = activeField.value.slice(0, pos - 1); + const after = activeField.value.slice(pos); + activeField.value = before + emoji + " " + after; + + // Move caret after inserted emoji. + const newPos = before.length + emoji.length + 1; + activeField.setSelectionRange(newPos, newPos); + activeField.focus(); + hideMentionPopup(); +}); + +// Hide popup when clicking outside. +document.addEventListener("click", (event) => { + if (!mentionPopup.contains(event.target)) { + hideMentionPopup(); + } +}); + +// =========================================================================== +// Use Case 2: Search/Find Highlighting +// =========================================================================== + +const searchInput = document.getElementById("search-input"); +const textareaSearch = document.getElementById("textarea-search"); +const searchCount = document.getElementById("search-count"); + +let currentSearchRanges = []; + +function updateSearchHighlights() { + if (!supportsOpaqueRange) { + return; + } + + // Disconnect old ranges. + currentSearchRanges.forEach((r) => r.disconnect()); + currentSearchRanges = []; + + const query = searchInput.value; + if (!query) { + CSS.highlights.delete("search-match"); + searchCount.textContent = ""; + return; + } + + const text = textareaSearch.value; + const newRanges = []; + + try { + // Find all case-insensitive occurrences of the search term. + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let startIndex = 0; + + while (startIndex < lowerText.length) { + const idx = lowerText.indexOf(lowerQuery, startIndex); + if (idx === -1) { + break; + } + const range = textareaSearch.createValueRange(idx, idx + query.length); + currentSearchRanges.push(range); + newRanges.push(range); + startIndex = idx + 1; + } + } catch (e) { + console.error("Search highlighting error:", e); + } + + if (newRanges.length > 0) { + const searchHighlight = new Highlight(...newRanges); + CSS.highlights.set("search-match", searchHighlight); + } else { + CSS.highlights.delete("search-match"); + } + + const count = newRanges.length; + searchCount.textContent = count === 1 ? "1 match" : `${count} matches`; +} + +searchInput.addEventListener("input", updateSearchHighlights); +textareaSearch.addEventListener("input", updateSearchHighlights); + +// =========================================================================== +// Use Case 3: Live Range Tracking +// =========================================================================== + +const textareaLive = document.getElementById("textarea-live"); +const btnHighlight = document.getElementById("btn-highlight-hello"); +const btnClear = document.getElementById("btn-clear-live"); +const liveInfo = document.getElementById("live-range-info"); + +let liveRange = null; + +btnHighlight.addEventListener("click", () => { + if (!supportsOpaqueRange) { + liveInfo.textContent = "OpaqueRange is not supported in this browser."; + return; + } + + const text = textareaLive.value; + const idx = text.indexOf("hello"); + if (idx === -1) { + liveInfo.textContent = '"hello" not found in the textarea.'; + return; + } + + // Disconnect the previous range before creating a new one. + if (liveRange) { + liveRange.disconnect(); + } + + try { + liveRange = textareaLive.createValueRange(idx, idx + "hello".length); + const trackedHighlight = new Highlight(liveRange); + CSS.highlights.set("tracked-word", trackedHighlight); + + liveInfo.textContent = `Highlighted "hello" at offsets ${liveRange.startOffset}–${liveRange.endOffset}. Type before it and watch the highlight follow!`; + } catch (e) { + console.error("Live range highlighting error:", e); + } +}); + +// Update the info text whenever the textarea content changes, so users can see +// the live range offsets shift in real time. +textareaLive.addEventListener("input", () => { + if (liveRange) { + liveInfo.textContent = `Live range offsets: ${liveRange.startOffset}–${liveRange.endOffset}`; + } +}); + +btnClear.addEventListener("click", () => { + CSS.highlights.delete("tracked-word"); + if (liveRange) { + liveRange.disconnect(); + liveRange = null; + } + liveInfo.textContent = ""; +}); + +// =========================================================================== +// Use Case 4: disconnect() +// =========================================================================== + +const textareaDisconnect = document.getElementById("textarea-disconnect"); +const btnCreateRange = document.getElementById("btn-create-range"); +const btnDisconnectRange = document.getElementById("btn-disconnect-range"); +const disconnectInfo = document.getElementById("disconnect-info"); + +let disconnectRange = null; + +function showRangeState(range, label) { + return `${label}: startOffset=${range.startOffset}, endOffset=${range.endOffset}, collapsed=${range.collapsed}, startContainer=${range.startContainer}, endContainer=${range.endContainer}`; +} + +btnCreateRange.addEventListener("click", () => { + if (!supportsOpaqueRange) { + disconnectInfo.textContent = "OpaqueRange is not supported in this browser."; + return; + } + + // Disconnect any previous range before creating a new one. + if (disconnectRange) { + disconnectRange.disconnect(); + } + + // "quick" is at positions 4–9 in "The quick brown fox" + try { + disconnectRange = textareaDisconnect.createValueRange(4, 9); + const disconnectHighlight = new Highlight(disconnectRange); + CSS.highlights.set("disconnect-demo", disconnectHighlight); + + disconnectInfo.textContent = showRangeState(disconnectRange, "Created"); + } catch (e) { + console.error("Disconnect demo highlighting error:", e); + } +}); + +btnDisconnectRange.addEventListener("click", () => { + if (!disconnectRange) { + disconnectInfo.textContent = "No range to disconnect. Create one first."; + return; + } + + disconnectRange.disconnect(); + CSS.highlights.delete("disconnect-demo"); + + disconnectInfo.textContent = showRangeState(disconnectRange, "After disconnect()") + + "\nThe range is now detached — offsets are 0, geometry is empty, and edits to the textarea no longer affect it."; + disconnectRange = null; +}); + +// Show live updates while a connected range exists. +textareaDisconnect.addEventListener("input", () => { + if (disconnectRange) { + disconnectInfo.textContent = showRangeState(disconnectRange, "Live update"); + } +}); diff --git a/opaque-range/style.css b/opaque-range/style.css new file mode 100644 index 0000000..4d81272 --- /dev/null +++ b/opaque-range/style.css @@ -0,0 +1,272 @@ +* { + box-sizing: border-box; +} + +html, +input, +textarea, +button { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; + font-size: 16px; + font-family: Segoe UI, SegoeUI, Helvetica Neue, Helvetica, Arial, sans-serif; + font-weight: 400; +} + +html { + min-height: 100vh; +} + +body { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1.5rem; + color: #1b1b1b; + line-height: 1.6; +} + +h1 { + margin-bottom: 0.25rem; + font-size: 1.75rem; +} + +h2 { + margin-top: 2rem; + font-size: 1.3rem; + border-bottom: 1px solid #ddd; + padding-bottom: 0.4rem; +} + +h3 { + font-size: 1rem; + margin-bottom: 0.25rem; +} + +code, kbd { + font-family: Consolas, "Courier New", monospace; + font-size: 0.9em; + background: #f0f0f0; + padding: 0.15em 0.35em; + border-radius: 3px; +} + +kbd { + border: 1px solid #ccc; + box-shadow: 0 1px 0 #bbb; +} + +a { + color: #0067b8; +} + +.warning { + background: #fff3cd; + border: 1px solid #ffecb5; + border-radius: 6px; + padding: 0.75rem 1rem; + margin: 1rem 0; + color: #664d03; +} + +.demo-section { + margin-bottom: 2rem; +} + +.field-wrapper { + position: relative; +} + +textarea, +input[type="text"] { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 1rem; + resize: vertical; + transition: border-color 0.15s; +} + +textarea:focus, +input[type="text"]:focus { + outline: none; + border-color: #0067b8; + box-shadow: 0 0 0 2px rgba(0, 103, 184, 0.2); +} + +/* Mention popup */ +.popup { + position: fixed; + z-index: 1000; + background: #fff; + border: 1px solid #ccc; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 150px; +} + +.popup ul { + list-style: none; + margin: 0; + padding: 0.25rem 0; +} + +.popup li { + padding: 0.4rem 0.75rem; + cursor: pointer; + font-size: 0.95rem; +} + +.popup li:hover { + background: #e8f0fe; +} + +/* Search highlight (CSS Custom Highlight API) */ +::highlight(search-match) { + background-color: rgba(255, 200, 0, 0.4); +} + +.search-bar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.search-bar label { + font-weight: 600; + white-space: nowrap; +} + +.search-bar input { + flex: 1; +} + +/* Live range highlight */ +::highlight(tracked-word) { + background-color: rgba(0, 120, 212, 0.3); +} + +/* Disconnect demo highlight */ +::highlight(disconnect-demo) { + background-color: rgba(0, 180, 80, 0.3); +} + +.button-group { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +button { + padding: 0.45rem 1rem; + border: 1px solid #bbb; + border-radius: 6px; + background: #f5f5f5; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.15s; +} + +button:hover { + background: #e0e0e0; +} + +button:active { + background: #d0d0d0; +} + +.info-text { + font-size: 0.9rem; + color: #555; + min-height: 1.4rem; +} + +@media (prefers-color-scheme: dark) { + body { + background: #1e1e1e; + color: #e0e0e0; + } + + h2 { + border-bottom-color: #444; + } + + code, kbd { + background: #2d2d2d; + color: #d4d4d4; + } + + kbd { + border-color: #555; + box-shadow: 0 1px 0 #444; + } + + a { + color: #4fc3f7; + } + + .warning { + background: #3e3219; + border-color: #5c4a1c; + color: #ffd54f; + } + + textarea, + input[type="text"] { + background: #2d2d2d; + color: #e0e0e0; + border-color: #555; + } + + textarea:focus, + input[type="text"]:focus { + border-color: #4fc3f7; + box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.25); + } + + .popup { + background: #2d2d2d; + border-color: #555; + color: #e0e0e0; + } + + .popup li:hover { + background: #3a3a3a; + } + + button { + background: #333; + color: #e0e0e0; + border-color: #555; + } + + button:hover { + background: #444; + } + + button:active { + background: #555; + } + + .info-text { + color: #aaa; + } + + ::highlight(search-match) { + background-color: rgba(255, 200, 0, 0.45); + } + + ::highlight(tracked-word) { + background-color: rgba(79, 195, 247, 0.35); + } + + ::highlight(disconnect-demo) { + background-color: rgba(0, 200, 100, 0.35); + } + +} \ No newline at end of file