diff --git a/web/fonts/LICENSE.md b/web/fonts/LICENSE.md new file mode 100644 index 0000000..08927e5 --- /dev/null +++ b/web/fonts/LICENSE.md @@ -0,0 +1,45 @@ +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" + +### MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/web/fonts/hack-regular.woff2 b/web/fonts/hack-regular.woff2 new file mode 100644 index 0000000..524465c Binary files /dev/null and b/web/fonts/hack-regular.woff2 differ diff --git a/web/index.html b/web/index.html index b8121f3..4bdc9e9 100644 --- a/web/index.html +++ b/web/index.html @@ -27,8 +27,11 @@

peepoScript playground

-
+
+ +
+
diff --git a/web/main.js b/web/main.js index da46b06..83464e9 100644 --- a/web/main.js +++ b/web/main.js @@ -6,6 +6,7 @@ const TOWDAN_TWITCH_ID = "76020462"; // towdan's channel owns the preferred emot const AC_MAX_ITEMS = 8; const editor = document.getElementById("editor"); +const gutter = document.getElementById("gutter"); const output = document.getElementById("output"); const runBtn = document.getElementById("run-btn"); const clearBtn = document.getElementById("clear-btn"); @@ -265,21 +266,59 @@ function setCaretOffset(offset) { } // Rebuilds the DOM from source text, swapping exact emote-name words -// for their images. Identifiers split on everything non-word-like, so +// for their images and wrapping strings/comments in colored spans. +// Identifiers split on everything non-word-like, so // `peepoChat "Wokege".` renders inside the string quotes too. function buildEditorFragment(src) { const frag = document.createDocumentFragment(); - for (const part of src.split(/([A-Za-z0-9_]+)/)) { + // Regions mirror the lexer's scan order. A quote opens a string + // (quote to next quote, no escapes, newlines allowed; unterminated + // runs to EOF, which is exactly what readString will swallow as + // ILLEGAL) unless a comment opened first: skipComment eats # to end + // of line before readString can see any quote inside it. Whichever + // region starts earlier wins the split, so a # inside a string stays + // literal and a quote inside a comment stays inert. + for (const part of src.split(/("[^"]*(?:"|$)|#[^\n]*)/)) { if (!part) continue; - if (emotes.has(part)) frag.append(makeEmoteImg(part)); - else frag.append(part); + if (part.startsWith('"') || part.startsWith("#")) { + const span = document.createElement("span"); + span.className = part.startsWith('"') ? "str" : "com"; + appendEmoteParts(span, part); + frag.append(span); + } else { + appendEmoteParts(frag, part); + } } return frag; } +function appendEmoteParts(root, text) { + for (const part of text.split(/([A-Za-z0-9_]+)/)) { + if (!part) continue; + if (emotes.has(part)) root.append(makeEmoteImg(part)); + else root.append(part); + } +} + function setEditorSource(src, caretOffset) { editor.replaceChildren(buildEditorFragment(src)); setCaretOffset(caretOffset ?? src.length); + renderGutter(src); + resetUndoHistory(); +} + +// Line numbers are derived from the source, not the DOM, so wrapped or +// partial nodes can never skew them. +function renderGutter(src) { + const lines = Math.max(1, src.split("\n").length); + if (gutter.childElementCount === lines) return; + const frag = document.createDocumentFragment(); + for (let i = 1; i <= lines; i++) { + const row = document.createElement("div"); + row.textContent = i; + frag.append(row); + } + gutter.replaceChildren(frag); } function normalize() { @@ -287,6 +326,8 @@ function normalize() { const src = editorSource(editor); editor.replaceChildren(buildEditorFragment(src)); if (off !== null) setCaretOffset(off); + renderGutter(src); + scheduleUndoCommit(); } editor.addEventListener("input", () => { @@ -294,6 +335,114 @@ editor.addEventListener("input", () => { updateAutocomplete(); }); +// ---------------------------------------------------------------- undo +// +// Rebuilding the DOM on every keystroke throws away the browser's +// native undo history, so the editor keeps its own. A commit lands +// UNDO_DEBOUNCE_MS after the last mutation and stores the *previous* +// known state, so one undo step rewinds a whole typed burst, not just +// the final character. Programmatic replaces (autocomplete accept, +// example load) go through setEditorSource and restart the stack. + +const UNDO_DEBOUNCE_MS = 400; +const UNDO_MAX_DEPTH = 200; + +let undoStack = []; +let redoStack = []; +let committed = null; // {source, caretOffset}: newest state outside the stacks +let undoTimer = null; + +function currentState() { + return { + source: editorSource(editor), + caretOffset: getCaretOffset(), + }; +} + +// Moves `committed` forward to what is in the editor right now, +// pushing the stale value onto the undo stack when the text changed. +function commitUndoPoint() { + const cur = currentState(); + if (!committed) { + // No baseline yet; startup order guarantees one exists today, + // but a reorder shouldn't turn into a TypeError here. + committed = cur; + return; + } + if (cur.source !== committed.source) { + undoStack.push(committed); + if (undoStack.length > UNDO_MAX_DEPTH) undoStack.shift(); + redoStack = []; + } + committed = cur; +} + +function scheduleUndoCommit() { + clearTimeout(undoTimer); + undoTimer = setTimeout(() => { + undoTimer = null; + commitUndoPoint(); + }, UNDO_DEBOUNCE_MS); +} + +// Ctrl+z must never race a pending commit, or the burst it belongs to +// would be skipped entirely. +function flushUndoCommit() { + if (!undoTimer) return; + clearTimeout(undoTimer); + undoTimer = null; + commitUndoPoint(); +} + +function resetUndoHistory() { + clearTimeout(undoTimer); + undoTimer = null; + undoStack = []; + redoStack = []; + committed = currentState(); +} + +function applySnapshot(snap) { + editor.replaceChildren(buildEditorFragment(snap.source)); + setCaretOffset(snap.caretOffset ?? snap.source.length); + renderGutter(snap.source); + // Content may no longer support the old scroll position; not every + // engine fires a scroll event for its own clamp. + gutter.scrollTop = editor.scrollTop; + committed = snap; + hideAutocomplete(); // the word before the caret may no longer exist +} + +function undoEdit() { + flushUndoCommit(); + const prev = undoStack.pop(); + if (!prev) return; + redoStack.push(currentState()); + applySnapshot(prev); +} + +function redoEdit() { + flushUndoCommit(); + const next = redoStack.pop(); + if (!next) return; + undoStack.push(currentState()); + applySnapshot(next); +} + +// Native undo is dead here (normalize() rewrites the DOM it would +// target), so these combos are always swallowed, history or not. +editor.addEventListener("keydown", (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + const key = e.key.toLowerCase(); + if (key === "z" && !e.shiftKey) { + e.preventDefault(); + undoEdit(); + } else if ((key === "z" && e.shiftKey) || key === "y") { + e.preventDefault(); + redoEdit(); + } +}); + // Paste as plain text at the caret; the input event then normalizes. editor.addEventListener("paste", (e) => { e.preventDefault(); @@ -303,6 +452,34 @@ editor.addEventListener("paste", (e) => { editor.addEventListener("blur", () => hideAutocompleteSoon()); +// The editor is the only scroller; the gutter mirrors it vertically and +// never moves horizontally (wide lines scroll under fixed numbers, like +// a real editor). +editor.addEventListener("scroll", () => { + gutter.scrollTop = editor.scrollTop; +}); + +// Clicking a number puts the caret at the start of that line. +gutter.addEventListener("mousedown", (e) => { + e.preventDefault(); + const row = e.target.closest("div"); + // Clicks landing on the container itself (empty space past the last + // number) must not teleport the caret to line 1. + if (!row || row === gutter) return; + const line = [...gutter.children].indexOf(row); + const src = editorSource(editor); + let off = 0; + for (let i = 0; i < line; i++) { + const next = src.indexOf("\n", off); + if (next === -1) break; + off = next + 1; + } + // Focus first: an empty editor has no boundary for setCaretOffset, + // which would otherwise return early and leave focus on the gutter. + editor.focus(); + setCaretOffset(Math.min(off, src.length)); +}); + // -------------------------------------------------------- autocomplete let acItems = []; diff --git a/web/style.css b/web/style.css index ede750f..8ce5915 100644 --- a/web/style.css +++ b/web/style.css @@ -1,3 +1,14 @@ +/* Hack is vendored in fonts/ (MIT, see LICENSE.md there) so the + editor renders identically offline. Regular weight only: nothing + in the stylesheet requests bold monospace. */ +@font-face { + font-family: "Hack"; + src: url("fonts/hack-regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + :root { --bg: #0e0e10; --panel: #18181b; @@ -8,6 +19,7 @@ --accent-dim: #6441a5; --error: #ff6b6b; --ok: #57d98d; + --string: #e6b455; } * { box-sizing: border-box; } @@ -43,13 +55,24 @@ main { flex: 1; display: grid; grid-template-columns: 1fr 1fr; + /* Cap the row at main's height so long source scrolls inside the + editor (keeping the gutter pinned) instead of stretching the page. */ + grid-template-rows: minmax(0, 1fr); gap: 1rem; padding: 1rem 1.25rem; min-height: 0; } @media (max-width: 900px) { - main { grid-template-columns: 1fr; } + main { grid-template-columns: 1fr; grid-template-rows: none; } +} + +@media (min-width: 901px) { + /* min-height alone leaves body height content-driven, so a long + source stretches the page instead of scrolling inside the editor + (which would drag the gutter off screen). A definite height lets + flex/grid bound the panes. */ + body { height: 100vh; } } .pane { @@ -91,13 +114,38 @@ main { cursor: pointer; } +.editor-wrap { + flex: 1; + display: flex; + align-items: stretch; + min-height: 0; + overflow: hidden; +} + +/* Numbers must share the editor's font metrics and top padding or the + rows drift apart. */ +#gutter { + overflow: hidden; + padding: 0.9rem 0.6ch 0.9rem 0.9rem; + text-align: right; + color: var(--muted); + background: #141416; + border-right: 1px solid var(--panel-edge); + user-select: none; + cursor: default; + font-family: "Hack", ui-monospace, "Cascadia Code", "JetBrains Mono", monospace; + font-size: 0.95rem; + line-height: 1.55; +} + #editor { flex: 1; padding: 0.9rem; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-word; - font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + overflow: auto; + /* pre, not pre-wrap: a wrapped line would occupy several rows and + desynchronize the gutter numbers. */ + white-space: pre; + font-family: "Hack", ui-monospace, "Cascadia Code", "JetBrains Mono", monospace; font-size: 0.95rem; line-height: 1.55; outline: none; @@ -107,6 +155,9 @@ main { #editor:focus { background: #1c1c21; } +#editor .str { color: var(--string); } +#editor .com { color: var(--muted); } + #editor img.emote, #output img.emote { height: 1.35em; @@ -133,7 +184,7 @@ main { justify-content: space-between; gap: 1rem; padding: 0.35rem 0.75rem; - font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-family: "Hack", ui-monospace, "Cascadia Code", "JetBrains Mono", monospace; font-size: 0.9rem; cursor: pointer; } @@ -177,7 +228,7 @@ button:disabled { opacity: 0.45; cursor: not-allowed; } flex: 1; padding: 0.9rem; overflow-y: auto; - font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-family: "Hack", ui-monospace, "Cascadia Code", "JetBrains Mono", monospace; font-size: 0.95rem; line-height: 1.55; white-space: pre-wrap;