v0.2.91
This commit is contained in:
1 parent
4da41a1404
commit
5cb6960b04
31 files changed
+1611
-2123
No files matched your search
+109
-34
@@ -16,6 +16,7 @@ import { identityOperation, operationFromEdit } from "@rustpad/collaboration";
|
||||
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||
import { buildLineStarts, createVirtualEditorLayers, VIRTUAL_LINE_THRESHOLD } from "@rustpad/virtual-editor-layers";
|
||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||
import { createMentionAutocomplete } from "@rustpad/mention-autocomplete";
|
||||
import { createMentionNavigator } from "@rustpad/mention-navigation";
|
||||
@@ -52,6 +53,18 @@ export function startNoteEditor(adapter) {
|
||||
? crypto.randomUUID().replaceAll("-", "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
const collaboration = new CollaborationSession(collaborationClientId);
|
||||
const bootLoading = window.__RUSTPAD_LOADING__;
|
||||
let bootDocumentReady = false;
|
||||
let bootFilesReady = false;
|
||||
let bootFinishing = false;
|
||||
|
||||
function releaseBootForAccessDialog() {
|
||||
if (!bootLoading || bootFinishing) return;
|
||||
bootFinishing = true;
|
||||
bootLoading.complete("access");
|
||||
bootLoading.skip("files"); // Identity/password dialog is ready to use.
|
||||
bootLoading.ready();
|
||||
}
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", lastRevealedHeadingHash = "", flushRequested = false, accountSession = null;
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
let pendingPreviewViewport = null;
|
||||
@@ -549,6 +562,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function syncEditorLayers() {
|
||||
virtualLayers.sync();
|
||||
gutter.scrollTop = editor.scrollTop;
|
||||
syncAuthorshipLayer(authorshipLayer, editor);
|
||||
syncOwnerLabels();
|
||||
@@ -557,19 +571,34 @@ export function startNoteEditor(adapter) {
|
||||
let gutterLineCount = 0;
|
||||
let gutterLineHeight = 0;
|
||||
let highlightedGutterLine = null;
|
||||
let cachedLineText = null, cachedLineStarts = [0];
|
||||
const virtualLayers = createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName });
|
||||
function updateLinkedLineHighlight() {
|
||||
const lineCount = cachedLineText === editor.value
|
||||
? cachedLineStarts.length : Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
||||
if (virtualLayers.active) virtualLayers.highlight(linkedLine);
|
||||
else {
|
||||
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
|
||||
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
|
||||
}
|
||||
highlightedGutterLine = linkedLine;
|
||||
}
|
||||
function renderGutter() {
|
||||
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
||||
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
|
||||
document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked);
|
||||
gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked));
|
||||
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const lines = Array.from({ length: lineCount });
|
||||
const content = editor.value;
|
||||
cachedLineText = content;
|
||||
cachedLineStarts = buildLineStarts(content);
|
||||
const lineCount = cachedLineStarts.length;
|
||||
const owners = authorshipOwners(authorship);
|
||||
renderedAuthorshipOwners = owners;
|
||||
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
|
||||
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
||||
const full = authorshipMode === "full";
|
||||
const authorsByLine = showAuthorship && full ? lineAuthors(content, authorship) : [];
|
||||
authorshipLayer.hidden = !showAuthorship;
|
||||
ownerLabels.hidden = !full || !showAuthorship;
|
||||
renderParticipantBadges(authorshipColorsEnabled ? owners : []);
|
||||
@@ -577,8 +606,26 @@ export function startNoteEditor(adapter) {
|
||||
editorWorkspace.dataset.authorshipMode = authorshipMode;
|
||||
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
||||
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
||||
// Physical rows represent sequential line numbers, not specific text lines.
|
||||
// Reuse them across character edits and add/remove only the changed tail.
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||
if (lineCount >= VIRTUAL_LINE_THRESHOLD) {
|
||||
if (!virtualLayers.active) gutterLineCount = 0;
|
||||
virtualLayers.update({
|
||||
content, lineStarts: cachedLineStarts, model: authorship, authorsByLine,
|
||||
full, visible: showAuthorship, link: lineFromHash(location.hash, lineCount),
|
||||
computedStyle: style,
|
||||
});
|
||||
updateLinkedLineHighlight();
|
||||
syncEditorLayers();
|
||||
return;
|
||||
}
|
||||
if (virtualLayers.active) {
|
||||
virtualLayers.destroy();
|
||||
gutterLineCount = 0;
|
||||
gutterLineHeight = 0;
|
||||
highlightedGutterLine = null;
|
||||
}
|
||||
const lines = Array.from({ length: lineCount });
|
||||
if (gutterLineHeight !== lineHeight) {
|
||||
for (const row of gutter.children) row.style.height = `${lineHeight}px`;
|
||||
gutterLineHeight = lineHeight;
|
||||
@@ -593,8 +640,6 @@ export function startNoteEditor(adapter) {
|
||||
for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove();
|
||||
}
|
||||
gutterLineCount = lineCount;
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||
if (full) {
|
||||
let previousAuthorSignature = null;
|
||||
ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => {
|
||||
@@ -610,12 +655,7 @@ export function startNoteEditor(adapter) {
|
||||
} else ownerLabels.replaceChildren();
|
||||
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
||||
else authorshipLayer.replaceChildren();
|
||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
||||
if (highlightedGutterLine !== linkedLine) {
|
||||
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
|
||||
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
|
||||
highlightedGutterLine = linkedLine;
|
||||
}
|
||||
updateLinkedLineHighlight();
|
||||
syncEditorLayers();
|
||||
}
|
||||
|
||||
@@ -644,10 +684,12 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
function revealLinkedLine() {
|
||||
if (!location.hash || location.hash === lastRevealedLineHash) return;
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const hasCurrentIndex = cachedLineText === editor.value;
|
||||
const lineCount = hasCurrentIndex ? cachedLineStarts.length
|
||||
: Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const line = lineFromHash(location.hash, lineCount);
|
||||
if (!line) return;
|
||||
const offset = lineStartOffset(editor.value, line);
|
||||
const offset = hasCurrentIndex ? cachedLineStarts[line - 1] : lineStartOffset(editor.value, line);
|
||||
if (offset == null) return;
|
||||
|
||||
const style = getComputedStyle(editor);
|
||||
@@ -1135,8 +1177,6 @@ export function startNoteEditor(adapter) {
|
||||
let cachedPreviewHeight = -1;
|
||||
let cachedPreviewWidth = -1;
|
||||
function invalidatePreviewPositions() { previewPositionCache = null; }
|
||||
// The (rect.top - container.top + scrollTop) coordinates are invariant to
|
||||
// scrolling; only rebuild them when layout/content actually changes.
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
new ResizeObserver(invalidatePreviewPositions).observe(preview);
|
||||
}
|
||||
@@ -1149,10 +1189,11 @@ export function startNoteEditor(adapter) {
|
||||
preview.addEventListener("toggle", invalidatePreviewPositions, true);
|
||||
preview.addEventListener("load", invalidatePreviewPositions, true);
|
||||
window.addEventListener("resize", invalidatePreviewPositions);
|
||||
window.addEventListener("resize", () => {
|
||||
if (virtualLayers.active) virtualLayers.sync();
|
||||
});
|
||||
document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions);
|
||||
function previewLinePositions() {
|
||||
// Height/width checks catch dynamic embeds that change geometry without
|
||||
// a DOM mutation (e.g. a media player expanding after initialization).
|
||||
const height = preview.scrollHeight, width = preview.clientWidth;
|
||||
if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache;
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
@@ -1312,11 +1353,9 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
let previewStale = true;
|
||||
let forcePreviewRender = false;
|
||||
let initialPreviewEnhancements = Promise.resolve();
|
||||
function renderNow() {
|
||||
const previewViewport = pendingPreviewViewport;
|
||||
// In editor-only view this DOM is display:none. Building a full Markdown
|
||||
// tree (and highlighting / Mermaid) on each remote edit is wasted work.
|
||||
// Keep the exceptional consumers working: heading deep-links and PDF export.
|
||||
const headingRevealPending = uiState.mode === "markdown" && location.hash
|
||||
&& !/^#L[1-9]\d*$/i.test(location.hash)
|
||||
&& lastRevealedHeadingHash !== location.hash;
|
||||
@@ -1337,9 +1376,7 @@ export function startNoteEditor(adapter) {
|
||||
scheduleAliasFileRefresh(editor.value);
|
||||
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
|
||||
if (activeView() !== "edit" || previewNeededForPdf) {
|
||||
renderMermaid();
|
||||
renderCodeHighlight();
|
||||
renderMediaPlayers();
|
||||
initialPreviewEnhancements = Promise.allSettled([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]);
|
||||
}
|
||||
} else {
|
||||
preview.classList.add("preview--raw");
|
||||
@@ -1360,16 +1397,35 @@ export function startNoteEditor(adapter) {
|
||||
revealHeadingAnchor();
|
||||
});
|
||||
}
|
||||
async function finishInitialEditorBoot() {
|
||||
if (!bootLoading || bootFinishing || !bootDocumentReady || !bootFilesReady) return;
|
||||
bootFinishing = true;
|
||||
try {
|
||||
if (deferredRenderTimer !== null) {
|
||||
clearTimeout(deferredRenderTimer);
|
||||
deferredRenderTimer = null;
|
||||
firstDeferredRenderAt = 0;
|
||||
}
|
||||
renderNow();
|
||||
await initialPreviewEnhancements;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
bootLoading.complete("files");
|
||||
bootLoading.ready();
|
||||
} catch (error) {
|
||||
bootLoading.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const render = createRenderQueue(renderNow);
|
||||
// Collaborative edits can arrive many times per second. Keep typing and
|
||||
// WebSocket processing responsive by batching expensive Markdown/DOM work.
|
||||
let deferredRenderTimer = null;
|
||||
let firstDeferredRenderAt = 0;
|
||||
let lastLocalInputAt = -Infinity;
|
||||
function scheduleDocumentRender() {
|
||||
const now = performance.now();
|
||||
if (deferredRenderTimer === null) firstDeferredRenderAt = now;
|
||||
else clearTimeout(deferredRenderTimer);
|
||||
const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||
const activeTyping = now - lastLocalInputAt < 450;
|
||||
const delay = activeTyping ? 300 : Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||
deferredRenderTimer = setTimeout(() => {
|
||||
deferredRenderTimer = null;
|
||||
firstDeferredRenderAt = 0;
|
||||
@@ -1607,7 +1663,7 @@ export function startNoteEditor(adapter) {
|
||||
throw new Error("Server snapshot does not match the collaborative operation");
|
||||
}
|
||||
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map);
|
||||
if (renderView) applyCollaborativeView({ resetHistory: result.remote });
|
||||
if (renderView && !result.ownAck) applyCollaborativeView({ resetHistory: result.remote });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1665,8 +1721,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { loadFiles } = bindNoteFiles({
|
||||
const { loadFiles, initialFilesReady } = bindNoteFiles({
|
||||
editor, toast, getAccessToken: () => accessToken,
|
||||
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
|
||||
canDelete: () => Boolean(info?.can_delete_files),
|
||||
@@ -1753,6 +1808,8 @@ export function startNoteEditor(adapter) {
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
flushRequested = false;
|
||||
saveState.textContent = "Read only";
|
||||
bootDocumentReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
return;
|
||||
}
|
||||
handleCollaborativeAuthentication(message);
|
||||
@@ -1760,6 +1817,8 @@ export function startNoteEditor(adapter) {
|
||||
else if (collaboration.buffer) flushCollaborativeUpdate();
|
||||
else saveState.textContent = "Changes are saved automatically";
|
||||
editor.focus();
|
||||
bootDocumentReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
},
|
||||
onDocument: handleCollaborativeDocument,
|
||||
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
|
||||
@@ -1780,6 +1839,7 @@ export function startNoteEditor(adapter) {
|
||||
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
releaseBootForAccessDialog();
|
||||
try {
|
||||
await loadNoteInfo();
|
||||
adapter.configureView?.(info);
|
||||
@@ -1803,6 +1863,7 @@ export function startNoteEditor(adapter) {
|
||||
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
|
||||
if (/nickname|session|account/i.test(message)) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
if (info?.protected && isResourceAccessError(message)) {
|
||||
@@ -1811,6 +1872,7 @@ export function startNoteEditor(adapter) {
|
||||
document.querySelector("#password-error").textContent = friendly;
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
toast.danger(friendly, { title: "Editor connection error" });
|
||||
@@ -1842,23 +1904,35 @@ export function startNoteEditor(adapter) {
|
||||
nickname = accountSession?.nickname || getNickname();
|
||||
if (!nickname) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
|
||||
await loadNoteInfo();
|
||||
bootLoading?.complete("access");
|
||||
document.title = adapter.title(info);
|
||||
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
|
||||
adapter.configureView?.(info);
|
||||
applyUi({ write: true, replace: true });
|
||||
updateCurrentUser();
|
||||
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
||||
else { loadFiles(); connect(); }
|
||||
if (info.protected && info.access_level === "none") {
|
||||
passwordDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
} else {
|
||||
void loadFiles();
|
||||
void initialFilesReady.then(() => {
|
||||
bootFilesReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
});
|
||||
connect();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
await showSystemNotFound();
|
||||
return;
|
||||
}
|
||||
bootLoading?.fail(e);
|
||||
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
|
||||
}
|
||||
}
|
||||
@@ -2159,7 +2233,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; updateLinkedLineHighlight(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
preview.addEventListener("click", event => {
|
||||
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
|
||||
if (!link) return;
|
||||
@@ -2626,6 +2700,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
|
||||
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
|
||||
previousContent = nextContent;
|
||||
if (!applyingRemote && !applyingHistory) lastLocalInputAt = performance.now();
|
||||
scheduleDocumentRender();
|
||||
if (applyingRemote || applyingHistory) return;
|
||||
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
|
||||
|
||||
Reference in new issue
Block a user