mara on #448: "firefox disconnects bc of too many tabs. needs bg service worker". picked SharedWorker over full Service Worker: smaller change, addresses the actual problem (shared connection across tabs), no offline-cache scope creep. architecture per-tab `new EventSource('/dashboard/stream')` replaced with a SharedWorker-backed facade. one SharedWorker instance per origin holds ONE upstream EventSource and fans every server-sent event out to every connected tab via MessagePort. N hyperhive tabs now share ONE backend connection, immune to Firefox's per-tab SSE throttling under many-open-tabs pressure. wire protocol (port.postMessage): tab → worker { kind: 'subscribe', url: '/dashboard/stream' } { kind: 'unsubscribe', url: '/dashboard/stream' } worker → tab { kind: 'open', url } { kind: 'message', url, data: '<raw SSE data>' } { kind: 'error', url } subscription tracking is per (port, url). a late subscriber that joins after the upstream is already OPEN gets a synthetic 'open' event so its onStreamOpen handler still runs (triggers the snapshot re-sync that recovers events lost during the join gap). unsubscribing the last port for a URL closes the upstream EventSource so we don't leak idle streams. files - frontend/packages/dashboard/src/stream-worker.js: new — the worker. multi-URL multiplexing via Map<url, {es, ports}>. - frontend/packages/dashboard/src/common.js: new exported helper openStream(url) — returns an EventSource-shaped facade backed by the SharedWorker. graceful fallback to direct EventSource when SharedWorker is unavailable. - frontend/packages/dashboard/src/app.js: replaces the inline new EventSource('/dashboard/stream') with openStream. - frontend/packages/dashboard/src/flow.js: passes streamFactory: openStream to termCreate so the broker terminal's SSE goes through the worker too. - frontend/packages/shared/src/terminal.js: accepts an optional streamFactory(url) option. default unchanged — non-dashboard consumers (per-agent UI) keep using direct EventSource. - frontend/packages/dashboard/build.mjs: new esbuild entry for stream-worker.js → dist/static/stream-worker.js (separate bundle because SharedWorker scripts run in a different global scope and can't be inlined into app.js). scope kept tight - per-agent UI's /events/stream stays on direct EventSource. the agent UI's tab count per agent is typically 1; SharedWorker helps when you have N tabs hitting the SAME stream and the per-agent stream URLs differ. if mara wants the agent UI to share its workers too it's a separate small PR. - no offline-cache, no push notifications — those need full Service Worker; explicit non-goal here per the design Q. validation - npm run build --workspace=@hive/dashboard clean. - stream-worker.js bundle: 1.8 kb. - app.js: 154 kb → 158 kb. flow.js: 29.9 kb → 32 kb. - browser smoke test isn't possible from inside iris's container; the EventSource-shaped facade preserves the exact onmessage / onopen / onerror surface the existing IIFE consumers use.
471 lines
19 KiB
JavaScript
471 lines
19 KiB
JavaScript
// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
|
|
// backfill + live SSE. Pages provide a kind→renderer map; this module
|
|
// owns scroll behaviour, animation suppression on backfill, and the
|
|
// EventSource lifecycle.
|
|
//
|
|
// Usage:
|
|
//
|
|
// import { create, linkify } from '@hive/shared/terminal.js';
|
|
//
|
|
// create({
|
|
// logEl: document.getElementById('msgflow'),
|
|
// historyUrl: '/messages/history?limit=200', // optional
|
|
// streamUrl: '/messages/stream',
|
|
// renderers: {
|
|
// sent: (ev, api) => api.row('msgrow sent', ...),
|
|
// delivered: (ev, api) => api.row('msgrow delivered', ...),
|
|
// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
|
|
// },
|
|
// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
|
|
// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in
|
|
// both backfill replay and live — use for derived views that need
|
|
// the full picture (e.g. a per-recipient inbox built from broker
|
|
// events) */ },
|
|
// onBackfillDone: (count) => { /* one-shot after history replay */ },
|
|
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
|
|
// use to re-sync snapshot-derived state after a reconnect gap */ },
|
|
// pillAnchor: document.getElementById('msgflow').parentElement,
|
|
// });
|
|
//
|
|
// Renderers receive (ev, api) where api exposes:
|
|
//
|
|
// api.row(cls, text) → appends a flat <div class="row cls">
|
|
// api.details(cls, summary, body) → appends <details class="row cls">
|
|
// with a <pre.tool-body>
|
|
// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
|
|
// leading "+ " / "- " prefix
|
|
// api.placeholder(text) → replaces log content with a single
|
|
// muted "(placeholder)" row, cleared
|
|
// on the next real row
|
|
// api.fromHistory → true while backfill is replaying
|
|
//
|
|
// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall
|
|
// through to `renderers._default` (which itself defaults to a JSON-dump
|
|
// note row). The convention is that the SSE/history endpoints emit
|
|
// objects with a `kind` field.
|
|
//
|
|
// Backfill is best-effort: if `historyUrl` is unset or the fetch fails,
|
|
// we skip straight to SSE. The optional `onBackfillDone(count)` hook
|
|
// fires after replay finishes (or after a failed/skipped fetch with
|
|
// count=0); pages use it to set state flags from the replayed history.
|
|
|
|
const NEAR_BOTTOM_PX = 48;
|
|
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
|
|
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
|
|
// as "still smooth, but visibly slow." 140ms with ease-out is fast
|
|
// enough to feel snap-y, slow enough that the row's destination
|
|
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
|
|
// short-circuit to instant — animating a 12px nudge is just jitter.
|
|
const SCROLL_ANIM_MS = 140;
|
|
const SCROLL_SNAP_PX = 24;
|
|
|
|
export function create(opts) {
|
|
const log = opts.logEl;
|
|
if (!log) throw new Error('HiveTerminal.create: logEl is required');
|
|
const renderers = opts.renderers || {};
|
|
const defaultRender = renderers._default
|
|
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
|
const pillAnchor = opts.pillAnchor || log.parentElement || log;
|
|
|
|
let placeholderEl = null;
|
|
let pill = null;
|
|
let unseen = 0;
|
|
let currentNoAnim = false;
|
|
// Sticky-bottom intent. True means "keep snapping to bottom on
|
|
// any mutation"; false means "the operator scrolled up — leave
|
|
// them alone". Updated synchronously from the scroll event
|
|
// handler so both programmatic scrollTop assignments and
|
|
// operator-driven wheel/drag stay in sync.
|
|
let stickToBottom = true;
|
|
// Guards scroll-event-handler from misreading the position while
|
|
// our own animation is mid-flight (#400). The animation drives
|
|
// scrollTop with rAF, which fires a stream of scroll events as
|
|
// the position eases toward the target — the position passes
|
|
// through "not near bottom" before settling. Without this gate,
|
|
// the scroll handler flips `stickToBottom` to false mid-animation,
|
|
// which then causes the MutationObserver to skip the next snap
|
|
// and leaves the operator stranded mid-scroll. Set to the
|
|
// animation's nominal end + small headroom; each fresh snap
|
|
// re-arms it so back-to-back snaps stay gated.
|
|
let smoothScrollingUntil = 0;
|
|
// rAF id for the current snap animation. Cancelled when a new
|
|
// snap starts so we never have two animations fighting over
|
|
// scrollTop.
|
|
let scrollAnimRaf = 0;
|
|
|
|
function isNearBottom() {
|
|
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
|
}
|
|
// Snap the log to the bottom with a brief eased animation
|
|
// (#400 + mara: snappier than the browser's default 500ms smooth
|
|
// scroll). Each call cancels the previous frame loop and starts a
|
|
// fresh one, so a burst of mutations coalesces into one ride to
|
|
// the latest bottom. Re-evaluates the target each frame so a
|
|
// renderer mutation landing mid-animation extends the destination
|
|
// without a visible jump. Falls back to instant scroll when
|
|
// `currentNoAnim` is true (backfill replay — operator never sees
|
|
// intermediate positions, animation is wasted frames) or when the
|
|
// remaining distance is under SCROLL_SNAP_PX.
|
|
function snapToBottom(immediate) {
|
|
stickToBottom = true;
|
|
if (scrollAnimRaf) {
|
|
cancelAnimationFrame(scrollAnimRaf);
|
|
scrollAnimRaf = 0;
|
|
}
|
|
const target = log.scrollHeight - log.clientHeight;
|
|
const start = log.scrollTop;
|
|
const distance = target - start;
|
|
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
|
|
smoothScrollingUntil = 0;
|
|
log.scrollTop = target;
|
|
return;
|
|
}
|
|
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
|
|
const t0 = performance.now();
|
|
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
|
const step = (now) => {
|
|
const elapsed = now - t0;
|
|
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
|
|
// Re-read target each frame so mutations landing mid-animation
|
|
// (the common case — a renderer appended badge / body bits
|
|
// after api.row returned) extend the destination smoothly
|
|
// rather than landing short.
|
|
const currentTarget = log.scrollHeight - log.clientHeight;
|
|
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
|
|
if (frac < 1) {
|
|
scrollAnimRaf = requestAnimationFrame(step);
|
|
} else {
|
|
// Final exact settle on the as-of-now bottom.
|
|
log.scrollTop = log.scrollHeight - log.clientHeight;
|
|
scrollAnimRaf = 0;
|
|
}
|
|
};
|
|
scrollAnimRaf = requestAnimationFrame(step);
|
|
}
|
|
function ensurePill() {
|
|
if (pill) return pill;
|
|
pill = document.createElement('button');
|
|
pill.type = 'button';
|
|
pill.className = 'tail-pill';
|
|
pill.addEventListener('click', () => snapToBottom());
|
|
pillAnchor.appendChild(pill);
|
|
return pill;
|
|
}
|
|
function updatePill() {
|
|
if (unseen <= 0) {
|
|
if (pill) pill.classList.remove('visible');
|
|
return;
|
|
}
|
|
ensurePill();
|
|
pill.textContent = '↓ ' + unseen + ' new';
|
|
pill.classList.add('visible');
|
|
}
|
|
log.addEventListener('scroll', () => {
|
|
// Mid-smooth-scroll: ignore the intermediate scroll events. The
|
|
// gate releases when the animation has had time to settle (or
|
|
// when the next snap re-arms it). Without this, easing toward
|
|
// bottom would flip `stickToBottom` false partway and the next
|
|
// MO callback would skip the snap.
|
|
if (Date.now() < smoothScrollingUntil) return;
|
|
stickToBottom = isNearBottom();
|
|
if (stickToBottom) { unseen = 0; updatePill(); }
|
|
});
|
|
// Post-append mutations (issue #393). Renderers commonly call
|
|
// `api.row(cls, text)` to create the row shell, then mutate it
|
|
// by appending more children (badges, multi-line bodies, tool
|
|
// result panes) AFTER api.row returned. The afterAppend scroll
|
|
// below only sees the row's INITIAL height — once the renderer
|
|
// adds the body, the row's grown past the visible bottom and
|
|
// the operator is left scrolled to the row's TOP, breaking
|
|
// stick-to-bottom for every subsequent event.
|
|
//
|
|
// Fix: MutationObserver on the log subtree. Fires once per
|
|
// microtask after each batch of synchronous mutations, so it
|
|
// runs once per renderer call regardless of how many children
|
|
// the renderer appends. When `stickToBottom` is true, snap to
|
|
// bottom again — catches whatever the renderer added after the
|
|
// afterAppend hop. Programmatic `scrollTop = scrollHeight`
|
|
// assignments don't re-trigger MO (the scroll itself isn't a
|
|
// DOM mutation), so no feedback loop.
|
|
const mo = new MutationObserver(() => {
|
|
if (stickToBottom) snapToBottom();
|
|
});
|
|
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
|
|
|
// Auto-scroll decision uses the PRE-append scroll position
|
|
// (issue #375). Checking after the append underestimates
|
|
// "nearness" because the new row's own height has already pushed
|
|
// `scrollHeight - scrollTop - clientHeight` past the threshold,
|
|
// even when the user was visually at the bottom an instant ago.
|
|
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
|
|
// and hands it to afterAppend so the auto-scroll triggers
|
|
// whenever the operator was at the bottom when the row landed.
|
|
// (The MutationObserver above catches the AFTER-row mutations
|
|
// too, but this initial scroll keeps the visual lag to one
|
|
// frame instead of one microtask + frame.)
|
|
function afterAppend(wasNearBottom) {
|
|
if (currentNoAnim || wasNearBottom) {
|
|
snapToBottom();
|
|
} else {
|
|
unseen += 1;
|
|
updatePill();
|
|
}
|
|
}
|
|
function clearPlaceholder() {
|
|
if (placeholderEl && placeholderEl.parentElement === log) {
|
|
log.removeChild(placeholderEl);
|
|
}
|
|
placeholderEl = null;
|
|
}
|
|
function placeholder(text) {
|
|
clearPlaceholder();
|
|
const e = document.createElement('div');
|
|
e.className = 'row note';
|
|
e.textContent = text;
|
|
log.appendChild(e);
|
|
placeholderEl = e;
|
|
}
|
|
function row(cls, text) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const e = document.createElement('div');
|
|
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
|
e.appendChild(linkify(text));
|
|
log.appendChild(e);
|
|
afterAppend(wasNearBottom);
|
|
return e;
|
|
}
|
|
function details(cls, summary, body) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const d = document.createElement('details');
|
|
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
|
const s = document.createElement('summary');
|
|
s.textContent = summary;
|
|
d.appendChild(s);
|
|
const pre = document.createElement('pre');
|
|
pre.className = 'tool-body';
|
|
pre.appendChild(linkify(body));
|
|
d.appendChild(pre);
|
|
log.appendChild(d);
|
|
afterAppend(wasNearBottom);
|
|
return d;
|
|
}
|
|
function detailsDiff(cls, summary, body) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const d = document.createElement('details');
|
|
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
|
const s = document.createElement('summary');
|
|
s.textContent = summary;
|
|
d.appendChild(s);
|
|
const pre = document.createElement('pre');
|
|
pre.className = 'tool-body diff-body';
|
|
for (const line of String(body).split('\n')) {
|
|
const span = document.createElement('span');
|
|
if (line.startsWith('+ ')) span.className = 'diff-add';
|
|
else if (line.startsWith('- ')) span.className = 'diff-del';
|
|
else span.className = 'diff-ctx';
|
|
span.textContent = line + '\n';
|
|
pre.appendChild(span);
|
|
}
|
|
d.appendChild(pre);
|
|
log.appendChild(d);
|
|
afterAppend(wasNearBottom);
|
|
return d;
|
|
}
|
|
|
|
function api(extra) {
|
|
return Object.assign({
|
|
row, details, detailsDiff, placeholder, linkify,
|
|
fromHistory: false,
|
|
}, extra || {});
|
|
}
|
|
function dispatch(ev, fromHistory) {
|
|
const r = renderers[ev.kind] || defaultRender;
|
|
try {
|
|
r(ev, api({ fromHistory }));
|
|
} catch (err) {
|
|
console.error('terminal renderer threw', ev, err);
|
|
row('note', '[render err] ' + (err && err.message ? err.message : err));
|
|
}
|
|
if (opts.onAnyEvent) {
|
|
try { opts.onAnyEvent(ev, { fromHistory }); }
|
|
catch (err) { console.error('onAnyEvent threw', err); }
|
|
}
|
|
}
|
|
|
|
// Subscribe → buffer → fetch history → dedupe → apply.
|
|
//
|
|
// Race the SSE subscription opens before the history fetch starts.
|
|
// Live events that land before history resolves are buffered, not
|
|
// rendered. Once the history response (`{ seq, events }`) arrives we:
|
|
// 1. Replay `events` (fromHistory=true).
|
|
// 2. Drop buffered events with `seq <= history.seq` — they're
|
|
// already reflected in the history rows above.
|
|
// 3. Apply remaining buffered events (fromHistory=false).
|
|
// 4. Switch to live mode: each new SSE event dispatches immediately.
|
|
//
|
|
// Without this dance an event that fires between history-fetch and
|
|
// SSE-subscribe goes missing; without seq dedupe the same event
|
|
// shows twice (once via history, once via live buffer). Both bugs
|
|
// were latent before.
|
|
//
|
|
// If `historyUrl` is unset we skip the dance: buffered events apply
|
|
// as live the moment the buffer flushes (no dedupe possible without
|
|
// a boundary seq).
|
|
function start() {
|
|
let live = false;
|
|
let buffered = [];
|
|
|
|
// #448: callers can supply a `streamFactory(url)` that returns an
|
|
// EventSource-shaped object (must expose onmessage/onopen/onerror
|
|
// + .close()). The dashboard pages pass a SharedWorker-backed
|
|
// factory so all open hyperhive tabs share ONE upstream SSE
|
|
// connection. Default keeps the direct `new EventSource(url)`
|
|
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
|
|
const es = opts.streamFactory
|
|
? opts.streamFactory(opts.streamUrl)
|
|
: new EventSource(opts.streamUrl);
|
|
es.onmessage = (e) => {
|
|
let ev;
|
|
try { ev = JSON.parse(e.data); }
|
|
catch (err) { row('note', '[parse err] ' + e.data); return; }
|
|
if (!live) { buffered.push(ev); return; }
|
|
dispatch(ev, false);
|
|
if (opts.onLiveEvent) {
|
|
try { opts.onLiveEvent(ev); }
|
|
catch (err) { console.error('onLiveEvent threw', err); }
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
// SharedWorker-backed facades expose `readyState` mirroring the
|
|
// upstream EventSource state; the native EventSource exposes the
|
|
// same. Either way the CONNECTING vs. closed distinction works.
|
|
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
|
|
else row('note', '[disconnected]');
|
|
};
|
|
es.onopen = () => {
|
|
// Fires on the initial connect and on every automatic
|
|
// reconnect. EventSource never replays events that fired
|
|
// during a disconnect window, so a consumer with
|
|
// snapshot-derived state (the dashboard's /api/state stores)
|
|
// must re-sync here or it shows stale state until a manual
|
|
// reload (issue #163).
|
|
if (opts.onStreamOpen) {
|
|
try { opts.onStreamOpen(); }
|
|
catch (err) { console.error('onStreamOpen threw', err); }
|
|
}
|
|
};
|
|
|
|
function flushBuffered(boundarySeq, historyKinds) {
|
|
const drained = buffered;
|
|
buffered = [];
|
|
live = true;
|
|
for (const ev of drained) {
|
|
// Seq-dedupe only events of a kind that actually appeared in
|
|
// the history replay — those are the only ones that could
|
|
// double (once via history, once via the live buffer).
|
|
// Mutation events (approval/question/container/…) are never
|
|
// carried by the history endpoint; deduping them against the
|
|
// broker-history seq would wrongly drop ones that fired
|
|
// between a consumer's own snapshot read and this history
|
|
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
|
|
if (boundarySeq != null
|
|
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
|
&& historyKinds && historyKinds.has(ev.kind)) {
|
|
continue;
|
|
}
|
|
dispatch(ev, false);
|
|
if (opts.onLiveEvent) {
|
|
try { opts.onLiveEvent(ev); }
|
|
catch (err) { console.error('onLiveEvent threw', err); }
|
|
}
|
|
}
|
|
}
|
|
|
|
async function backfill() {
|
|
if (!opts.historyUrl) {
|
|
flushBuffered(null);
|
|
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(opts.historyUrl);
|
|
if (!resp.ok) {
|
|
flushBuffered(null);
|
|
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
|
return;
|
|
}
|
|
const body = await resp.json();
|
|
// Accept the envelope `{ seq, events }`. A bare array means
|
|
// the server hasn't been updated to include seq yet — treat
|
|
// it as "no dedupe possible."
|
|
const events = Array.isArray(body) ? body : (body.events || []);
|
|
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
|
|
// Kinds present in the history replay — the only kinds that
|
|
// can double and therefore the only ones to seq-dedupe.
|
|
const historyKinds = new Set(events.map((ev) => ev.kind));
|
|
currentNoAnim = true;
|
|
for (const ev of events) dispatch(ev, true);
|
|
currentNoAnim = false;
|
|
if (events.length) row('note', '─── live (older above) ───');
|
|
else placeholder('(connected — waiting for events)');
|
|
flushBuffered(boundarySeq, historyKinds);
|
|
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
|
} catch (err) {
|
|
console.warn('history backfill failed', err);
|
|
flushBuffered(null);
|
|
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
|
}
|
|
}
|
|
return backfill();
|
|
}
|
|
|
|
const ready = start();
|
|
return { row, details, detailsDiff, placeholder, ready };
|
|
}
|
|
|
|
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
|
|
// clickable links that open in a new tab. Non-URL text stays as plain
|
|
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
|
|
// punctuation is kept out of the link. (issue #233)
|
|
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
|
export function linkify(text) {
|
|
const str = text == null ? '' : String(text);
|
|
const frag = document.createDocumentFragment();
|
|
if (str.indexOf('://') === -1) { // fast path: no URLs
|
|
if (str) frag.appendChild(document.createTextNode(str));
|
|
return frag;
|
|
}
|
|
let last = 0;
|
|
let m;
|
|
LINKIFY_URL_RE.lastIndex = 0;
|
|
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
|
|
let url = m[0];
|
|
// Don't swallow trailing punctuation that's really sentence text.
|
|
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
|
const tail = trail ? trail[0] : '';
|
|
if (tail) url = url.slice(0, -tail.length);
|
|
if (m.index > last) {
|
|
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
|
|
}
|
|
if (!url.slice(url.indexOf('://') + 3)) {
|
|
// Nothing past the scheme — not a real URL, emit verbatim.
|
|
frag.appendChild(document.createTextNode(m[0]));
|
|
} else {
|
|
const a = document.createElement('a');
|
|
a.href = url; // regex only matches https?:// — safe
|
|
a.textContent = url;
|
|
a.target = '_blank';
|
|
a.rel = 'noopener noreferrer';
|
|
frag.appendChild(a);
|
|
if (tail) frag.appendChild(document.createTextNode(tail));
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < str.length) {
|
|
frag.appendChild(document.createTextNode(str.slice(last)));
|
|
}
|
|
return frag;
|
|
}
|