feat:ADD ping cfunction via line number and FIX misallignmnent at the bottom of larger files between linenumber and textbox

This commit is contained in:
Caffeine Fueled 2026-09-04 12:30:30 +02:00
parent 405d60fa27
commit 973e4bcedc
Signed by: cf7
GPG key ID: CA295D643074C68C

155
app.py
View file

@ -15,6 +15,11 @@ application = app # alias if you prefer "application"
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")),
name="vendor")
# Minimum gap between accepted pings, per connection. A ping is one inbound message
# fanned out to every peer, so this bounds that amplification. Low enough that rapid
# human clicking gets through.
PING_MIN_INTERVAL = 0.15
# Languages offered in the editor dropdown. This is a security boundary as much as a
# feature list: the value is interpolated into a module URL client-side, so it must
# never be free-form. Keep in sync with vendor/fetch.sh.
@ -192,7 +197,11 @@ HTML = """<!doctype html>
<title>aukpad</title>
<style>
:root {
--line-h: 1.4;
/* Integer px, not a ratio. 14px * 1.4 = 19.6px, and a fractional line box makes the
real per-line advance 19.5938 rather than 19.6 - a 4.4px error by line 700 for any
code that computes a line's position, and a source of rounding divergence between
the textarea and the <pre> layers. 20px divides cleanly at 1x/1.25x/1.5x/2x. */
--line-h: 20px;
--bg: #fbfbfb;
--text: #111;
--border: #ddd;
@ -210,6 +219,8 @@ HTML = """<!doctype html>
--hl-title: #6f42c1;
--hl-attr: #e36209;
--hl-meta: #6a737d;
--ping: #ff0000;
--ping-alpha: .5;
}
[data-theme="dark"] {
--bg: #17181E;
@ -229,6 +240,8 @@ HTML = """<!doctype html>
--hl-title: #d2a8ff;
--hl-attr: #ffa657;
--hl-meta: #8b949e;
--ping: #ff0000;
--ping-alpha: .5;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); }
@ -266,15 +279,45 @@ HTML = """<!doctype html>
border:1px solid var(--border); border-radius:4px; overflow:hidden; flex: 1; }
#gutter, #t, #hl { font: 14px/var(--line-h) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
#gutter { padding:.5rem .75rem; text-align:right; color:var(--gutter-color); background:var(--gutter-bg); border-right:1px solid var(--gutter-border);
user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden; }
user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden;
cursor:pointer; }
/* Highlight overlay. #t and #hl must render glyph-for-glyph identically: any
difference in padding, white-space or tab-size shows up as drifting colors. */
#edit { position:relative; overflow:hidden; }
/* #edit owns the background so that #t and #hl can both be transparent. That is what
lets the ping layer sit underneath them and read as a highlight behind the text
rather than a tint over it - and it works in both .hl states, where which of the
two text layers is the visible one flips. */
#edit { position:relative; overflow:hidden; background:var(--bg); }
#t, #hl { padding:.5rem .75rem; border:0; margin:0; white-space:pre; tab-size:4; }
#t { position:absolute; inset:0; width:100%; height:100%; resize:none; outline:0;
overflow:auto; background:var(--bg); color:var(--text); }
#hl { position:absolute; inset:0; overflow:hidden; pointer-events:none; background:var(--bg); color:var(--text); }
overflow:auto; background:transparent; color:var(--text); }
#hl { position:absolute; inset:0; overflow:hidden; pointer-events:none; background:transparent; color:var(--text); }
#hl code { font: inherit; padding:0; background:none; }
/* First child of #edit, so it paints beneath the text layers. */
#pings { position:absolute; inset:0; overflow:hidden; pointer-events:none; }
#pings-in { position:absolute; inset:0; }
.ping { position:absolute; left:0; right:0; background:var(--ping); opacity:var(--ping-alpha);
animation:ping-fade 5s forwards; }
@keyframes ping-fade { 0%, 70% { opacity:var(--ping-alpha); } 100% { opacity:0; } }
@media (prefers-reduced-motion: reduce) { .ping { animation-duration:5s; } }
/* Shown when a ping lands outside the viewport. The one interactive element in the
#edit stack, so unlike #pings it keeps its pointer events. Resets the global
a,button rule, which would otherwise force min-width:2rem and inline-block. */
/* --sbw is the textarea's vertical scrollbar width, kept current by syncExtent();
without it the pill sits on top of the scrollbar. */
#pingjump { display:none; position:absolute; right:calc(.75rem + var(--sbw, 0px)); top:.75rem; z-index:5;
align-items:center; gap:.5rem; padding:.5rem .85rem; min-width:0;
border:1px solid var(--border); border-radius:6px; background:var(--panel-bg);
color:var(--text); box-shadow:0 4px 12px rgba(0,0,0,.1);
font-size:1.05rem; font-weight:600; cursor:pointer; }
/* Solid, not var(--ping-alpha): the bar is translucent because it sits behind text,
but the dot needs to read as the same colour at a glance. */
#pingjump .dot { width:.65rem; height:.65rem; border-radius:50%; background:var(--ping); flex-shrink:0; }
/* Scoped: .ic-sm is shared with the notice bar and the peer count. */
#pingjump .ic-sm { width:1.15rem; height:1.15rem; }
/* A .show class, not the hidden attribute: this id rule would outrank [hidden]. */
#pingjump.show { display:inline-flex; }
#pingjump.up .ic-sm { transform:rotate(180deg); }
/* Only while highlighting is live; without .hl the editor is byte-identical to
the plain version, which is the fallback for every failure path. */
#wrap.hl #t { color:transparent; background:transparent; caret-color:var(--text); }
@ -343,7 +386,9 @@ HTML = """<!doctype html>
/* --- Touch devices: 16px fields, because iOS Safari force-zooms into anything
smaller on focus and never zooms back out. Button sizes are unchanged. --- */
@media (pointer: coarse) {
#gutter, #t, #hl { font-size:16px; } /* all three, or the overlay desyncs */
/* All three together, or the overlay desyncs. 24px keeps the line box integral at
16px the way 20px does at 14px. */
#gutter, #t, #hl { font-size:16px; line-height:24px; }
#pw-panel input { font-size:1rem; }
}
</style>
@ -355,6 +400,7 @@ HTML = """<!doctype html>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
</defs></svg>
<header>
@ -386,9 +432,11 @@ HTML = """<!doctype html>
<div id="wrap">
<pre id="gutter">1</pre>
<div id="edit">
<div id="pings" aria-hidden="true"><div id="pings-in"></div></div>
<pre id="hl" aria-hidden="true"><code></code></pre>
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
placeholder="Start typing…"></textarea>
<button id="pingjump" type="button"><span class="dot" aria-hidden="true"></span><span id="pingjump-txt"></span><svg class="ic ic-sm" aria-hidden="true"><use href="#i-chevron"/></svg></button>
</div>
<p id="notice" onclick="hideNotice()" title="Click to dismiss">
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-info"/></svg>
@ -478,12 +526,85 @@ function updateGutter() {
gutter.textContent = s;
}
ta.addEventListener("input", refresh);
// The textarea's horizontal scrollbar eats into its client height, so it can scroll
// further than the mirrors, which have overflow:hidden and no scrollbar. At the bottom
// they clamp and lag behind by the scrollbar's height. Give them matching bottom padding
// so their scrollable extents line up. Recomputed on input and resize, the only times a
// horizontal scrollbar can appear or vanish.
function syncExtent() {
const sb = ta.offsetHeight - ta.clientHeight; // 0 when there is no h-scrollbar
const pad = parseFloat(getComputedStyle(ta).paddingBottom) + sb;
gutter.style.paddingBottom = pad + "px";
hl.style.paddingBottom = pad + "px";
// Same idea horizontally, for anything anchored to the editor's right edge.
document.documentElement.style.setProperty("--sbw", (ta.offsetWidth - ta.clientWidth) + "px");
}
window.addEventListener("resize", () => { syncExtent(); syncScroll(); });
function syncScroll() {
gutter.scrollTop = ta.scrollTop;
// The overlay needs horizontal sync too: white-space:pre means long lines
// scroll sideways, and the gutter never does.
hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft;
// Pings sit at document coordinates; move the wrapper rather than every bar.
pingsIn.style.transform = "translateY(" + (-ta.scrollTop) + "px)";
// Only drop the notice if the document shrank past the line it points at.
if (jumpLine && jumpLine > lineCount()) hideJump();
}
// --- Line ping: click a line number to flash that line for every peer ---
// Read from computed style, never hardcoded: the touch media query switches the
// editor font from 14px to 16px, so a literal line height would be wrong on mobile.
const pingsIn = $("#pings-in");
const lineH = () => parseFloat(getComputedStyle(ta).lineHeight);
const padTop = () => parseFloat(getComputedStyle(ta).paddingTop);
const lineCount = () => ta.value.split("\\n").length;
gutter.addEventListener("click", (e) => {
if (!isAuthed || ws?.readyState !== 1) return;
const y = e.clientY - gutter.getBoundingClientRect().top + ta.scrollTop - padTop();
const line = Math.floor(y / lineH()) + 1;
if (line < 1 || line > lineCount()) return; // e.g. clicked below the last line
ws.send(JSON.stringify({type: "ping", line}));
});
function showPing(line) {
// Re-pinging a visible line restarts it rather than stacking a second bar.
const prev = pingsIn.querySelector('[data-line="' + line + '"]');
if (prev) prev.remove();
const el = document.createElement("div");
el.className = "ping";
el.dataset.line = line;
el.style.top = (padTop() + (line - 1) * lineH()) + "px";
el.style.height = lineH() + "px";
el.addEventListener("animationend", () => el.remove());
pingsIn.appendChild(el);
}
// --- Ping notice ---
// Raised for every ping, for every peer, whether or not the line is on screen: on a long
// pad the bar alone is easy to miss. Click it to jump to the line and re-flash it.
const jump = $("#pingjump");
let jumpLine = 0, jumpTimer = null;
function showJump(line) {
jumpLine = line;
$("#pingjump-txt").textContent = "Ping on line " + line;
jump.classList.toggle("up", padTop() + (line - 1) * lineH() < ta.scrollTop);
jump.classList.add("show");
clearTimeout(jumpTimer);
jumpTimer = setTimeout(hideJump, 10000);
}
function hideJump() { jump.classList.remove("show"); jumpLine = 0; }
jump.addEventListener("click", () => {
const line = jumpLine;
hideJump();
ta.scrollTop = Math.max(0, padTop() + (line - 1) * lineH() - (ta.clientHeight - lineH()) / 2);
syncScroll();
showPing(line); // re-flash: the original bar may have almost faded by now
});
ta.addEventListener("scroll", syncScroll);
// Also sync on keydown for immediate response
ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); });
@ -560,6 +681,7 @@ function scheduleHl() {
// Re-render gutter and highlighting together.
function refresh() {
updateGutter();
syncExtent();
if (ta.value.length > HL_MAX) {
wrap.classList.remove("hl");
langSel.disabled = true;
@ -710,6 +832,9 @@ function connect(){
ta.selectionEnd = adjustCursor(oldText, msg.text, e);
} else if (msg.type === "peers_changed") {
setPeers(msg.count);
} else if (msg.type === "ping") {
showPing(msg.line);
showJump(msg.line);
} else if (msg.type === "lang_changed") {
applyLang(msg.lang || "");
} else if (msg.type === "protected_changed") {
@ -921,6 +1046,8 @@ def get_system_info():
<li>optional syntax highlighting (24 languages) from a per-pad dropdown, shared with everyone on the pad;
highlight.js is vendored locally and loaded lazily, so a plain pad fetches nothing</li>
<li>line numbers; Tab inserts 4 spaces</li>
<li>click a line number to &quot;ping&quot; it that line flashes for five seconds
for everyone on the pad</li>
<li>dark / light mode (auto-detects system preference, manual toggle)</li>
<li>copy-to-clipboard and "new pad" buttons, live peer count in the header</li>
</ul>
@ -1235,6 +1362,7 @@ async def ws(doc_id: str, ws: WebSocket):
await _broadcast(doc_id, {"type": "peers_changed", "count": len(room["peers"])})
# Per-connection auth state: already authed if pad has no password
last_ping = 0.0 # per-connection ping throttle; see the "ping" branch below
authed = room["pw_hash"] is None
if authed:
room["authed_peers"].add(ws)
@ -1333,6 +1461,21 @@ async def ws(doc_id: str, ws: WebSocket):
save_room_data_to_cache(doc_id, room)
await _broadcast(doc_id, {"type": "lang_changed", "lang": room["lang"]}, authed_only=True)
elif data.get("type") == "ping":
# Flash a line for everyone. Stateless: nothing is stored, so there is
# nothing to drift, expire or clean up on disconnect.
now = time.monotonic()
if now - last_ping < PING_MIN_INTERVAL:
continue # one inbound message fans out to every peer - throttle it
last_ping = now
line = data.get("line")
# bool subclasses int, so True would otherwise pass as line 1
if isinstance(line, bool) or not isinstance(line, int):
continue
if not 1 <= line <= room["text"].count("\n") + 1:
continue
await _broadcast(doc_id, {"type": "ping", "line": line}, authed_only=True)
except WebSocketDisconnect:
pass
finally: