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:
parent
405d60fa27
commit
973e4bcedc
1 changed files with 149 additions and 6 deletions
155
app.py
155
app.py
|
|
@ -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")),
|
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")),
|
||||||
name="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
|
# 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
|
# 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.
|
# never be free-form. Keep in sync with vendor/fetch.sh.
|
||||||
|
|
@ -192,7 +197,11 @@ HTML = """<!doctype html>
|
||||||
<title>aukpad</title>
|
<title>aukpad</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
: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;
|
--bg: #fbfbfb;
|
||||||
--text: #111;
|
--text: #111;
|
||||||
--border: #ddd;
|
--border: #ddd;
|
||||||
|
|
@ -210,6 +219,8 @@ HTML = """<!doctype html>
|
||||||
--hl-title: #6f42c1;
|
--hl-title: #6f42c1;
|
||||||
--hl-attr: #e36209;
|
--hl-attr: #e36209;
|
||||||
--hl-meta: #6a737d;
|
--hl-meta: #6a737d;
|
||||||
|
--ping: #ff0000;
|
||||||
|
--ping-alpha: .5;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--bg: #17181E;
|
--bg: #17181E;
|
||||||
|
|
@ -229,6 +240,8 @@ HTML = """<!doctype html>
|
||||||
--hl-title: #d2a8ff;
|
--hl-title: #d2a8ff;
|
||||||
--hl-attr: #ffa657;
|
--hl-attr: #ffa657;
|
||||||
--hl-meta: #8b949e;
|
--hl-meta: #8b949e;
|
||||||
|
--ping: #ff0000;
|
||||||
|
--ping-alpha: .5;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); }
|
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; }
|
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, #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);
|
#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
|
/* 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. */
|
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, #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;
|
#t { position:absolute; inset:0; width:100%; height:100%; resize:none; outline:0;
|
||||||
overflow:auto; background:var(--bg); color:var(--text); }
|
overflow:auto; background:transparent; color:var(--text); }
|
||||||
#hl { position:absolute; inset:0; overflow:hidden; pointer-events:none; background:var(--bg); 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; }
|
#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
|
/* Only while highlighting is live; without .hl the editor is byte-identical to
|
||||||
the plain version, which is the fallback for every failure path. */
|
the plain version, which is the fallback for every failure path. */
|
||||||
#wrap.hl #t { color:transparent; background:transparent; caret-color:var(--text); }
|
#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
|
/* --- Touch devices: 16px fields, because iOS Safari force-zooms into anything
|
||||||
smaller on focus and never zooms back out. Button sizes are unchanged. --- */
|
smaller on focus and never zooms back out. Button sizes are unchanged. --- */
|
||||||
@media (pointer: coarse) {
|
@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; }
|
#pw-panel input { font-size:1rem; }
|
||||||
}
|
}
|
||||||
</style>
|
</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-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-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-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>
|
<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>
|
</defs></svg>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -386,9 +432,11 @@ HTML = """<!doctype html>
|
||||||
<div id="wrap">
|
<div id="wrap">
|
||||||
<pre id="gutter">1</pre>
|
<pre id="gutter">1</pre>
|
||||||
<div id="edit">
|
<div id="edit">
|
||||||
|
<div id="pings" aria-hidden="true"><div id="pings-in"></div></div>
|
||||||
<pre id="hl" aria-hidden="true"><code></code></pre>
|
<pre id="hl" aria-hidden="true"><code></code></pre>
|
||||||
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
|
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||||
placeholder="Start typing…"></textarea>
|
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>
|
</div>
|
||||||
<p id="notice" onclick="hideNotice()" title="Click to dismiss">
|
<p id="notice" onclick="hideNotice()" title="Click to dismiss">
|
||||||
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-info"/></svg>
|
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-info"/></svg>
|
||||||
|
|
@ -478,12 +526,85 @@ function updateGutter() {
|
||||||
gutter.textContent = s;
|
gutter.textContent = s;
|
||||||
}
|
}
|
||||||
ta.addEventListener("input", refresh);
|
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() {
|
function syncScroll() {
|
||||||
gutter.scrollTop = ta.scrollTop;
|
gutter.scrollTop = ta.scrollTop;
|
||||||
// The overlay needs horizontal sync too: white-space:pre means long lines
|
// The overlay needs horizontal sync too: white-space:pre means long lines
|
||||||
// scroll sideways, and the gutter never does.
|
// scroll sideways, and the gutter never does.
|
||||||
hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft;
|
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);
|
ta.addEventListener("scroll", syncScroll);
|
||||||
// Also sync on keydown for immediate response
|
// Also sync on keydown for immediate response
|
||||||
ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); });
|
ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); });
|
||||||
|
|
@ -560,6 +681,7 @@ function scheduleHl() {
|
||||||
// Re-render gutter and highlighting together.
|
// Re-render gutter and highlighting together.
|
||||||
function refresh() {
|
function refresh() {
|
||||||
updateGutter();
|
updateGutter();
|
||||||
|
syncExtent();
|
||||||
if (ta.value.length > HL_MAX) {
|
if (ta.value.length > HL_MAX) {
|
||||||
wrap.classList.remove("hl");
|
wrap.classList.remove("hl");
|
||||||
langSel.disabled = true;
|
langSel.disabled = true;
|
||||||
|
|
@ -710,6 +832,9 @@ function connect(){
|
||||||
ta.selectionEnd = adjustCursor(oldText, msg.text, e);
|
ta.selectionEnd = adjustCursor(oldText, msg.text, e);
|
||||||
} else if (msg.type === "peers_changed") {
|
} else if (msg.type === "peers_changed") {
|
||||||
setPeers(msg.count);
|
setPeers(msg.count);
|
||||||
|
} else if (msg.type === "ping") {
|
||||||
|
showPing(msg.line);
|
||||||
|
showJump(msg.line);
|
||||||
} else if (msg.type === "lang_changed") {
|
} else if (msg.type === "lang_changed") {
|
||||||
applyLang(msg.lang || "");
|
applyLang(msg.lang || "");
|
||||||
} else if (msg.type === "protected_changed") {
|
} 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;
|
<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>
|
highlight.js is vendored locally and loaded lazily, so a plain pad fetches nothing</li>
|
||||||
<li>line numbers; Tab inserts 4 spaces</li>
|
<li>line numbers; Tab inserts 4 spaces</li>
|
||||||
|
<li>click a line number to "ping" 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>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>
|
<li>copy-to-clipboard and "new pad" buttons, live peer count in the header</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -1235,6 +1362,7 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
await _broadcast(doc_id, {"type": "peers_changed", "count": len(room["peers"])})
|
await _broadcast(doc_id, {"type": "peers_changed", "count": len(room["peers"])})
|
||||||
|
|
||||||
# Per-connection auth state: already authed if pad has no password
|
# 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
|
authed = room["pw_hash"] is None
|
||||||
if authed:
|
if authed:
|
||||||
room["authed_peers"].add(ws)
|
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)
|
save_room_data_to_cache(doc_id, room)
|
||||||
await _broadcast(doc_id, {"type": "lang_changed", "lang": room["lang"]}, authed_only=True)
|
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:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue