diff options
| -rw-r--r-- | _includes/cm-importmap.html | 21 | ||||
| -rw-r--r-- | _layouts/default.html | 54 | ||||
| -rw-r--r-- | _posts/2026-10-17-regex.html | 746 | ||||
| -rw-r--r-- | _tools/cm-esbuild/scripts/deploy.js | 62 | ||||
| -rw-r--r-- | _tools/cm-esbuild/src/commands.js | 2 | ||||
| -rw-r--r-- | _tools/cm-esbuild/src/language.js | 2 | ||||
| -rw-r--r-- | _tools/cm-esbuild/src/state.js | 2 | ||||
| -rw-r--r-- | _tools/cm-esbuild/src/theme.js | 2 | ||||
| -rw-r--r-- | _tools/cm-esbuild/src/view.js | 2 | ||||
| -rw-r--r-- | _tools/e2e/playwright.config.ts | 22 | ||||
| -rw-r--r-- | _tools/e2e/tests/regex-editor.spec.ts | 695 |
11 files changed, 923 insertions, 687 deletions
diff --git a/_includes/cm-importmap.html b/_includes/cm-importmap.html index c0c7c99..90bf32e 100644 --- a/_includes/cm-importmap.html +++ b/_includes/cm-importmap.html @@ -1,14 +1,23 @@ -<link rel="modulepreload" href="{{ '/assets/cm/chunk-7PWEZHZB.js' | relative_url }}"> -<link rel="modulepreload" href="{{ '/assets/cm/chunk-I3OVNVKL.js' | relative_url }}"> -<link rel="modulepreload" href="{{ '/assets/cm/chunk-JULZPKSW.js' | relative_url }}"> +<link + rel="modulepreload" + href="{{ '/assets/cm/chunk-7PWEZHZB.js' | relative_url }}" +/> +<link + rel="modulepreload" + href="{{ '/assets/cm/chunk-I3OVNVKL.js' | relative_url }}" +/> +<link + rel="modulepreload" + href="{{ '/assets/cm/chunk-JULZPKSW.js' | relative_url }}" +/> <script type="importmap"> -{ - "imports": { + { + "imports": { "@codemirror/state": "{{ '/assets/cm/state.js' | relative_url }}?v=ea9398b90f", "@codemirror/view": "{{ '/assets/cm/view.js' | relative_url }}?v=ea9398b90f", "@codemirror/commands": "{{ '/assets/cm/commands.js' | relative_url }}?v=ea9398b90f", "@codemirror/language": "{{ '/assets/cm/language.js' | relative_url }}?v=ea9398b90f", "@uiw/codemirror-theme-github": "{{ '/assets/cm/theme.js' | relative_url }}?v=ea9398b90f" + } } -} </script> diff --git a/_layouts/default.html b/_layouts/default.html index 04b2cf4..d7dd799 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -1,9 +1,9 @@ -<!DOCTYPE html> -<html lang="{{ site.lang | default: "en-US" }}"> +<!doctype html> +<html lang="{{ site.lang | default: 'en-US' }}"> <head> - <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="color-scheme" content="dark light" /> - <meta charset="utf-8"> + <meta charset="utf-8" /> <title>{{ page.title }} - {{ site.title }}</title> <style> :root { @@ -19,31 +19,43 @@ body { background: var(--bg-color); color: var(--text-color); - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 16px; margin: 2em auto; max-width: 800px; padding: 1em; line-height: 1.4; - hyphens: auto + hyphens: auto; } </style> </head> <body> -<!-- _layout/default.html BEGIN_content --> -{{ content -}} -<!-- _layout/default.html END_content --> - <footer> - <small title="Reflects the whole site, not this page. Individual pages may be unchanged since earlier versions."> - {% if site.data.build %} - site version - <a href="https://hizbu.com/cgit/hizbu.com.git/commit/?id={{ site.data.build.commit_full }}"><code>{{ site.data.build.commit }}</code></a> - · committed <time datetime="{{ site.data.build.date }}">{{ site.data.build.date }}</time> - {% else %} - site version <a href="https://hizbu.com/cgit/hizbu.com.git"><code>development</code></a> - · committed <time datetime="1970-01-01 00:00 UTC">1970-01-01 00:00 UTC</time> - {% endif %} - </small> - </footer> + <!-- _layout/default.html BEGIN_content --> + {{ content -}} + <!-- _layout/default.html END_content --> + <footer> + <small + title="Reflects the whole site, not this page. Individual pages may be unchanged since earlier versions." + > + {% if site.data.build %} site version + <a + href="https://hizbu.com/cgit/hizbu.com.git/commit/?id={{ site.data.build.commit_full }}" + ><code>{{ site.data.build.commit }}</code></a + > + · committed + <time datetime="{{ site.data.build.date }}" + >{{ site.data.build.date }}</time + > + {% else %} site version + <a href="https://hizbu.com/cgit/hizbu.com.git" + ><code>development</code></a + > + · committed + <time datetime="1970-01-01 00:00 UTC">1970-01-01 00:00 UTC</time> + {% endif %} + </small> + </footer> </body> </html> diff --git a/_posts/2026-10-17-regex.html b/_posts/2026-10-17-regex.html index b03779a..bb0ceb4 100644 --- a/_posts/2026-10-17-regex.html +++ b/_posts/2026-10-17-regex.html @@ -3,7 +3,7 @@ layout: post --- <label> - <input type="checkbox" id="fullscreen"> + <input type="checkbox" id="fullscreen" /> Fullscreen </label> @@ -11,19 +11,18 @@ layout: post const body = document.body; const checkbox = document.getElementById("fullscreen"); checkbox.addEventListener("change", () => { - if (checkbox.checked) { - body.style.setProperty("margin", "0"); - body.style.setProperty("max-width", "none"); - body.style.setProperty("padding", "0"); - } else { - body.style.removeProperty("margin"); - body.style.removeProperty("max-width"); - body.style.removeProperty("padding"); - } + if (checkbox.checked) { + body.style.setProperty("margin", "0"); + body.style.setProperty("max-width", "none"); + body.style.setProperty("padding", "0"); + } else { + body.style.removeProperty("margin"); + body.style.removeProperty("max-width"); + body.style.removeProperty("padding"); + } }); </script> - <div id="loading"> <div class="spinner"></div> <div>Loading editor…</div> @@ -31,155 +30,194 @@ layout: post <style> :root { - --border-color: #d0d7de; - --tab-bg: #f6f8fa; - --tab-active-bg: #ffffff; - --editor-bg: #ffffff; - --rename-bg: #ffffff; - --rename-fg: #000000; + --border-color: #d0d7de; + --tab-bg: #f6f8fa; + --tab-active-bg: #ffffff; + --editor-bg: #ffffff; + --rename-bg: #ffffff; + --rename-fg: #000000; } @media (prefers-color-scheme: dark) { - :root { - --border-color: #2f2821; - --tab-bg: #090705; - --tab-active-bg: #000000; - --editor-bg: #0d1117; - --rename-bg: #0d1117; - --rename-fg: #ffffff; - } + :root { + --border-color: #2f2821; + --tab-bg: #090705; + --tab-active-bg: #000000; + --editor-bg: #0d1117; + --rename-bg: #0d1117; + --rename-fg: #ffffff; + } } #loading { - position: absolute; - inset: 0; - z-index: 10; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - background: var(--bg-color); - font-size: 14px; + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--bg-color); + font-size: 14px; } #loading .spinner { - width: 28px; - height: 28px; - border: 3px solid var(--border-color); - border-top-color: #0969da; - border-radius: 50%; - animation: spin 0.8s linear infinite; + width: 28px; + height: 28px; + border: 3px solid var(--border-color); + border-top-color: #0969da; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } } - @keyframes spin { to { transform: rotate(360deg); } } /* Match highlights (bottom editor) */ - .cm-regex-match-0 { background-color: rgba(9, 105, 218, 0.15); border-bottom: 2px solid #0969da; border-radius: 2px; } - .cm-regex-match-1 { background-color: rgba(45, 164, 78, 0.15); border-bottom: 2px solid #2da44e; border-radius: 2px; } - .cm-regex-match-2 { background-color: rgba(207, 34, 46, 0.15); border-bottom: 2px solid #cf222e; border-radius: 2px; } - .cm-regex-match-3 { background-color: rgba(154, 103, 0, 0.15); border-bottom: 2px solid #9a6700; border-radius: 2px; } - .cm-regex-match-4 { background-color: rgba(130, 80, 223, 0.15); border-bottom: 2px solid #8250df; border-radius: 2px; } + .cm-regex-match-0 { + background-color: rgba(9, 105, 218, 0.15); + border-bottom: 2px solid #0969da; + border-radius: 2px; + } + .cm-regex-match-1 { + background-color: rgba(45, 164, 78, 0.15); + border-bottom: 2px solid #2da44e; + border-radius: 2px; + } + .cm-regex-match-2 { + background-color: rgba(207, 34, 46, 0.15); + border-bottom: 2px solid #cf222e; + border-radius: 2px; + } + .cm-regex-match-3 { + background-color: rgba(154, 103, 0, 0.15); + border-bottom: 2px solid #9a6700; + border-radius: 2px; + } + .cm-regex-match-4 { + background-color: rgba(130, 80, 223, 0.15); + border-bottom: 2px solid #8250df; + border-radius: 2px; + } /* Line fringe highlights (top editor) */ - .cm-regex-line-0 { border-left: 4px solid #0969da; background-color: rgba(9, 105, 218, 0.05); } - .cm-regex-line-1 { border-left: 4px solid #2da44e; background-color: rgba(45, 164, 78, 0.05); } - .cm-regex-line-2 { border-left: 4px solid #cf222e; background-color: rgba(207, 34, 46, 0.05); } - .cm-regex-line-3 { border-left: 4px solid #9a6700; background-color: rgba(154, 103, 0, 0.05); } - .cm-regex-line-4 { border-left: 4px solid #8250df; background-color: rgba(130, 80, 223, 0.05); } + .cm-regex-line-0 { + border-left: 4px solid #0969da; + background-color: rgba(9, 105, 218, 0.05); + } + .cm-regex-line-1 { + border-left: 4px solid #2da44e; + background-color: rgba(45, 164, 78, 0.05); + } + .cm-regex-line-2 { + border-left: 4px solid #cf222e; + background-color: rgba(207, 34, 46, 0.05); + } + .cm-regex-line-3 { + border-left: 4px solid #9a6700; + background-color: rgba(154, 103, 0, 0.05); + } + .cm-regex-line-4 { + border-left: 4px solid #8250df; + background-color: rgba(130, 80, 223, 0.05); + } .tab-bar-container { - display: flex; - align-items: center; - background: var(--border-color); - padding: 1px 1px 0 1px; - overflow: hidden; + display: flex; + align-items: center; + background: var(--border-color); + padding: 1px 1px 0 1px; + overflow: hidden; } .tab-bar { - display: flex; - gap: 1px; - overflow-x: auto; - flex: 1; + display: flex; + gap: 1px; + overflow-x: auto; + flex: 1; } .tab-btn { - background: var(--tab-bg); - color: var(--text-color); - border: none; - padding: 6px 10px 6px 14px; - cursor: pointer; - font-size: 13px; - display: flex; - align-items: center; - gap: 8px; - white-space: nowrap; - user-select: none; + background: var(--tab-bg); + color: var(--text-color); + border: none; + padding: 6px 10px 6px 14px; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + user-select: none; } .tab-btn input { - font: inherit; - padding: 0 2px; - border: 1px solid #0969da; - outline: none; - background: var(--rename-bg); - color: var(--rename-fg); + font: inherit; + padding: 0 2px; + border: 1px solid #0969da; + outline: none; + background: var(--rename-bg); + color: var(--rename-fg); } .tab-btn.active { - background: var(--tab-active-bg); - border-bottom: 2px solid #0969da; - font-weight: 500; + background: var(--tab-active-bg); + border-bottom: 2px solid #0969da; + font-weight: 500; } .tab-close { - background: transparent; - border: none; - color: #8c959f; - cursor: pointer; - font-size: 14px; - padding: 0 4px; - border-radius: 4px; + background: transparent; + border: none; + color: #8c959f; + cursor: pointer; + font-size: 14px; + padding: 0 4px; + border-radius: 4px; } .tab-close:hover { - background: rgba(128, 128, 128, 0.25); - color: var(--text-color); + background: rgba(128, 128, 128, 0.25); + color: var(--text-color); } .tab-add-btn { - background: var(--tab-bg); - color: var(--text-color); - border: none; - cursor: pointer; - font-size: 16px; - font-weight: bold; + background: var(--tab-bg); + color: var(--text-color); + border: none; + cursor: pointer; + font-size: 16px; + font-weight: bold; } .tab-add-btn:hover { - background: var(--tab-active-bg); + background: var(--tab-active-bg); } #regex-editor { - margin-bottom: 1rem; - border: 1px solid var(--border-color); - overflow: hidden; + margin-bottom: 1rem; + border: 1px solid var(--border-color); + overflow: hidden; background: var(--editor-bg); } #cm6-editor { - flex: 1; - display: flex; - flex-direction: column; - border: 1px solid var(--border-color); - overflow: hidden; - background: var(--editor-bg); + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + background: var(--editor-bg); } #regex-editor .cm-editor, #cm6-editor .cm-editor { - height: 100%; + height: 100%; } #regex-editor .cm-scroller, #cm6-editor .cm-scroller { - overflow: auto; + overflow: auto; } - .cm-editor.cm-focused, - .cm-scroller:focus, + .cm-editor.cm-focused, + .cm-scroller:focus, .cm-content:focus { - outline: none !important; + outline: none !important; } </style> @@ -196,50 +234,74 @@ layout: post {% include cm-importmap.html %} <script type="module"> - import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'; - import { EditorView, Decoration, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine } from '@codemirror/view'; - import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; - import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language'; + import { + EditorState, + StateField, + StateEffect, + RangeSetBuilder, + Compartment, + } from "@codemirror/state"; + import { + EditorView, + Decoration, + keymap, + lineNumbers, + highlightActiveLineGutter, + highlightSpecialChars, + drawSelection, + highlightActiveLine, + } from "@codemirror/view"; + import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; + import { + syntaxHighlighting, + defaultHighlightStyle, + bracketMatching, + } from "@codemirror/language"; import { githubLight, githubDark } from "@uiw/codemirror-theme-github"; - const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const themeFor = dark => dark ? githubDark : githubLight; + const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const themeFor = (dark) => (dark ? githubDark : githubLight); const regexTheme = new Compartment(); - const mainTheme = new Compartment(); + const mainTheme = new Compartment(); // Top Editor Line Fringe Decorations const topEditorLineDecos = [ - Decoration.line({ class: 'cm-regex-line-0' }), - Decoration.line({ class: 'cm-regex-line-1' }), - Decoration.line({ class: 'cm-regex-line-2' }), - Decoration.line({ class: 'cm-regex-line-3' }), - Decoration.line({ class: 'cm-regex-line-4' }) + Decoration.line({ class: "cm-regex-line-0" }), + Decoration.line({ class: "cm-regex-line-1" }), + Decoration.line({ class: "cm-regex-line-2" }), + Decoration.line({ class: "cm-regex-line-3" }), + Decoration.line({ class: "cm-regex-line-4" }), ]; const topEditorField = StateField.define({ - create: state => buildTopDecorations(state), - update: (decorations, tr) => tr.docChanged ? buildTopDecorations(tr.state) : decorations, - provide: f => EditorView.decorations.from(f) + create: (state) => buildTopDecorations(state), + update: (decorations, tr) => + tr.docChanged ? buildTopDecorations(tr.state) : decorations, + provide: (f) => EditorView.decorations.from(f), }); function buildTopDecorations(state) { - const builder = new RangeSetBuilder(); - for (let i = 1; i <= state.doc.lines; i++) { - const line = state.doc.line(i); - builder.add(line.from, line.from, topEditorLineDecos[(i - 1) % topEditorLineDecos.length]); - } - return builder.finish(); - } - - + const builder = new RangeSetBuilder(); + for (let i = 1; i <= state.doc.lines; i++) { + const line = state.doc.line(i); + builder.add( + line.from, + line.from, + topEditorLineDecos[(i - 1) % topEditorLineDecos.length], + ); + } + return builder.finish(); + } - // Load saved regex patterns or fallback to default - const savedRegex = localStorage.getItem('regex_patterns') || "/([a-zA-Z0-9._-]+)@([a-zA-Z0-9._-]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/"; + // Load saved regex patterns or fallback to default + const savedRegex = + localStorage.getItem("regex_patterns") || + "/([a-zA-Z0-9._-]+)@([a-zA-Z0-9._-]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/"; - // Load saved tabs/documents or fallback to defaults - let documents = JSON.parse(localStorage.getItem('regex_documents')) || [ - `Welcome to CodeMirror 6 Regex Visualizer! + // Load saved tabs/documents or fallback to defaults + let documents = JSON.parse(localStorage.getItem("regex_documents")) || [ + `Welcome to CodeMirror 6 Regex Visualizer! Test email addresses: - Support Team: support@example.com @@ -251,7 +313,7 @@ layout: post - User ID: 1042 | Session Token: abc123XYZ - Created on: 2026-10-17`, - `function testAuth() { + `function testAuth() { const token = "Bearer eyJhbGciOiJIUzI1NiIsIn"; let userId = 9921; // TODO: sanitize input @@ -259,268 +321,280 @@ layout: post } const date = "2026-10-18";`, - `[2026-10-18 10:15:30] INFO: User login success (uid=402, ip=192.168.1.50) + `[2026-10-18 10:15:30] INFO: User login success (uid=402, ip=192.168.1.50) [2026-10-18 10:16:02] ERROR: Invalid token provided (token=xyz_9988_abc) - [2026-10-18 10:17:11] WARN: Rate limit approaching for service@api.net` - ]; + [2026-10-18 10:17:11] WARN: Rate limit approaching for service@api.net`, + ]; - let tabTitles = JSON.parse(localStorage.getItem('regex_tab_titles')) || ["Emails & IDs", "Code Snippet", "Server Logs"]; - let activeTabIndex = parseInt(localStorage.getItem('regex_active_tab')) || 0; - if (activeTabIndex >= documents.length) activeTabIndex = 0; + let tabTitles = JSON.parse(localStorage.getItem("regex_tab_titles")) || [ + "Emails & IDs", + "Code Snippet", + "Server Logs", + ]; + let activeTabIndex = parseInt(localStorage.getItem("regex_active_tab")) || 0; + if (activeTabIndex >= documents.length) activeTabIndex = 0; - function saveToStorage() { + function saveToStorage() { // Keep current active tab text up to date in the array before saving documents[activeTabIndex] = mainView.state.doc.toString(); - - localStorage.setItem('regex_patterns', regexView.state.doc.toString()); - localStorage.setItem('regex_documents', JSON.stringify(documents)); - localStorage.setItem('regex_tab_titles', JSON.stringify(tabTitles)); - localStorage.setItem('regex_active_tab', activeTabIndex.toString()); - } - const initialRegex = "/([a-zA-Z0-9.\-_]+)@([a-zA-Z0-9.\-_]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/"; + localStorage.setItem("regex_patterns", regexView.state.doc.toString()); + localStorage.setItem("regex_documents", JSON.stringify(documents)); + localStorage.setItem("regex_tab_titles", JSON.stringify(tabTitles)); + localStorage.setItem("regex_active_tab", activeTabIndex.toString()); + } - const regexView = new EditorView({ + const initialRegex = + "/([a-zA-Z0-9.\-_]+)@([a-zA-Z0-9.\-_]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/"; + + const regexView = new EditorView({ state: EditorState.create({ - doc: savedRegex, - extensions: [ - lineNumbers(), - drawSelection(), - keymap.of(defaultKeymap), - regexTheme.of(themeFor(darkQuery.matches)), - topEditorField, - EditorView.updateListener.of(update => { - if (update.docChanged) { - dispatchRegexUpdate(); - saveToStorage(); - } - }) - ] + doc: savedRegex, + extensions: [ + lineNumbers(), + drawSelection(), + keymap.of(defaultKeymap), + regexTheme.of(themeFor(darkQuery.matches)), + topEditorField, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + dispatchRegexUpdate(); + saveToStorage(); + } + }), + ], }), - parent: document.getElementById('regex-editor') - }); + parent: document.getElementById("regex-editor"), + }); - const tabBarEl = document.getElementById('tab-bar'); + const tabBarEl = document.getElementById("tab-bar"); - function renderTabs() { - tabBarEl.innerHTML = ''; + function renderTabs() { + tabBarEl.innerHTML = ""; documents.forEach((_, index) => { - const tab = document.createElement('div'); - tab.className = 'tab-btn' + (index === activeTabIndex ? ' active' : ''); - tab.setAttribute('role', 'tab'); - - const label = document.createElement('span'); - label.textContent = tabTitles[index] || `Tab ${index + 1}`; - label.title = 'Double-click to rename'; - label.addEventListener('dblclick', () => renameTab(index, label)); - tab.appendChild(label); - - if (documents.length > 1) { - const closeBtn = document.createElement('span'); - closeBtn.className = 'tab-close'; - closeBtn.setAttribute('role', 'button'); - closeBtn.title = 'Close Tab'; - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', e => { e.stopPropagation(); closeTab(index); }); - tab.appendChild(closeBtn); - } + const tab = document.createElement("div"); + tab.className = "tab-btn" + (index === activeTabIndex ? " active" : ""); + tab.setAttribute("role", "tab"); + + const label = document.createElement("span"); + label.textContent = tabTitles[index] || `Tab ${index + 1}`; + label.title = "Double-click to rename"; + label.addEventListener("dblclick", () => renameTab(index, label)); + tab.appendChild(label); + + if (documents.length > 1) { + const closeBtn = document.createElement("span"); + closeBtn.className = "tab-close"; + closeBtn.setAttribute("role", "button"); + closeBtn.title = "Close Tab"; + closeBtn.textContent = "×"; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + closeTab(index); + }); + tab.appendChild(closeBtn); + } - tab.addEventListener('click', () => switchTab(index)); - tabBarEl.appendChild(tab); + tab.addEventListener("click", () => switchTab(index)); + tabBarEl.appendChild(tab); }); - } + } - function renameTab(index, label) { - const input = document.createElement('input'); - input.value = tabTitles[index] || ''; + function renameTab(index, label) { + const input = document.createElement("input"); + input.value = tabTitles[index] || ""; input.size = Math.max(6, input.value.length); label.replaceWith(input); input.focus(); input.select(); let done = false; - const finish = save => { - if (done) return; // blur also fires when the input is removed - done = true; - const name = input.value.trim(); - if (save && name) tabTitles[index] = name; // empty name keeps the old one - renderTabs(); - saveToStorage(); + const finish = (save) => { + if (done) return; // blur also fires when the input is removed + done = true; + const name = input.value.trim(); + if (save && name) tabTitles[index] = name; // empty name keeps the old one + renderTabs(); + saveToStorage(); }; - input.addEventListener('keydown', e => { - e.stopPropagation(); - if (e.key === 'Enter') finish(true); - if (e.key === 'Escape') finish(false); + input.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") finish(true); + if (e.key === "Escape") finish(false); }); - input.addEventListener('blur', () => finish(true)); - input.addEventListener('click', e => e.stopPropagation()); // don't trigger switchTab - } + input.addEventListener("blur", () => finish(true)); + input.addEventListener("click", (e) => e.stopPropagation()); // don't trigger switchTab + } function applyTheme() { - const t = themeFor(darkQuery.matches); - regexView.dispatch({ effects: regexTheme.reconfigure(t) }); - mainView.dispatch({ effects: mainTheme.reconfigure(t) }); + const t = themeFor(darkQuery.matches); + regexView.dispatch({ effects: regexTheme.reconfigure(t) }); + mainView.dispatch({ effects: mainTheme.reconfigure(t) }); } - darkQuery.addEventListener('change', applyTheme); + darkQuery.addEventListener("change", applyTheme); - function loadActive() { + function loadActive() { renderTabs(); mainView.setState(states[activeTabIndex]); - applyTheme(); - dispatchRegexUpdate(); // a stashed state may hold stale regex queries - saveToStorage(); // must run after setState so it saves the new tab's text - } + applyTheme(); + dispatchRegexUpdate(); // a stashed state may hold stale regex queries + saveToStorage(); // must run after setState so it saves the new tab's text + } - function switchTab(index) { + function switchTab(index) { if (index === activeTabIndex) return; - states[activeTabIndex] = mainView.state; // stash undo history + cursor + states[activeTabIndex] = mainView.state; // stash undo history + cursor activeTabIndex = index; loadActive(); - } + } - function closeTab(index) { + function closeTab(index) { const tabName = tabTitles[index] || `Tab ${index + 1}`; if (!window.confirm(`Are you sure you want to close "${tabName}"?`)) return; states[activeTabIndex] = mainView.state; - [documents, tabTitles, states].forEach(a => a.splice(index, 1)); - - if (index < activeTabIndex) activeTabIndex--; // also fixes the wrong-tab bug - else activeTabIndex = Math.min(activeTabIndex, documents.length - 1); - loadActive(); - } - - document.getElementById('add-tab-btn').addEventListener('click', () => { - const text = "// Type or paste text here...\n"; - states[activeTabIndex] = mainView.state; - documents.push(text); - tabTitles.push(`Dataset ${documents.length}`); - states.push(makeState(text)); - activeTabIndex = documents.length - 1; - loadActive(); - }); + [documents, tabTitles, states].forEach((a) => a.splice(index, 1)); - // Initial Render Call - renderTabs(); - - const updateRegexEffect = StateEffect.define(); - - const regexMatchDecos = [ - Decoration.mark({ class: 'cm-regex-match-0' }), - Decoration.mark({ class: 'cm-regex-match-1' }), - Decoration.mark({ class: 'cm-regex-match-2' }), - Decoration.mark({ class: 'cm-regex-match-3' }), - Decoration.mark({ class: 'cm-regex-match-4' }) - ]; - - const regexHighlightField = StateField.define({ - create: () => ({ decorations: Decoration.none, query: [] }), - update(value, tr) { + if (index < activeTabIndex) + activeTabIndex--; // also fixes the wrong-tab bug + else activeTabIndex = Math.min(activeTabIndex, documents.length - 1); + loadActive(); + } + + document.getElementById("add-tab-btn").addEventListener("click", () => { + const text = "// Type or paste text here...\n"; + states[activeTabIndex] = mainView.state; + documents.push(text); + tabTitles.push(`Dataset ${documents.length}`); + states.push(makeState(text)); + activeTabIndex = documents.length - 1; + loadActive(); + }); + + // Initial Render Call + renderTabs(); + + const updateRegexEffect = StateEffect.define(); + + const regexMatchDecos = [ + Decoration.mark({ class: "cm-regex-match-0" }), + Decoration.mark({ class: "cm-regex-match-1" }), + Decoration.mark({ class: "cm-regex-match-2" }), + Decoration.mark({ class: "cm-regex-match-3" }), + Decoration.mark({ class: "cm-regex-match-4" }), + ]; + + const regexHighlightField = StateField.define({ + create: () => ({ decorations: Decoration.none, query: [] }), + update(value, tr) { let currentQueries = value.query; for (let effect of tr.effects) { - if (effect.is(updateRegexEffect)) currentQueries = effect.value; + if (effect.is(updateRegexEffect)) currentQueries = effect.value; } if (!tr.docChanged && tr.effects.length === 0) return value; const docText = tr.state.doc.toString(); const ranges = []; - currentQueries.forEach(q => { - if (!q.pattern) return; - let activeRegex; - try { - activeRegex = new RegExp(q.pattern, q.flags); - } catch (err) { - return; - } - - let match; - const deco = regexMatchDecos[q.lineIndex % regexMatchDecos.length]; - - if (q.flags.includes('g')) { - while ((match = activeRegex.exec(docText)) !== null) { - const fullStart = match.index; - const fullEnd = fullStart + match[0].length; - if (fullStart !== fullEnd) { - ranges.push({ from: fullStart, to: fullEnd, deco }); - } - if (match.index === activeRegex.lastIndex) { - activeRegex.lastIndex++; - } - } - } else { - match = activeRegex.exec(docText); - if (match) { - const fullStart = match.index; - const fullEnd = fullStart + match[0].length; - if (fullStart !== fullEnd) { - ranges.push({ from: fullStart, to: fullEnd, deco }); - } - } - } + currentQueries.forEach((q) => { + if (!q.pattern) return; + let activeRegex; + try { + activeRegex = new RegExp(q.pattern, q.flags); + } catch (err) { + return; + } + + let match; + const deco = regexMatchDecos[q.lineIndex % regexMatchDecos.length]; + + if (q.flags.includes("g")) { + while ((match = activeRegex.exec(docText)) !== null) { + const fullStart = match.index; + const fullEnd = fullStart + match[0].length; + if (fullStart !== fullEnd) { + ranges.push({ from: fullStart, to: fullEnd, deco }); + } + if (match.index === activeRegex.lastIndex) { + activeRegex.lastIndex++; + } + } + } else { + match = activeRegex.exec(docText); + if (match) { + const fullStart = match.index; + const fullEnd = fullStart + match[0].length; + if (fullStart !== fullEnd) { + ranges.push({ from: fullStart, to: fullEnd, deco }); + } + } + } }); ranges.sort((a, b) => a.from - b.from || a.to - b.to); const builder = new RangeSetBuilder(); - ranges.forEach(r => builder.add(r.from, r.to, r.deco)); + ranges.forEach((r) => builder.add(r.from, r.to, r.deco)); return { decorations: builder.finish(), query: currentQueries }; - }, - provide: f => EditorView.decorations.from(f, val => val.decorations) - }); + }, + provide: (f) => EditorView.decorations.from(f, (val) => val.decorations), + }); - const mainExtensions = [ - lineNumbers(), - highlightActiveLineGutter(), - highlightSpecialChars(), - history(), - drawSelection(), - highlightActiveLine(), - bracketMatching(), - mainTheme.of(themeFor(darkQuery.matches)), - // syntaxHighlighting(defaultHighlightStyle, { fallback: true }), - keymap.of([...defaultKeymap, ...historyKeymap]), - regexHighlightField, - EditorView.updateListener.of(u => { if (u.docChanged) saveToStorage(); }) - ]; - - const makeState = doc => EditorState.create({ doc, extensions: mainExtensions }); - const states = documents.map(makeState); // one EditorState per tab, parallel to documents/tabTitles - - const mainView = new EditorView({ - state: states[activeTabIndex], - parent: document.getElementById('cm6-editor') - }); + const mainExtensions = [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + drawSelection(), + highlightActiveLine(), + bracketMatching(), + mainTheme.of(themeFor(darkQuery.matches)), + // syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([...defaultKeymap, ...historyKeymap]), + regexHighlightField, + EditorView.updateListener.of((u) => { + if (u.docChanged) saveToStorage(); + }), + ]; + + const makeState = (doc) => + EditorState.create({ doc, extensions: mainExtensions }); + const states = documents.map(makeState); // one EditorState per tab, parallel to documents/tabTitles - function dispatchRegexUpdate() { - const lines = regexView.state.doc.toString().split('\n'); - const queries = []; + const mainView = new EditorView({ + state: states[activeTabIndex], + parent: document.getElementById("cm6-editor"), + }); - lines.forEach((lineText, index) => { + function dispatchRegexUpdate() { + const lines = regexView.state.doc.toString().split("\n"); + const queries = []; + + lines.forEach((lineText, index) => { const trimmed = lineText.trim(); if (!trimmed) return; let pattern = trimmed; - let flags = 'g'; + let flags = "g"; - const lastSlashIndex = trimmed.lastIndexOf('/'); - if (trimmed.startsWith('/') && lastSlashIndex > 0) { - pattern = trimmed.substring(1, lastSlashIndex); - flags = trimmed.substring(lastSlashIndex + 1) || 'g'; + const lastSlashIndex = trimmed.lastIndexOf("/"); + if (trimmed.startsWith("/") && lastSlashIndex > 0) { + pattern = trimmed.substring(1, lastSlashIndex); + flags = trimmed.substring(lastSlashIndex + 1) || "g"; } queries.push({ pattern, flags, lineIndex: index }); - }); + }); - mainView.dispatch({ - effects: updateRegexEffect.of(queries) - }); - } + mainView.dispatch({ + effects: updateRegexEffect.of(queries), + }); + } - // Initial setup call - dispatchRegexUpdate(); + // Initial setup call + dispatchRegexUpdate(); - document.getElementById('loading').remove(); + document.getElementById("loading").remove(); </script> diff --git a/_tools/cm-esbuild/scripts/deploy.js b/_tools/cm-esbuild/scripts/deploy.js index a06130e..2dcae72 100644 --- a/_tools/cm-esbuild/scripts/deploy.js +++ b/_tools/cm-esbuild/scripts/deploy.js @@ -1,37 +1,55 @@ -const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const fs = require("fs"), + path = require("path"), + crypto = require("crypto"); const site = process.argv[2]; -if (!site) { console.error('usage: node scripts/publish.js <path-to-jekyll-site>'); process.exit(1); } +if (!site) { + console.error("usage: node scripts/publish.js <path-to-jekyll-site>"); + process.exit(1); +} -const dist = path.join(__dirname, '..', 'dist'); -const dest = path.join(site, 'assets', 'cm'); -const jsFiles = fs.readdirSync(dist).filter(f => f.endsWith('.js')).sort(); +const dist = path.join(__dirname, "..", "dist"); +const dest = path.join(site, "assets", "cm"); +const jsFiles = fs + .readdirSync(dist) + .filter((f) => f.endsWith(".js")) + .sort(); // version = hash of the bundle contents, so it only changes when the code does -const h = crypto.createHash('sha1'); +const h = crypto.createHash("sha1"); for (const f of jsFiles) h.update(fs.readFileSync(path.join(dist, f))); -const v = h.digest('hex').slice(0, 10); +const v = h.digest("hex").slice(0, 10); // replace old files so stale chunks don't pile up fs.rmSync(dest, { recursive: true, force: true }); fs.mkdirSync(dest, { recursive: true }); -for (const f of fs.readdirSync(dist)) fs.copyFileSync(path.join(dist, f), path.join(dest, f)); +for (const f of fs.readdirSync(dist)) + fs.copyFileSync(path.join(dist, f), path.join(dest, f)); const entries = { - '@codemirror/state': 'state', - '@codemirror/view': 'view', - '@codemirror/commands': 'commands', - '@codemirror/language': 'language', - '@uiw/codemirror-theme-github': 'theme', + "@codemirror/state": "state", + "@codemirror/view": "view", + "@codemirror/commands": "commands", + "@codemirror/language": "language", + "@uiw/codemirror-theme-github": "theme", }; const imports = Object.entries(entries) - .map(([spec, name]) => ` "${spec}": "{{ '/assets/cm/${name}.js' | relative_url }}?v=${v}"`) - .join(',\n'); -const preload = jsFiles.filter(f => f.startsWith('chunk-')) - .map(f => `<link rel="modulepreload" href="{{ '/assets/cm/${f}' | relative_url }}">`) - .join('\n'); + .map( + ([spec, name]) => + ` "${spec}": "{{ '/assets/cm/${name}.js' | relative_url }}?v=${v}"`, + ) + .join(",\n"); +const preload = jsFiles + .filter((f) => f.startsWith("chunk-")) + .map( + (f) => `<link + rel="modulepreload" + href="{{ '/assets/cm/${f}' | relative_url }}" +/>`, + ) + .join("\n"); -const html = `${preload}\n<script type="importmap">\n{\n "imports": {\n${imports}\n }\n}\n</script>\n`; -fs.mkdirSync(path.join(site, '_includes'), { recursive: true }); -fs.writeFileSync(path.join(site, '_includes', 'cm-importmap.html'), html); -console.log('published, version', v); +const html = `${preload}\n<script type="importmap">\n {\n "imports": {\n${imports}\n }\n }\n</script>\n`; +fs.mkdirSync(path.join(site, "_includes"), { recursive: true }); +fs.writeFileSync(path.join(site, "_includes", "cm-importmap.html"), html); +console.log("published, version", v); diff --git a/_tools/cm-esbuild/src/commands.js b/_tools/cm-esbuild/src/commands.js index 34031c2..1862ceb 100644 --- a/_tools/cm-esbuild/src/commands.js +++ b/_tools/cm-esbuild/src/commands.js @@ -1 +1 @@ -export * from '@codemirror/commands'; +export * from "@codemirror/commands"; diff --git a/_tools/cm-esbuild/src/language.js b/_tools/cm-esbuild/src/language.js index 9bfe6b6..632a9f7 100644 --- a/_tools/cm-esbuild/src/language.js +++ b/_tools/cm-esbuild/src/language.js @@ -1 +1 @@ -export * from '@codemirror/language'; +export * from "@codemirror/language"; diff --git a/_tools/cm-esbuild/src/state.js b/_tools/cm-esbuild/src/state.js index b6ef173..9dd5c97 100644 --- a/_tools/cm-esbuild/src/state.js +++ b/_tools/cm-esbuild/src/state.js @@ -1 +1 @@ -export * from '@codemirror/state'; +export * from "@codemirror/state"; diff --git a/_tools/cm-esbuild/src/theme.js b/_tools/cm-esbuild/src/theme.js index d7b898a..42f56ae 100644 --- a/_tools/cm-esbuild/src/theme.js +++ b/_tools/cm-esbuild/src/theme.js @@ -1 +1 @@ -export * from '@uiw/codemirror-theme-github'; +export * from "@uiw/codemirror-theme-github"; diff --git a/_tools/cm-esbuild/src/view.js b/_tools/cm-esbuild/src/view.js index 6d964dc..3669d05 100644 --- a/_tools/cm-esbuild/src/view.js +++ b/_tools/cm-esbuild/src/view.js @@ -1 +1 @@ -export * from '@codemirror/view'; +export * from "@codemirror/view"; diff --git a/_tools/e2e/playwright.config.ts b/_tools/e2e/playwright.config.ts index 1e97f70..fdd7826 100644 --- a/_tools/e2e/playwright.config.ts +++ b/_tools/e2e/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; /** * Read environment variables from file. @@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test'; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './tests', + testDir: "./tests", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -22,26 +22,26 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */ // baseURL: 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: "on-first-retry", }, /* Configure projects for major browsers */ projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: "chromium", + use: { ...devices["Desktop Chrome"] }, }, { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + name: "firefox", + use: { ...devices["Desktop Firefox"] }, }, // { @@ -72,9 +72,9 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'jekyll serve --source .. --destination ../_site', - url: 'http://localhost:4000', + command: "jekyll serve --source .. --destination ../_site", + url: "http://localhost:4000", reuseExistingServer: !process.env.CI, timeout: 60_000, }, -}) +}); diff --git a/_tools/e2e/tests/regex-editor.spec.ts b/_tools/e2e/tests/regex-editor.spec.ts index 1f3c0d3..fd0a04f 100644 --- a/_tools/e2e/tests/regex-editor.spec.ts +++ b/_tools/e2e/tests/regex-editor.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page, Locator, Dialog } from '@playwright/test'; +import { test, expect, Page, Locator, Dialog } from "@playwright/test"; /** * Playwright spec for https://hizbu.com/regex/ @@ -11,33 +11,39 @@ import { test, expect, Page, Locator, Dialog } from '@playwright/test'; * DOM node (`cmView` / `cmTile` property). If a CodeMirror upgrade ever moves that * property, only the `__cmView` init script below needs updating. */ -const URL = 'http://localhost:4000/regex/'; +const URL = "http://localhost:4000/regex/"; const KEY = { - patterns: 'regex_patterns', - docs: 'regex_documents', - titles: 'regex_tab_titles', - active: 'regex_active_tab', + patterns: "regex_patterns", + docs: "regex_documents", + titles: "regex_tab_titles", + active: "regex_active_tab", }; -const DEFAULT_TITLES = ['Emails & IDs', 'Code Snippet', 'Server Logs']; -const NEW_TAB_TEXT = '// Type or paste text here...\n'; +const DEFAULT_TITLES = ["Emails & IDs", "Code Snippet", "Server Logs"]; +const NEW_TAB_TEXT = "// Type or paste text here...\n"; // ---------- locators ---------- -const topEditor = (p: Page) => p.locator('#regex-editor .cm-content'); -const mainEditor = (p: Page) => p.locator('#cm6-editor .cm-content'); +const topEditor = (p: Page) => p.locator("#regex-editor .cm-content"); +const mainEditor = (p: Page) => p.locator("#cm6-editor .cm-content"); const tabs = (p: Page) => p.locator('#tab-bar [role="tab"]'); -const tabLabel = (p: Page, i: number) => tabs(p).nth(i).locator('span[title="Double-click to rename"]'); -const tabClose = (p: Page, i: number) => tabs(p).nth(i).locator('.tab-close'); -const addTabBtn = (p: Page) => p.locator('#add-tab-btn'); -const fullscreen = (p: Page) => p.locator('#fullscreen'); +const tabLabel = (p: Page, i: number) => + tabs(p).nth(i).locator('span[title="Double-click to rename"]'); +const tabClose = (p: Page, i: number) => tabs(p).nth(i).locator(".tab-close"); +const addTabBtn = (p: Page) => p.locator("#add-tab-btn"); +const fullscreen = (p: Page) => p.locator("#fullscreen"); const matchTexts = (p: Page, n: number) => p.locator(`#cm6-editor .cm-regex-match-${n}`).allTextContents(); // ---------- helpers ---------- const docText = (l: Locator): Promise<string> => l.evaluate((el: any) => { - try { return (window as any).__cmView(el).state.doc.toString(); } - catch { return [...el.querySelectorAll('.cm-line')].map((x: any) => x.textContent).join('\n'); } + try { + return (window as any).__cmView(el).state.doc.toString(); + } catch { + return [...el.querySelectorAll(".cm-line")] + .map((x: any) => x.textContent) + .join("\n"); + } }); const selection = (l: Locator): Promise<{ anchor: number; head: number }> => @@ -47,13 +53,16 @@ const selection = (l: Locator): Promise<{ anchor: number; head: number }> => }); /** Focus the editor WITHOUT changing its selection (unlike click). */ -const focusEditor = (l: Locator) => l.evaluate((el: any) => (window as any).__cmView(el).focus()); +const focusEditor = (l: Locator) => + l.evaluate((el: any) => (window as any).__cmView(el).focus()); -const storage = (p: Page, key: string) => p.evaluate((k) => localStorage.getItem(k), key); -const storageJson = async (p: Page, key: string) => JSON.parse((await storage(p, key)) ?? 'null'); +const storage = (p: Page, key: string) => + p.evaluate((k) => localStorage.getItem(k), key); +const storageJson = async (p: Page, key: string) => + JSON.parse((await storage(p, key)) ?? "null"); async function waitReady(p: Page) { - await expect(p.locator('#loading')).toHaveCount(0, { timeout: 30_000 }); + await expect(p.locator("#loading")).toHaveCount(0, { timeout: 30_000 }); await expect(topEditor(p)).toBeVisible(); await expect(mainEditor(p)).toBeVisible(); } @@ -66,10 +75,10 @@ async function reload(p: Page) { /** Replace the whole content of a CodeMirror editor by typing it. */ async function setDoc(p: Page, editor: Locator, lines: string[]) { await editor.click(); - await p.keyboard.press('ControlOrMeta+a'); - await p.keyboard.press('Backspace'); + await p.keyboard.press("ControlOrMeta+a"); + await p.keyboard.press("Backspace"); for (let i = 0; i < lines.length; i++) { - if (i > 0) await p.keyboard.press('Enter'); + if (i > 0) await p.keyboard.press("Enter"); if (lines[i]) await p.keyboard.type(lines[i]); } } @@ -81,11 +90,16 @@ async function redo(p: Page) { // CodeMirror historyKeymap: Mod-y = redo (Win/Linux), Mod-Shift-z = redo (Mac). // Ctrl+Shift+Z is avoided on purpose: on Linux Chromium it was observed to act as undo. const isMac = await p.evaluate(() => /Mac/i.test(navigator.platform)); - await p.keyboard.press(isMac ? 'Meta+Shift+z' : 'Control+y'); + await p.keyboard.press(isMac ? "Meta+Shift+z" : "Control+y"); } /** Close a tab; asserts the native confirm() text, then accepts or dismisses it. */ -async function closeTab(p: Page, index: number, accept: boolean, expectedName?: string) { +async function closeTab( + p: Page, + index: number, + accept: boolean, + expectedName?: string, +) { // window.confirm() blocks the page, so click() cannot resolve until the dialog is answered. // The handler therefore must be registered BEFORE the click and answer immediately. const seen: { type: string; message: string }[] = []; @@ -93,16 +107,19 @@ async function closeTab(p: Page, index: number, accept: boolean, expectedName?: seen.push({ type: d.type(), message: d.message() }); await (accept ? d.accept() : d.dismiss()); }; - p.on('dialog', handler); + p.on("dialog", handler); try { await tabClose(p, index).click(); } finally { - p.off('dialog', handler); + p.off("dialog", handler); } - expect(seen, 'expected exactly one confirm() dialog').toHaveLength(1); - expect(seen[0].type).toBe('confirm'); - if (expectedName) expect(seen[0].message).toBe(`Are you sure you want to close "${expectedName}"?`); + expect(seen, "expected exactly one confirm() dialog").toHaveLength(1); + expect(seen[0].type).toBe("confirm"); + if (expectedName) + expect(seen[0].message).toBe( + `Are you sure you want to close "${expectedName}"?`, + ); } test.beforeEach(async ({ page }) => { @@ -112,7 +129,10 @@ test.beforeEach(async ({ page }) => { (window as any).__cmView = (el: any) => { const t = el.cmView ?? el.cmTile; const v = t?.view ?? t?.root?.view ?? t?.rootView?.view; - if (!v) throw new Error('Could not locate CodeMirror EditorView on .cm-content'); + if (!v) + throw new Error( + "Could not locate CodeMirror EditorView on .cm-content", + ); return v; }; }); @@ -124,260 +144,312 @@ test.beforeEach(async ({ page }) => { // ===================================================================== // 0. initial state // ===================================================================== -test.describe('initial state', () => { - test('loads default tabs, regexes and highlights', async ({ page }) => { +test.describe("initial state", () => { + test("loads default tabs, regexes and highlights", async ({ page }) => { await expect(tabs(page)).toHaveCount(3); await expect(tabs(page).nth(0)).toHaveClass(/active/); - for (let i = 0; i < 3; i++) await expect(tabLabel(page, i)).toHaveText(DEFAULT_TITLES[i]); + for (let i = 0; i < 3; i++) + await expect(tabLabel(page, i)).toHaveText(DEFAULT_TITLES[i]); - await expect(topEditor(page)).toContainText('[0-9]{4}-[0-9]{2}-[0-9]{2}'); - await expect.poll(() => matchTexts(page, 0)).toContain('support@example.com'); - await expect.poll(() => matchTexts(page, 1)).toContain('2026-10-17'); + await expect(topEditor(page)).toContainText("[0-9]{4}-[0-9]{2}-[0-9]{2}"); + await expect + .poll(() => matchTexts(page, 0)) + .toContain("support@example.com"); + await expect.poll(() => matchTexts(page, 1)).toContain("2026-10-17"); }); }); // ===================================================================== // 1. every regex line is reflected in the lower editor // ===================================================================== -test.describe('regex lines -> lower editor highlights', () => { - test('each line is reflected (highlight class N = line index % 5) as it is typed', async ({ page }) => { - await setDoc(page, mainEditor(page), ['abc 123 DEF 456']); +test.describe("regex lines -> lower editor highlights", () => { + test("each line is reflected (highlight class N = line index % 5) as it is typed", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["abc 123 DEF 456"]); await setDoc(page, topEditor(page), []); // clear default regexes, leaves top editor focused const lines = [ - { text: '/[a-z]+/', expected: ['abc'] }, - { text: '/[0-9]+/', expected: ['123', '456'] }, - { text: '/[A-Z]+/', expected: ['DEF'] }, - { text: '/zzz/', expected: [] }, - { text: '/ /', expected: [' ', ' ', ' '] }, + { text: "/[a-z]+/", expected: ["abc"] }, + { text: "/[0-9]+/", expected: ["123", "456"] }, + { text: "/[A-Z]+/", expected: ["DEF"] }, + { text: "/zzz/", expected: [] }, + { text: "/ /", expected: [" ", " ", " "] }, ]; for (let i = 0; i < lines.length; i++) { - if (i > 0) await page.keyboard.press('Enter'); + if (i > 0) await page.keyboard.press("Enter"); await page.keyboard.type(lines[i].text); // lower editor shows this line's matches with its colour class await expect.poll(() => matchTexts(page, i)).toEqual(lines[i].expected); // top editor line fringe uses the same colour index - await expect(page.locator('#regex-editor .cm-line').nth(i)) - .toHaveClass(new RegExp(`cm-regex-line-${i % 5}`)); + await expect(page.locator("#regex-editor .cm-line").nth(i)).toHaveClass( + new RegExp(`cm-regex-line-${i % 5}`), + ); // all earlier lines are still reflected - for (let j = 0; j < i; j++) expect(await matchTexts(page, j)).toEqual(lines[j].expected); + for (let j = 0; j < i; j++) + expect(await matchTexts(page, j)).toEqual(lines[j].expected); } - await expect.poll(() => storage(page, KEY.patterns)).toBe(lines.map((l) => l.text).join('\n')); - }); - - test('colour index wraps after 5 lines', async ({ page }) => { - await setDoc(page, mainEditor(page), ['w0 w1 w2 w3 w4 w5']); - await setDoc(page, topEditor(page), [0, 1, 2, 3, 4, 5].map((i) => `/w${i}/`)); - - await expect.poll(() => matchTexts(page, 0)).toEqual(['w0', 'w5']); - for (const n of [1, 2, 3, 4]) expect(await matchTexts(page, n)).toEqual([`w${n}`]); - await expect(page.locator('#regex-editor .cm-line').nth(5)).toHaveClass(/cm-regex-line-0/); - }); - - test('blank lines are skipped but still consume a line index', async ({ page }) => { - await setDoc(page, mainEditor(page), ['abc 123']); - await setDoc(page, topEditor(page), ['/abc/', '', '/123/']); - - await expect.poll(() => matchTexts(page, 0)).toEqual(['abc']); + await expect + .poll(() => storage(page, KEY.patterns)) + .toBe(lines.map((l) => l.text).join("\n")); + }); + + test("colour index wraps after 5 lines", async ({ page }) => { + await setDoc(page, mainEditor(page), ["w0 w1 w2 w3 w4 w5"]); + await setDoc( + page, + topEditor(page), + [0, 1, 2, 3, 4, 5].map((i) => `/w${i}/`), + ); + + await expect.poll(() => matchTexts(page, 0)).toEqual(["w0", "w5"]); + for (const n of [1, 2, 3, 4]) + expect(await matchTexts(page, n)).toEqual([`w${n}`]); + await expect(page.locator("#regex-editor .cm-line").nth(5)).toHaveClass( + /cm-regex-line-0/, + ); + }); + + test("blank lines are skipped but still consume a line index", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["abc 123"]); + await setDoc(page, topEditor(page), ["/abc/", "", "/123/"]); + + await expect.poll(() => matchTexts(page, 0)).toEqual(["abc"]); expect(await matchTexts(page, 1)).toEqual([]); - expect(await matchTexts(page, 2)).toEqual(['123']); + expect(await matchTexts(page, 2)).toEqual(["123"]); }); - test('editing a line updates highlights; deleting all lines removes them', async ({ page }) => { - await setDoc(page, mainEditor(page), ['cat dog bird']); - await setDoc(page, topEditor(page), ['/cat/']); - await expect.poll(() => matchTexts(page, 0)).toEqual(['cat']); + test("editing a line updates highlights; deleting all lines removes them", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["cat dog bird"]); + await setDoc(page, topEditor(page), ["/cat/"]); + await expect.poll(() => matchTexts(page, 0)).toEqual(["cat"]); // replace line 0 - await page.keyboard.press('ControlOrMeta+Home'); - await page.keyboard.press('Shift+End'); - await page.keyboard.type('/dog/'); - await expect.poll(() => matchTexts(page, 0)).toEqual(['dog']); + await page.keyboard.press("ControlOrMeta+Home"); + await page.keyboard.press("Shift+End"); + await page.keyboard.type("/dog/"); + await expect.poll(() => matchTexts(page, 0)).toEqual(["dog"]); // delete everything - await page.keyboard.press('ControlOrMeta+a'); - await page.keyboard.press('Backspace'); - await expect(page.locator('#cm6-editor [class*="cm-regex-match-"]')).toHaveCount(0); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Backspace"); + await expect( + page.locator('#cm6-editor [class*="cm-regex-match-"]'), + ).toHaveCount(0); }); - test('editing the lower editor text refreshes highlights', async ({ page }) => { - await setDoc(page, topEditor(page), ['/foo/']); - await setDoc(page, mainEditor(page), ['nothing here']); - await expect(page.locator('#cm6-editor .cm-regex-match-0')).toHaveCount(0); + test("editing the lower editor text refreshes highlights", async ({ + page, + }) => { + await setDoc(page, topEditor(page), ["/foo/"]); + await setDoc(page, mainEditor(page), ["nothing here"]); + await expect(page.locator("#cm6-editor .cm-regex-match-0")).toHaveCount(0); - await page.keyboard.type(' foo foo'); - await expect.poll(() => matchTexts(page, 0)).toEqual(['foo', 'foo']); + await page.keyboard.type(" foo foo"); + await expect.poll(() => matchTexts(page, 0)).toEqual(["foo", "foo"]); }); - test('flags: g default, explicit flags honoured, no-g matches only once', async ({ page }) => { - await setDoc(page, mainEditor(page), ['abc ABC abc']); + test("flags: g default, explicit flags honoured, no-g matches only once", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["abc ABC abc"]); - await setDoc(page, topEditor(page), ['/abc/gi']); - await expect.poll(() => matchTexts(page, 0)).toEqual(['abc', 'ABC', 'abc']); + await setDoc(page, topEditor(page), ["/abc/gi"]); + await expect.poll(() => matchTexts(page, 0)).toEqual(["abc", "ABC", "abc"]); - await setDoc(page, topEditor(page), ['/abc/i']); // no g -> first match only - await expect.poll(() => matchTexts(page, 0)).toEqual(['abc']); + await setDoc(page, topEditor(page), ["/abc/i"]); // no g -> first match only + await expect.poll(() => matchTexts(page, 0)).toEqual(["abc"]); - await setDoc(page, topEditor(page), ['abc']); // no slashes -> raw pattern, g flag - await expect.poll(() => matchTexts(page, 0)).toEqual(['abc', 'abc']); + await setDoc(page, topEditor(page), ["abc"]); // no slashes -> raw pattern, g flag + await expect.poll(() => matchTexts(page, 0)).toEqual(["abc", "abc"]); }); - test('invalid regex line is ignored without breaking other lines', async ({ page }) => { + test("invalid regex line is ignored without breaking other lines", async ({ + page, + }) => { const errors: string[] = []; - page.on('pageerror', (e) => errors.push(e.message)); + page.on("pageerror", (e) => errors.push(e.message)); - await setDoc(page, mainEditor(page), ['abc 123']); - await setDoc(page, topEditor(page), ['/(/', '/123/']); + await setDoc(page, mainEditor(page), ["abc 123"]); + await setDoc(page, topEditor(page), ["/(/", "/123/"]); - await expect.poll(() => matchTexts(page, 1)).toEqual(['123']); + await expect.poll(() => matchTexts(page, 1)).toEqual(["123"]); expect(await matchTexts(page, 0)).toEqual([]); expect(errors).toEqual([]); }); - test('top editor gets one fringe-coloured line per regex line', async ({ page }) => { - await setDoc(page, topEditor(page), ['a', 'b', 'c', 'd', 'e', 'f']); - const lines = page.locator('#regex-editor .cm-line'); + test("top editor gets one fringe-coloured line per regex line", async ({ + page, + }) => { + await setDoc(page, topEditor(page), ["a", "b", "c", "d", "e", "f"]); + const lines = page.locator("#regex-editor .cm-line"); await expect(lines).toHaveCount(6); - for (let i = 0; i < 6; i++) await expect(lines.nth(i)).toHaveClass(new RegExp(`cm-regex-line-${i % 5}`)); + for (let i = 0; i < 6; i++) + await expect(lines.nth(i)).toHaveClass( + new RegExp(`cm-regex-line-${i % 5}`), + ); }); }); // ===================================================================== // 2. localStorage // ===================================================================== -test.describe('localStorage', () => { - test('nothing is written until the first change, then all keys exist', async ({ page }) => { +test.describe("localStorage", () => { + test("nothing is written until the first change, then all keys exist", async ({ + page, + }) => { expect(await storage(page, KEY.docs)).toBeNull(); - await setDoc(page, topEditor(page), ['/x/']); - await expect.poll(() => storage(page, KEY.patterns)).toBe('/x/'); + await setDoc(page, topEditor(page), ["/x/"]); + await expect.poll(() => storage(page, KEY.patterns)).toBe("/x/"); expect(await storageJson(page, KEY.titles)).toEqual(DEFAULT_TITLES); - expect(await storage(page, KEY.active)).toBe('0'); + expect(await storage(page, KEY.active)).toBe("0"); expect((await storageJson(page, KEY.docs)).length).toBe(3); }); - test('regex editor text is stored and restored', async ({ page }) => { - await setDoc(page, topEditor(page), ['/one/', '/two/g']); - await expect.poll(() => storage(page, KEY.patterns)).toBe('/one/\n/two/g'); + test("regex editor text is stored and restored", async ({ page }) => { + await setDoc(page, topEditor(page), ["/one/", "/two/g"]); + await expect.poll(() => storage(page, KEY.patterns)).toBe("/one/\n/two/g"); await reload(page); - expect(await docText(topEditor(page))).toBe('/one/\n/two/g'); + expect(await docText(topEditor(page))).toBe("/one/\n/two/g"); }); - test('lower editor text is stored per tab and restored', async ({ page }) => { - await setDoc(page, mainEditor(page), ['tab zero text']); + test("lower editor text is stored per tab and restored", async ({ page }) => { + await setDoc(page, mainEditor(page), ["tab zero text"]); await switchTo(page, 1); - await setDoc(page, mainEditor(page), ['tab one text']); + await setDoc(page, mainEditor(page), ["tab one text"]); - await expect.poll(async () => (await storageJson(page, KEY.docs))?.[0]).toBe('tab zero text'); - expect((await storageJson(page, KEY.docs))[1]).toBe('tab one text'); - expect(await storage(page, KEY.active)).toBe('1'); + await expect + .poll(async () => (await storageJson(page, KEY.docs))?.[0]) + .toBe("tab zero text"); + expect((await storageJson(page, KEY.docs))[1]).toBe("tab one text"); + expect(await storage(page, KEY.active)).toBe("1"); await reload(page); await expect(tabs(page).nth(1)).toHaveClass(/active/); // active tab restored - expect(await docText(mainEditor(page))).toBe('tab one text'); + expect(await docText(mainEditor(page))).toBe("tab one text"); await switchTo(page, 0); - expect(await docText(mainEditor(page))).toBe('tab zero text'); + expect(await docText(mainEditor(page))).toBe("tab zero text"); }); - test('tab titles and tab count are stored and restored', async ({ page }) => { + test("tab titles and tab count are stored and restored", async ({ page }) => { await addTabBtn(page).click(); await tabLabel(page, 0).dblclick(); - await page.locator('#tab-bar input').fill('Renamed'); - await page.keyboard.press('Enter'); + await page.locator("#tab-bar input").fill("Renamed"); + await page.keyboard.press("Enter"); - await expect.poll(() => storageJson(page, KEY.titles)).toEqual(['Renamed', 'Code Snippet', 'Server Logs', 'Dataset 4']); + await expect + .poll(() => storageJson(page, KEY.titles)) + .toEqual(["Renamed", "Code Snippet", "Server Logs", "Dataset 4"]); await reload(page); await expect(tabs(page)).toHaveCount(4); - await expect(tabLabel(page, 0)).toHaveText('Renamed'); - await expect(tabLabel(page, 3)).toHaveText('Dataset 4'); + await expect(tabLabel(page, 0)).toHaveText("Renamed"); + await expect(tabLabel(page, 3)).toHaveText("Dataset 4"); }); - test('highlights are re-applied after reload', async ({ page }) => { - await setDoc(page, mainEditor(page), ['needle hay needle']); - await setDoc(page, topEditor(page), ['/needle/']); - await expect.poll(() => matchTexts(page, 0)).toEqual(['needle', 'needle']); + test("highlights are re-applied after reload", async ({ page }) => { + await setDoc(page, mainEditor(page), ["needle hay needle"]); + await setDoc(page, topEditor(page), ["/needle/"]); + await expect.poll(() => matchTexts(page, 0)).toEqual(["needle", "needle"]); await reload(page); - await expect.poll(() => matchTexts(page, 0)).toEqual(['needle', 'needle']); + await expect.poll(() => matchTexts(page, 0)).toEqual(["needle", "needle"]); }); - test('clearing localStorage resets to defaults', async ({ page }) => { - await setDoc(page, topEditor(page), ['/wiped/']); - await expect.poll(() => storage(page, KEY.patterns)).toBe('/wiped/'); + test("clearing localStorage resets to defaults", async ({ page }) => { + await setDoc(page, topEditor(page), ["/wiped/"]); + await expect.poll(() => storage(page, KEY.patterns)).toBe("/wiped/"); await page.evaluate(() => localStorage.clear()); await reload(page); await expect(tabs(page)).toHaveCount(3); - expect(await docText(topEditor(page))).not.toContain('wiped'); + expect(await docText(topEditor(page))).not.toContain("wiped"); }); }); // ===================================================================== // 3. rename tab on double click // ===================================================================== -test.describe('rename tab', () => { - const input = (p: Page) => p.locator('#tab-bar input'); +test.describe("rename tab", () => { + const input = (p: Page) => p.locator("#tab-bar input"); - test('double click shows a focused input with the current name selected', async ({ page }) => { + test("double click shows a focused input with the current name selected", async ({ + page, + }) => { await tabLabel(page, 0).dblclick(); await expect(input(page)).toBeVisible(); await expect(input(page)).toBeFocused(); await expect(input(page)).toHaveValue(DEFAULT_TITLES[0]); - expect(await input(page).evaluate((el: HTMLInputElement) => el.selectionEnd! - el.selectionStart!)) - .toBe(DEFAULT_TITLES[0].length); + expect( + await input(page).evaluate( + (el: HTMLInputElement) => el.selectionEnd! - el.selectionStart!, + ), + ).toBe(DEFAULT_TITLES[0].length); }); - test('Enter commits, trims, and persists across reload', async ({ page }) => { + test("Enter commits, trims, and persists across reload", async ({ page }) => { await tabLabel(page, 0).dblclick(); - await input(page).fill(' My Tab '); - await page.keyboard.press('Enter'); + await input(page).fill(" My Tab "); + await page.keyboard.press("Enter"); await expect(input(page)).toHaveCount(0); - await expect(tabLabel(page, 0)).toHaveText('My Tab'); - await expect.poll(async () => (await storageJson(page, KEY.titles))[0]).toBe('My Tab'); + await expect(tabLabel(page, 0)).toHaveText("My Tab"); + await expect + .poll(async () => (await storageJson(page, KEY.titles))[0]) + .toBe("My Tab"); await reload(page); - await expect(tabLabel(page, 0)).toHaveText('My Tab'); + await expect(tabLabel(page, 0)).toHaveText("My Tab"); }); - test('Escape cancels', async ({ page }) => { + test("Escape cancels", async ({ page }) => { await tabLabel(page, 0).dblclick(); - await input(page).fill('nope'); - await page.keyboard.press('Escape'); + await input(page).fill("nope"); + await page.keyboard.press("Escape"); await expect(tabLabel(page, 0)).toHaveText(DEFAULT_TITLES[0]); }); - test('blur commits', async ({ page }) => { + test("blur commits", async ({ page }) => { await tabLabel(page, 0).dblclick(); - await input(page).fill('Blurred'); + await input(page).fill("Blurred"); await mainEditor(page).click(); - await expect(tabLabel(page, 0)).toHaveText('Blurred'); + await expect(tabLabel(page, 0)).toHaveText("Blurred"); }); - test('empty / whitespace-only name keeps the old name', async ({ page }) => { + test("empty / whitespace-only name keeps the old name", async ({ page }) => { await tabLabel(page, 0).dblclick(); - await input(page).fill(' '); - await page.keyboard.press('Enter'); + await input(page).fill(" "); + await page.keyboard.press("Enter"); await expect(tabLabel(page, 0)).toHaveText(DEFAULT_TITLES[0]); }); - test('typing in the rename input (incl. Enter) never edits the editors', async ({ page }) => { - await setDoc(page, mainEditor(page), ['keep me']); - await setDoc(page, topEditor(page), ['/keep/']); + test("typing in the rename input (incl. Enter) never edits the editors", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["keep me"]); + await setDoc(page, topEditor(page), ["/keep/"]); await tabLabel(page, 0).dblclick(); - await input(page).fill('abc'); - await page.keyboard.press('Enter'); - expect(await docText(mainEditor(page))).toBe('keep me'); - expect(await docText(topEditor(page))).toBe('/keep/'); + await input(page).fill("abc"); + await page.keyboard.press("Enter"); + expect(await docText(mainEditor(page))).toBe("keep me"); + expect(await docText(topEditor(page))).toBe("/keep/"); }); - test('renaming a non-active tab also works (and activates it)', async ({ page }) => { + test("renaming a non-active tab also works (and activates it)", async ({ + page, + }) => { await tabLabel(page, 2).dblclick(); - await input(page).fill('Logs!'); - await page.keyboard.press('Enter'); - await expect(tabLabel(page, 2)).toHaveText('Logs!'); + await input(page).fill("Logs!"); + await page.keyboard.press("Enter"); + await expect(tabLabel(page, 2)).toHaveText("Logs!"); await expect(tabs(page).nth(2)).toHaveClass(/active/); }); - test('clicking inside the rename input does not switch tabs', async ({ page }) => { + test("clicking inside the rename input does not switch tabs", async ({ + page, + }) => { await tabLabel(page, 0).dblclick(); await input(page).click(); await expect(input(page)).toBeVisible(); @@ -388,86 +460,104 @@ test.describe('rename tab', () => { // ===================================================================== // 4. add / delete tab (with confirmation) // ===================================================================== -test.describe('add and delete tabs', () => { - test('add creates "Dataset N" with starter text and activates it', async ({ page }) => { +test.describe("add and delete tabs", () => { + test('add creates "Dataset N" with starter text and activates it', async ({ + page, + }) => { await addTabBtn(page).click(); await expect(tabs(page)).toHaveCount(4); - await expect(tabLabel(page, 3)).toHaveText('Dataset 4'); + await expect(tabLabel(page, 3)).toHaveText("Dataset 4"); await expect(tabs(page).nth(3)).toHaveClass(/active/); expect(await docText(mainEditor(page))).toBe(NEW_TAB_TEXT); await addTabBtn(page).click(); - await expect(tabLabel(page, 4)).toHaveText('Dataset 5'); + await expect(tabLabel(page, 4)).toHaveText("Dataset 5"); }); - test('add keeps the previous tab content intact', async ({ page }) => { - await setDoc(page, mainEditor(page), ['original']); + test("add keeps the previous tab content intact", async ({ page }) => { + await setDoc(page, mainEditor(page), ["original"]); await addTabBtn(page).click(); await switchTo(page, 0); - expect(await docText(mainEditor(page))).toBe('original'); + expect(await docText(mainEditor(page))).toBe("original"); }); - test('delete shows confirm() with the tab name; cancelling keeps the tab', async ({ page }) => { - await closeTab(page, 1, false, 'Code Snippet'); + test("delete shows confirm() with the tab name; cancelling keeps the tab", async ({ + page, + }) => { + await closeTab(page, 1, false, "Code Snippet"); await expect(tabs(page)).toHaveCount(3); - await expect(tabLabel(page, 1)).toHaveText('Code Snippet'); + await expect(tabLabel(page, 1)).toHaveText("Code Snippet"); }); - test('accepting removes the tab and persists', async ({ page }) => { - await closeTab(page, 1, true, 'Code Snippet'); + test("accepting removes the tab and persists", async ({ page }) => { + await closeTab(page, 1, true, "Code Snippet"); await expect(tabs(page)).toHaveCount(2); - await expect(tabLabel(page, 0)).toHaveText('Emails & IDs'); - await expect(tabLabel(page, 1)).toHaveText('Server Logs'); + await expect(tabLabel(page, 0)).toHaveText("Emails & IDs"); + await expect(tabLabel(page, 1)).toHaveText("Server Logs"); - await expect.poll(() => storageJson(page, KEY.titles)).toEqual(['Emails & IDs', 'Server Logs']); + await expect + .poll(() => storageJson(page, KEY.titles)) + .toEqual(["Emails & IDs", "Server Logs"]); expect((await storageJson(page, KEY.docs)).length).toBe(2); await reload(page); await expect(tabs(page)).toHaveCount(2); }); - test('close click does not switch to the tab being closed when cancelled', async ({ page }) => { + test("close click does not switch to the tab being closed when cancelled", async ({ + page, + }) => { await closeTab(page, 2, false); await expect(tabs(page).nth(0)).toHaveClass(/active/); }); - test('deleting a tab BEFORE the active one keeps the same tab and content active', async ({ page }) => { + test("deleting a tab BEFORE the active one keeps the same tab and content active", async ({ + page, + }) => { await switchTo(page, 2); const before = await docText(mainEditor(page)); await closeTab(page, 0, true); await expect(tabs(page)).toHaveCount(2); await expect(tabs(page).nth(1)).toHaveClass(/active/); - await expect(tabLabel(page, 1)).toHaveText('Server Logs'); + await expect(tabLabel(page, 1)).toHaveText("Server Logs"); expect(await docText(mainEditor(page))).toBe(before); }); - test('deleting the active LAST tab activates the new last tab', async ({ page }) => { + test("deleting the active LAST tab activates the new last tab", async ({ + page, + }) => { await switchTo(page, 2); await closeTab(page, 2, true); await expect(tabs(page)).toHaveCount(2); await expect(tabs(page).nth(1)).toHaveClass(/active/); - await expect(tabLabel(page, 1)).toHaveText('Code Snippet'); + await expect(tabLabel(page, 1)).toHaveText("Code Snippet"); }); - test('deleting the active FIRST tab activates the tab that took its place', async ({ page }) => { + test("deleting the active FIRST tab activates the tab that took its place", async ({ + page, + }) => { await closeTab(page, 0, true); await expect(tabs(page).nth(0)).toHaveClass(/active/); - await expect(tabLabel(page, 0)).toHaveText('Code Snippet'); + await expect(tabLabel(page, 0)).toHaveText("Code Snippet"); }); - test('last remaining tab cannot be closed (no close button)', async ({ page }) => { + test("last remaining tab cannot be closed (no close button)", async ({ + page, + }) => { await closeTab(page, 0, true); await closeTab(page, 0, true); await expect(tabs(page)).toHaveCount(1); - await expect(page.locator('#tab-bar .tab-close')).toHaveCount(0); + await expect(page.locator("#tab-bar .tab-close")).toHaveCount(0); }); - test('deleted tab edits do not leak into remaining tabs', async ({ page }) => { + test("deleted tab edits do not leak into remaining tabs", async ({ + page, + }) => { await switchTo(page, 1); - await setDoc(page, mainEditor(page), ['doomed']); + await setDoc(page, mainEditor(page), ["doomed"]); await closeTab(page, 1, true); for (let i = 0; i < 2; i++) { await switchTo(page, i); - expect(await docText(mainEditor(page))).not.toBe('doomed'); + expect(await docText(mainEditor(page))).not.toBe("doomed"); } }); }); @@ -475,59 +565,74 @@ test.describe('add and delete tabs', () => { // ===================================================================== // 5. fullscreen (body margin / padding / max-width) // ===================================================================== -test.describe('fullscreen', () => { +test.describe("fullscreen", () => { const bodyMetrics = (p: Page) => p.evaluate(() => { const cs = getComputedStyle(document.body); return { margin: [cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft], - padding: [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft], + padding: [ + cs.paddingTop, + cs.paddingRight, + cs.paddingBottom, + cs.paddingLeft, + ], maxWidth: cs.maxWidth, - inline: ['margin', 'padding', 'max-width'].map((k) => document.body.style.getPropertyValue(k)), + inline: ["margin", "padding", "max-width"].map((k) => + document.body.style.getPropertyValue(k), + ), }; }); - const ZERO = ['0px', '0px', '0px', '0px']; + const ZERO = ["0px", "0px", "0px", "0px"]; - test('checkbox is unchecked by default', async ({ page }) => { + test("checkbox is unchecked by default", async ({ page }) => { await expect(fullscreen(page)).not.toBeChecked(); }); - test('checking removes body margin & padding and lifts max-width', async ({ page }) => { + test("checking removes body margin & padding and lifts max-width", async ({ + page, + }) => { await fullscreen(page).check(); const m = await bodyMetrics(page); expect(m.margin).toEqual(ZERO); expect(m.padding).toEqual(ZERO); - expect(m.maxWidth).toBe('none'); - expect(m.inline).toEqual(['0px', '0px', 'none']); + expect(m.maxWidth).toBe("none"); + expect(m.inline).toEqual(["0px", "0px", "none"]); - const box = (await page.locator('body').boundingBox())!; + const box = (await page.locator("body").boundingBox())!; expect(box.x).toBe(0); - const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + const clientWidth = await page.evaluate( + () => document.documentElement.clientWidth, + ); expect(Math.round(box.width)).toBe(clientWidth); }); - test('unchecking restores the original body styles exactly', async ({ page }) => { + test("unchecking restores the original body styles exactly", async ({ + page, + }) => { const before = await bodyMetrics(page); await fullscreen(page).check(); await fullscreen(page).uncheck(); expect(await bodyMetrics(page)).toEqual(before); }); - test('editors become at least as wide in fullscreen', async ({ page }) => { - const wBefore = (await page.locator('#regex-editor').boundingBox())!.width; + test("editors become at least as wide in fullscreen", async ({ page }) => { + const wBefore = (await page.locator("#regex-editor").boundingBox())!.width; await fullscreen(page).check(); - const wAfter = (await page.locator('#regex-editor').boundingBox())!.width; + const wAfter = (await page.locator("#regex-editor").boundingBox())!.width; expect(wAfter).toBeGreaterThanOrEqual(wBefore); }); - test('fullscreen survives tab switching, add/rename and typing', async ({ page }) => { + test("fullscreen survives tab switching, add/rename and typing", async ({ + page, + }) => { await fullscreen(page).check(); await switchTo(page, 1); await addTabBtn(page).click(); - await setDoc(page, topEditor(page), ['/x/']); + await setDoc(page, topEditor(page), ["/x/"]); await tabLabel(page, 0).dblclick(); - await page.locator('#tab-bar input').fill('FS'); - await page.keyboard.press('Enter'); + await page.locator("#tab-bar input").fill("FS"); + await page.keyboard.press("Enter"); const m = await bodyMetrics(page); expect(m.margin).toEqual(ZERO); @@ -535,7 +640,7 @@ test.describe('fullscreen', () => { await expect(fullscreen(page)).toBeChecked(); }); - test('toggling repeatedly is stable', async ({ page }) => { + test("toggling repeatedly is stable", async ({ page }) => { const before = await bodyMetrics(page); for (let i = 0; i < 3; i++) { await fullscreen(page).check(); @@ -550,21 +655,25 @@ test.describe('fullscreen', () => { // 6. undo / redo / selection / cursor preserved across tab movement // (each tab has its own EditorState in the lower editor) // ===================================================================== -test.describe('per-tab editor state', () => { +test.describe("per-tab editor state", () => { const HISTORY_GAP = 700; // CodeMirror groups edits within 500ms into one undo step - test('cursor position is preserved per tab', async ({ page }) => { - await setDoc(page, mainEditor(page), ['line one', 'line two', 'line three']); - await page.keyboard.press('ControlOrMeta+Home'); - await page.keyboard.press('ArrowDown'); - for (let i = 0; i < 4; i++) await page.keyboard.press('ArrowRight'); + test("cursor position is preserved per tab", async ({ page }) => { + await setDoc(page, mainEditor(page), [ + "line one", + "line two", + "line three", + ]); + await page.keyboard.press("ControlOrMeta+Home"); + await page.keyboard.press("ArrowDown"); + for (let i = 0; i < 4; i++) await page.keyboard.press("ArrowRight"); const cursor0 = await selection(mainEditor(page)); expect(cursor0.head).toBe(9 + 4); expect(cursor0.anchor).toBe(cursor0.head); await switchTo(page, 1); await focusEditor(mainEditor(page)); - await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.press("ControlOrMeta+End"); const len1 = (await docText(mainEditor(page))).length; expect((await selection(mainEditor(page))).head).toBe(len1); @@ -573,23 +682,25 @@ test.describe('per-tab editor state', () => { // typing at the restored position lands exactly there await focusEditor(mainEditor(page)); - await page.keyboard.type('X'); - expect(await docText(mainEditor(page))).toBe('line one\nlineX two\nline three'); + await page.keyboard.type("X"); + expect(await docText(mainEditor(page))).toBe( + "line one\nlineX two\nline three", + ); await switchTo(page, 1); expect((await selection(mainEditor(page))).head).toBe(len1); }); - test('selection range is preserved per tab', async ({ page }) => { - await setDoc(page, mainEditor(page), ['hello brave new world']); - await page.keyboard.press('ControlOrMeta+Home'); - for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowRight'); - for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowRight'); // "brave" + test("selection range is preserved per tab", async ({ page }) => { + await setDoc(page, mainEditor(page), ["hello brave new world"]); + await page.keyboard.press("ControlOrMeta+Home"); + for (let i = 0; i < 6; i++) await page.keyboard.press("ArrowRight"); + for (let i = 0; i < 5; i++) await page.keyboard.press("Shift+ArrowRight"); // "brave" expect(await selection(mainEditor(page))).toEqual({ anchor: 6, head: 11 }); await switchTo(page, 1); await focusEditor(mainEditor(page)); - await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press("ControlOrMeta+a"); const all1 = await selection(mainEditor(page)); expect(all1.anchor).toBe(0); expect(all1.head).toBeGreaterThan(0); @@ -602,14 +713,16 @@ test.describe('per-tab editor state', () => { // selection is real: typing replaces exactly the preserved range await switchTo(page, 0); await focusEditor(mainEditor(page)); - await page.keyboard.type('BRAVE'); - expect(await docText(mainEditor(page))).toBe('hello BRAVE new world'); + await page.keyboard.type("BRAVE"); + expect(await docText(mainEditor(page))).toBe("hello BRAVE new world"); }); - test('selection direction (anchor vs head) is preserved', async ({ page }) => { - await setDoc(page, mainEditor(page), ['abcdefghij']); - await page.keyboard.press('ControlOrMeta+End'); - for (let i = 0; i < 4; i++) await page.keyboard.press('Shift+ArrowLeft'); // backwards + test("selection direction (anchor vs head) is preserved", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["abcdefghij"]); + await page.keyboard.press("ControlOrMeta+End"); + for (let i = 0; i < 4; i++) await page.keyboard.press("Shift+ArrowLeft"); // backwards const sel = await selection(mainEditor(page)); expect(sel.head).toBeLessThan(sel.anchor); @@ -618,76 +731,80 @@ test.describe('per-tab editor state', () => { expect(await selection(mainEditor(page))).toEqual(sel); }); - test('undo history is preserved per tab and does not leak between tabs', async ({ page }) => { + test("undo history is preserved per tab and does not leak between tabs", async ({ + page, + }) => { // tab 0: three separate undo steps - await setDoc(page, mainEditor(page), ['base']); + await setDoc(page, mainEditor(page), ["base"]); await page.waitForTimeout(HISTORY_GAP); - await page.keyboard.type(' one'); + await page.keyboard.type(" one"); await page.waitForTimeout(HISTORY_GAP); - await page.keyboard.type(' two'); + await page.keyboard.type(" two"); await page.waitForTimeout(HISTORY_GAP); - await page.keyboard.type(' three'); - expect(await docText(mainEditor(page))).toBe('base one two three'); + await page.keyboard.type(" three"); + expect(await docText(mainEditor(page))).toBe("base one two three"); // tab 1: its own edit await switchTo(page, 1); const tab1Original = await docText(mainEditor(page)); await focusEditor(mainEditor(page)); - await page.keyboard.press('ControlOrMeta+End'); - await page.keyboard.type(' T1'); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.type(" T1"); await page.waitForTimeout(HISTORY_GAP); - expect(await docText(mainEditor(page))).toBe(tab1Original + ' T1'); + expect(await docText(mainEditor(page))).toBe(tab1Original + " T1"); // back to tab 0: undo works step by step, with no tab-1 text involved await switchTo(page, 0); await focusEditor(mainEditor(page)); - await page.keyboard.press('ControlOrMeta+z'); - expect(await docText(mainEditor(page))).toBe('base one two'); - await page.keyboard.press('ControlOrMeta+z'); - expect(await docText(mainEditor(page))).toBe('base one'); + await page.keyboard.press("ControlOrMeta+z"); + expect(await docText(mainEditor(page))).toBe("base one two"); + await page.keyboard.press("ControlOrMeta+z"); + expect(await docText(mainEditor(page))).toBe("base one"); // tab 1: its undo only removes its own edit await switchTo(page, 1); await focusEditor(mainEditor(page)); - await page.keyboard.press('ControlOrMeta+z'); + await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe(tab1Original); - await page.keyboard.press('ControlOrMeta+z'); // nothing more to undo + await page.keyboard.press("ControlOrMeta+z"); // nothing more to undo expect(await docText(mainEditor(page))).toBe(tab1Original); // tab 0 untouched by tab 1's undo await switchTo(page, 0); - expect(await docText(mainEditor(page))).toBe('base one'); + expect(await docText(mainEditor(page))).toBe("base one"); }); - test('redo history is preserved across tab switches', async ({ page }) => { - await setDoc(page, mainEditor(page), ['alpha']); + test("redo history is preserved across tab switches", async ({ page }) => { + await setDoc(page, mainEditor(page), ["alpha"]); await page.waitForTimeout(HISTORY_GAP); - await page.keyboard.type(' beta'); + await page.keyboard.type(" beta"); await page.waitForTimeout(HISTORY_GAP); - await page.keyboard.type(' gamma'); - await page.keyboard.press('ControlOrMeta+z'); - await page.keyboard.press('ControlOrMeta+z'); - expect(await docText(mainEditor(page))).toBe('alpha'); + await page.keyboard.type(" gamma"); + await page.keyboard.press("ControlOrMeta+z"); + await page.keyboard.press("ControlOrMeta+z"); + expect(await docText(mainEditor(page))).toBe("alpha"); await switchTo(page, 2); await focusEditor(mainEditor(page)); - await page.keyboard.type('noise'); + await page.keyboard.type("noise"); await page.waitForTimeout(HISTORY_GAP); await switchTo(page, 0); - expect(await docText(mainEditor(page))).toBe('alpha'); + expect(await docText(mainEditor(page))).toBe("alpha"); await focusEditor(mainEditor(page)); await redo(page); - expect(await docText(mainEditor(page))).toBe('alpha beta'); + expect(await docText(mainEditor(page))).toBe("alpha beta"); await redo(page); - expect(await docText(mainEditor(page))).toBe('alpha beta gamma'); + expect(await docText(mainEditor(page))).toBe("alpha beta gamma"); }); - test('undo restores selection/cursor consistently after returning to a tab', async ({ page }) => { - await setDoc(page, mainEditor(page), ['12345']); + test("undo restores selection/cursor consistently after returning to a tab", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["12345"]); await page.waitForTimeout(HISTORY_GAP); - await page.keyboard.type('678'); - await page.keyboard.press('Home'); + await page.keyboard.type("678"); + await page.keyboard.press("Home"); const cursor = await selection(mainEditor(page)); await switchTo(page, 1); @@ -695,30 +812,32 @@ test.describe('per-tab editor state', () => { expect(await selection(mainEditor(page))).toEqual(cursor); await focusEditor(mainEditor(page)); - await page.keyboard.press('ControlOrMeta+z'); - expect(await docText(mainEditor(page))).toBe('12345'); + await page.keyboard.press("ControlOrMeta+z"); + expect(await docText(mainEditor(page))).toBe("12345"); }); - test('a newly added tab has a fresh history (undo is a no-op) and cursor at start', async ({ page }) => { - await setDoc(page, mainEditor(page), ['something']); + test("a newly added tab has a fresh history (undo is a no-op) and cursor at start", async ({ + page, + }) => { + await setDoc(page, mainEditor(page), ["something"]); await addTabBtn(page).click(); await focusEditor(mainEditor(page)); expect(await selection(mainEditor(page))).toEqual({ anchor: 0, head: 0 }); - await page.keyboard.press('ControlOrMeta+z'); + await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe(NEW_TAB_TEXT); }); - test('state survives many rapid tab switches', async ({ page }) => { - await setDoc(page, mainEditor(page), ['AAAAAAAAAA']); - await page.keyboard.press('ControlOrMeta+Home'); - for (let i = 0; i < 2; i++) await page.keyboard.press('ArrowRight'); - for (let i = 0; i < 3; i++) await page.keyboard.press('Shift+ArrowRight'); + test("state survives many rapid tab switches", async ({ page }) => { + await setDoc(page, mainEditor(page), ["AAAAAAAAAA"]); + await page.keyboard.press("ControlOrMeta+Home"); + for (let i = 0; i < 2; i++) await page.keyboard.press("ArrowRight"); + for (let i = 0; i < 3; i++) await page.keyboard.press("Shift+ArrowRight"); const selA = await selection(mainEditor(page)); await switchTo(page, 1); - await setDoc(page, mainEditor(page), ['BBBBBBBBBB']); - await page.keyboard.press('ControlOrMeta+Home'); - for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowRight'); + await setDoc(page, mainEditor(page), ["BBBBBBBBBB"]); + await page.keyboard.press("ControlOrMeta+Home"); + for (let i = 0; i < 6; i++) await page.keyboard.press("ArrowRight"); const selB = await selection(mainEditor(page)); for (let i = 0; i < 5; i++) { @@ -730,24 +849,28 @@ test.describe('per-tab editor state', () => { expect(await selection(mainEditor(page))).toEqual(selA); }); - test('highlights are refreshed per tab when switching (stale regex queries)', async ({ page }) => { - await setDoc(page, topEditor(page), ['/zqx/']); - await setDoc(page, mainEditor(page), ['zqx qqq']); - await expect.poll(() => matchTexts(page, 0)).toEqual(['zqx']); + test("highlights are refreshed per tab when switching (stale regex queries)", async ({ + page, + }) => { + await setDoc(page, topEditor(page), ["/zqx/"]); + await setDoc(page, mainEditor(page), ["zqx qqq"]); + await expect.poll(() => matchTexts(page, 0)).toEqual(["zqx"]); await switchTo(page, 1); - await expect(page.locator('#cm6-editor .cm-regex-match-0')).toHaveCount(0); + await expect(page.locator("#cm6-editor .cm-regex-match-0")).toHaveCount(0); // change the regex while on tab 1, then return to tab 0: must use the NEW regex - await setDoc(page, topEditor(page), ['/qqq/']); + await setDoc(page, topEditor(page), ["/qqq/"]); await switchTo(page, 0); - await expect.poll(() => matchTexts(page, 0)).toEqual(['qqq']); + await expect.poll(() => matchTexts(page, 0)).toEqual(["qqq"]); }); - test('switching tabs does not alter the regex (top) editor', async ({ page }) => { - await setDoc(page, topEditor(page), ['/keep/', '/me/']); + test("switching tabs does not alter the regex (top) editor", async ({ + page, + }) => { + await setDoc(page, topEditor(page), ["/keep/", "/me/"]); await switchTo(page, 1); await switchTo(page, 2); - expect(await docText(topEditor(page))).toBe('/keep/\n/me/'); + expect(await docText(topEditor(page))).toBe("/keep/\n/me/"); }); }); |