diff --git a/.gitignore b/.gitignore index d698f31..ce90d73 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.db-shm public/viz/dist.js public/editor/dist.js +public/ui.js public/dist.css public/htmx.min.js public/katex.css diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 2022997..4822c9f 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -33,10 +33,7 @@ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") log() { echo "[$TIMESTAMP] ($TARGET) $1"; } log "==> Building assets" -bun run build:css -bun run build:editor -bun run build:viz -bun run build:htmx +bun run build log "==> Backing up DB and previous deploy on VPS" ssh "$VPS" " diff --git a/knip.config.ts b/knip.config.ts index 710bd54..e300e7e 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -5,6 +5,7 @@ const config: KnipConfig = { "src/**/index.ts", "public/editor/editor.ts", "public/viz/viz-hydrate.ts", + "public/ui.ts", "deploy/gen-oauth-jwk.ts", ], project: ["**/*.{ts,tsx,mjs}!"], diff --git a/package.json b/package.json index e9375c5..070788b 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "private": true, "scripts": { "dev": "bun --watch src/server/index.ts & bun --watch src/firehose/index.ts & bun run dev:assets", - "dev:assets": "bun run build:css --watch & bun run build:editor --watch & bun run build:viz --watch", - "build": "bun run build:css && bun run build:editor && bun run build:viz && bun run build:htmx", + "dev:assets": "bun run build:css --watch & bun run build:editor --watch & bun run build:viz --watch & bun run build:ui --watch", + "build": "bun run build:css && bun run build:editor && bun run build:viz && bun run build:ui && bun run build:htmx", "build:css": "mkdir -p public/fonts && cp node_modules/katex/dist/fonts/* public/fonts/ && cp node_modules/katex/dist/katex.min.css public/katex.css && bunx @tailwindcss/cli -i public/style.css -o public/dist.css", "build:editor": "bun build public/editor/editor.ts --outfile public/editor/dist.js", "build:viz": "bun build public/viz/viz-hydrate.ts --outfile public/viz/dist.js", + "build:ui": "bun build public/ui.ts --outfile public/ui.js", "build:htmx": "cp node_modules/htmx.org/dist/htmx.min.js public/htmx.min.js", "test": "bun test", "lint": "biome check src/ public/ tests/", diff --git a/public/editor/editor.ts b/public/editor/editor.ts index b117306..b598191 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -33,6 +33,10 @@ let activeCollab: CollabHandle | null = null; // editor (e.g. when WebSockets are blocked). Keeps "no editor at all" off the table. const COLLAB_CONNECT_TIMEOUT_MS = 5000; +// At most this many avatar chips before the rest collapse into a "+N" tally, so a +// crowded note never widens the presence strip enough to crush the title field. +const MAX_AVATARS = 3; + /** Up-to-two-letter initials for an avatar chip, from a display name or handle. */ function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); @@ -41,6 +45,35 @@ function initials(name: string): string { return (parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? ""); } +/** A single round presence chip — an editor's initials, or a "+N" overflow tally. */ +function avatarChip(opts: { + text: string; + bg: string; + fg: string; + title: string; + first: boolean; +}): HTMLSpanElement { + const chip = document.createElement("span"); + chip.style.display = "inline-flex"; + chip.style.alignItems = "center"; + chip.style.justifyContent = "center"; + chip.style.width = "1.75rem"; + chip.style.height = "1.75rem"; + chip.style.marginLeft = opts.first ? "0" : "-0.4rem"; + chip.style.borderRadius = "9999px"; + chip.style.backgroundColor = opts.bg; + chip.style.color = opts.fg; + chip.style.fontSize = "0.7rem"; + chip.style.fontWeight = "600"; + chip.style.lineHeight = "1"; + chip.style.textTransform = "uppercase"; + chip.style.userSelect = "none"; + chip.style.border = "2px solid var(--bg)"; + chip.title = opts.title; + chip.textContent = opts.text; + return chip; +} + /** * Save flow for live-collaboration mode. Reuses the normal edit route — the * shared Y.Doc text is posted through the same `POST .../edit` endpoint as the @@ -162,20 +195,38 @@ function initEditor(root: Document | Element = document): void { editorPane.style.display = "flex"; editorPane.style.flexDirection = "column"; + // Preview pane mirrors the editor pane: a bordered column with a header strip + // (matched to the toolbar height via .lichen-ed-pane-header) over a scrollable + // rendered-markdown body. The header gives the two panes symmetric chrome. + const previewPane = document.createElement("div"); + previewPane.style.flex = "1 1 0%"; + previewPane.style.minWidth = "0"; + previewPane.style.minHeight = "0"; + previewPane.style.display = "flex"; + previewPane.style.flexDirection = "column"; + previewPane.style.overflow = "hidden"; + previewPane.style.border = "1px solid var(--border-input)"; + previewPane.style.borderRadius = "0.375rem"; + previewPane.style.backgroundColor = "var(--surface)"; + + const previewHeader = document.createElement("div"); + previewHeader.className = "lichen-ed-pane-header"; + previewHeader.textContent = textarea.dataset["previewLabel"] ?? "Preview"; + const preview = document.createElement("div"); const previewClass = textarea.dataset["previewClass"] ?? "prose max-w-none"; preview.className = previewClass; preview.style.flex = "1 1 0%"; - preview.style.minWidth = "0"; + preview.style.minHeight = "0"; preview.style.overflow = "auto"; preview.style.padding = "0.75rem"; - preview.style.border = "1px solid var(--border-input)"; - preview.style.borderRadius = "0.375rem"; - preview.style.backgroundColor = "var(--surface)"; preview.style.color = "var(--text)"; + previewPane.appendChild(previewHeader); + previewPane.appendChild(preview); + wrapper.appendChild(editorPane); - wrapper.appendChild(preview); + wrapper.appendChild(previewPane); textarea.parentElement?.appendChild(wrapper); // Responsive split: side by side on desktop, but on phones the form is no longer @@ -184,20 +235,25 @@ function initEditor(root: Document | Element = document): void { const mobileQuery = window.matchMedia("(max-width: 767px)"); function applyResponsiveLayout(): void { if (mobileQuery.matches) { + // Phones: stack the preview under a fixed-height editor; the page itself + // scrolls, and each pane sizes to its content. wrapper.style.flexDirection = "column"; wrapper.style.height = "auto"; editorPane.style.flex = "0 0 auto"; - editorPane.style.height = "60vh"; + editorPane.style.height = "60dvh"; + previewPane.style.flex = "0 0 auto"; preview.style.flex = "0 0 auto"; - preview.style.height = "auto"; preview.style.overflow = "visible"; } else { + // Desktop: side-by-side, sized straight off the viewport with a floor and + // a ceiling — generous on big screens, still usable on short ones, and + // with no magic offset that could clip the surrounding page chrome. wrapper.style.flexDirection = "row"; - wrapper.style.height = "100%"; + wrapper.style.height = "clamp(22rem, calc(100dvh - 16rem), 56rem)"; editorPane.style.flex = "1 1 0%"; editorPane.style.height = ""; + previewPane.style.flex = "1 1 0%"; preview.style.flex = "1 1 0%"; - preview.style.height = ""; preview.style.overflow = "auto"; } } @@ -220,6 +276,54 @@ function initEditor(root: Document | Element = document): void { flex: 1 1 0% !important; min-height: 0 !important; } + /* Editor chrome — toolbar + pane headers. Token-colored, single style + source instead of per-button inline styles. The 2.25rem min-height + keeps the toolbar and the preview header the same height. */ + .lichen-ed-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + min-height: 2.25rem; + background: var(--bg); + border-bottom: 1px solid var(--border-input); + flex-shrink: 0; + } + .lichen-ed-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 1px solid transparent; + border-radius: 0.375rem; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + } + .lichen-ed-btn:hover { + background: var(--accent-soft); + color: var(--text); + } + .lichen-ed-btn svg { + width: 16px; + height: 16px; + display: block; + } + .lichen-ed-pane-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + min-height: 2.25rem; + background: var(--bg); + border-bottom: 1px solid var(--border-input); + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + flex-shrink: 0; + } `; document.head.appendChild(style); } @@ -256,6 +360,8 @@ function initEditor(root: Document | Element = document): void { const displayName = textarea.dataset["displayName"] ?? ""; const noteHref = textarea.dataset["noteUrl"] ?? ""; const youLabel = textarea.dataset["collabYou"] ?? "you"; + const liveLabel = textarea.dataset["collabLive"] ?? "Live"; + const editingTpl = textarea.dataset["collabEditing"] ?? "{count} editing"; // Paint one avatar chip per connected editor into the avatar strip beside the // title. Chips reuse each editor's cursor color so presence and cursors match. @@ -263,27 +369,52 @@ function initEditor(root: Document | Element = document): void { const container = root.querySelector("[data-collab-avatars]"); if (!container) return; container.replaceChildren(); - users.forEach((u, i) => { - const chip = document.createElement("span"); - chip.style.display = "inline-flex"; - chip.style.alignItems = "center"; - chip.style.justifyContent = "center"; - chip.style.width = "1.75rem"; - chip.style.height = "1.75rem"; - chip.style.marginLeft = i === 0 ? "0" : "-0.4rem"; - chip.style.borderRadius = "9999px"; - chip.style.backgroundColor = u.color; - chip.style.color = "#fff"; - chip.style.fontSize = "0.7rem"; - chip.style.fontWeight = "600"; - chip.style.lineHeight = "1"; - chip.style.textTransform = "uppercase"; - chip.style.userSelect = "none"; - chip.style.border = "2px solid var(--bg)"; - chip.title = u.self ? `${u.name} (${youLabel})` : `@${u.handle}`; - chip.textContent = initials(u.name); - container.appendChild(chip); - }); + // This callback only fires in live-collab mode, so reveal the presence + // cluster as soon as we're connected — including when you're the only + // editor. The lone avatar is a reassuring "live sync is on" signal, and the + // title (flex-1) simply yields the small space the cluster (shrink-0) needs. + const cluster = root.querySelector("[data-collab-presence]"); + if (cluster) cluster.style.display = "flex"; + + // Label tracks the head-count: solo → "Live", otherwise "N editing". + const label = root.querySelector("[data-collab-label]"); + if (label) { + label.textContent = + users.length > 1 + ? editingTpl.replace("{count}", String(users.length)) + : liveLabel; + } + + // Show the first few editors; collapse any extras into a single "+N" chip + // (its tooltip lists the hidden handles). + const shown = users.slice(0, MAX_AVATARS); + for (const [i, u] of shown.entries()) { + container.appendChild( + avatarChip({ + text: initials(u.name), + bg: u.color, + fg: "#fff", + title: u.self ? `${u.name} (${youLabel})` : `@${u.handle}`, + first: i === 0, + }), + ); + } + + const overflow = users.length - shown.length; + if (overflow > 0) { + container.appendChild( + avatarChip({ + text: `+${overflow}`, + bg: "var(--placeholder)", + fg: "var(--text-secondary)", + title: users + .slice(MAX_AVATARS) + .map((u) => `@${u.handle}`) + .join(", "), + first: false, + }), + ); + } } // Builds the CodeMirror view and wires the toolbar, uploads and save flow. @@ -299,7 +430,6 @@ function initEditor(root: Document | Element = document): void { // Plain editor: there is no live session, so drop the collab-only chrome. if (!collabHandle) { root.querySelector("[data-collab-presence]")?.remove(); - root.querySelector("[data-collab-discard]")?.remove(); } const view = new EditorView({ @@ -423,22 +553,6 @@ function initEditor(root: Document | Element = document): void { syncBlobMetadata(); }); - - if (activeCollab) { - const discardBtn = root.querySelector( - "[data-collab-discard]", - ); - discardBtn?.addEventListener("click", () => { - const collab = activeCollab; - if (!collab) return; - const confirmText = - discardBtn.dataset["confirm"] ?? "Discard the shared draft?"; - if (confirm(confirmText)) { - collab.discard(); - if (noteHref) setTimeout(() => location.assign(noteHref), 2500); - } - }); - } } if (collabEnabled && collabWs && userDid && userHandle) { diff --git a/public/editor/toolbar.ts b/public/editor/toolbar.ts index 42f911c..34cfe94 100644 --- a/public/editor/toolbar.ts +++ b/public/editor/toolbar.ts @@ -35,8 +35,13 @@ function insertLink(view: EditorView): void { }); } +/** Stroke-style SVG icon, matching the app's icon set (Feather-like). */ +function icon(paths: string): string { + return ``; +} + interface ToolbarButton { - label: string; + icon: string; title: string; action: (view: EditorView) => void; } @@ -47,53 +52,50 @@ export function createToolbar( ): HTMLDivElement { const buttons: ToolbarButton[] = [ { - label: "B", + icon: icon( + ``, + ), title: "Bold", action: (v) => wrapSelection(v, "**", "**"), }, { - label: "I", + icon: icon( + ``, + ), title: "Italic", action: (v) => wrapSelection(v, "*", "*"), }, { - label: "H", + icon: icon(``), title: "Heading", action: (v) => prependToLine(v, "## "), }, { - label: "\uD83D\uDD17", + icon: icon( + ``, + ), title: "Link", action: (v) => insertLink(v), }, { - label: "\uD83D\uDDBC\uFE0F", + icon: icon( + ``, + ), title: "Image", action: () => fileInput.click(), }, ]; const toolbar = document.createElement("div"); - toolbar.style.display = "flex"; - toolbar.style.gap = "2px"; - toolbar.style.padding = "4px 8px"; - toolbar.style.backgroundColor = "var(--bg)"; - toolbar.style.borderBottom = "1px solid var(--border-input)"; - toolbar.style.flexShrink = "0"; + toolbar.className = "lichen-ed-bar"; for (const btn of buttons) { const el = document.createElement("button"); el.type = "button"; - el.textContent = btn.label; + el.className = "lichen-ed-btn"; + el.innerHTML = btn.icon; el.title = btn.title; - el.style.padding = "2px 8px"; - el.style.border = "1px solid var(--border-input)"; - el.style.borderRadius = "3px"; - el.style.backgroundColor = "var(--surface)"; - el.style.color = "var(--text)"; - el.style.cursor = "pointer"; - el.style.fontSize = "14px"; - el.style.lineHeight = "1.5"; + el.setAttribute("aria-label", btn.title); el.addEventListener("click", () => btn.action(view)); toolbar.appendChild(el); } diff --git a/public/ui.js b/public/ui.ts similarity index 65% rename from public/ui.js rename to public/ui.ts index 22901b5..a578474 100644 --- a/public/ui.js +++ b/public/ui.ts @@ -4,12 +4,27 @@ // All behaviors are triggered by `data-action="..."` attributes (with helper // data-* attributes carrying parameters) so views only emit declarative // markup and never executable strings. +// +// Source of truth for public/ui.js, produced by `bun run build:ui`. (() => { - function $(id) { + // Globals contributed by third-party scripts / our own toast timer, neither + // of which ships ambient types. + interface UiGlobals { + htmx?: { trigger(el: Element, name: string): void }; + _htmxToastTimeout?: ReturnType; + } + const win = window as unknown as UiGlobals; + + function $(id: string): HTMLElement | null { return document.getElementById(id); } - function postForm(url, body) { + function dialogById(id: string): HTMLDialogElement | null { + const el = document.getElementById(id); + return el instanceof HTMLDialogElement ? el : null; + } + + function postForm(url: string, body: string): Promise { const meta = document.querySelector('meta[name="csrf-token"]'); const csrf = meta ? meta.getAttribute("content") || "" : ""; return fetch(url, { @@ -22,7 +37,7 @@ }); } - const PICKER_PAIRS = [ + const PICKER_PAIRS: [string, string][] = [ ["locale-picker", "locale-menu"], ["theme-picker", "theme-menu"], ["profile-picker", "profile-menu"], @@ -44,7 +59,7 @@ // Close search modal when any result link is clicked. if (target.closest("#search-results a")) { - $("search-modal")?.close(); + dialogById("search-modal")?.close(); } const trigger = target.closest("[data-action]"); @@ -59,14 +74,14 @@ if (action === "open-search") { e.preventDefault(); - $("search-modal")?.showModal(); + dialogById("search-modal")?.showModal(); $("search-input")?.focus(); return; } if (action === "close-search-on-link") { const link = target.closest("a"); - if (link) $("search-modal")?.close(); + if (link) dialogById("search-modal")?.close(); return; } @@ -99,14 +114,51 @@ const formId = trigger.getAttribute("data-form"); const msg = trigger.getAttribute("data-confirm") || ""; if (confirm(msg) && formId) { - $(formId)?.submit(); + const form = $(formId); + if (form instanceof HTMLFormElement) form.submit(); } return; } if (action === "close-dialog") { const dialog = target.closest("dialog"); - if (dialog && target === dialog) dialog.close(); + if (dialog instanceof HTMLDialogElement && target === dialog) + dialog.close(); + return; + } + + // Open a modally. preventDefault() so a type="submit" trigger + // doesn't also submit the form — without JS the button submits directly + // (the dialog's own submit button is the enhanced path). + if (action === "open-dialog") { + e.preventDefault(); + // If the trigger belongs to an invalid form, surface native validation + // now — while no modal is trapping focus — instead of opening a dialog + // whose submit button would then silently fail. + const form = + trigger instanceof HTMLButtonElement || + trigger instanceof HTMLInputElement + ? trigger.form + : null; + if (form && !form.checkValidity()) { + form.reportValidity(); + return; + } + const id = trigger.getAttribute("data-target"); + const d = id ? dialogById(id) : null; + if (d) { + d.showModal(); + const focusSel = trigger.getAttribute("data-focus"); + const f = focusSel ? d.querySelector(focusSel) : null; + if (f instanceof HTMLElement) f.focus(); + } + return; + } + + if (action === "close-parent-dialog") { + e.preventDefault(); + const dlg = target.closest("dialog"); + if (dlg instanceof HTMLDialogElement) dlg.close(); return; } @@ -116,13 +168,12 @@ } }); - // Dialog backdrop dismiss (the dialog itself uses data-action="close-dialog" - // via its click handler — see search modal in layout). + // Backdrop dismiss: clicking a element itself (its padding/backdrop + // region, not its inner content, which is wrapped in a child element) closes + // any open modal dialog. document.addEventListener("click", (e) => { const dlg = e.target; - if (dlg && dlg.tagName === "DIALOG" && dlg.id === "search-modal") { - if (e.target === dlg) dlg.close(); - } + if (dlg instanceof HTMLDialogElement) dlg.close(); }); // Change-event behaviors: file-input filename mirror, visibility-toggle @@ -144,7 +195,11 @@ const showWhenValue = t.getAttribute("data-show-when-value"); if (showWhenTarget && showWhenValue !== null && "value" in t) { const el = $(showWhenTarget); - if (el) el.classList.toggle("hidden", t.value !== showWhenValue); + if (el) + el.classList.toggle( + "hidden", + (t as HTMLInputElement).value !== showWhenValue, + ); } const original = t.getAttribute("data-original"); @@ -159,12 +214,12 @@ } const htmxTrigger = t.getAttribute("data-htmx-trigger"); - if (htmxTrigger && window.htmx) { + if (htmxTrigger && win.htmx) { const parts = htmxTrigger.split("|"); const targetId = parts[0]; const evtName = parts[1] || "change"; const targetEl = targetId ? $(targetId) : null; - if (targetEl) window.htmx.trigger(targetEl, evtName); + if (targetEl) win.htmx.trigger(targetEl, evtName); } }); @@ -176,7 +231,7 @@ const expected = f.getAttribute("data-confirm-name"); if (expected) { const input = f.querySelector('[name="confirm"]'); - if (input && input.value !== expected) { + if (input instanceof HTMLInputElement && input.value !== expected) { alert( f.getAttribute("data-mismatch-msg") || "Confirmation did not match.", ); @@ -187,7 +242,7 @@ // Global search shortcut: opt-in via data-search-shortcut on . document.addEventListener("keydown", (e) => { - if (!document.body.dataset.searchShortcut) return; + if (!document.body.dataset["searchShortcut"]) return; const target = e.target instanceof Element ? e.target : document.activeElement; if ( @@ -205,11 +260,11 @@ if (!slash && !cmdK) return; e.preventDefault(); const inlineSearch = $("wiki-search"); - if (inlineSearch) { + if (inlineSearch instanceof HTMLInputElement) { inlineSearch.focus(); inlineSearch.select(); } else { - $("search-modal")?.showModal(); + dialogById("search-modal")?.showModal(); $("search-input")?.focus(); } }); @@ -220,8 +275,8 @@ const toast = $("htmx-toast"); if (!toast) return; toast.classList.remove("hidden"); - clearTimeout(window._htmxToastTimeout); - window._htmxToastTimeout = setTimeout(() => { + clearTimeout(win._htmxToastTimeout); + win._htmxToastTimeout = setTimeout(() => { toast.classList.add("hidden"); }, 3000); }); diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index cf0f936..81e57b3 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -86,6 +86,8 @@ export const en: Messages = { editSummaryPlaceholder: "Describe your changes", createNote: "Create Note", save: "Save", + saveTitle: "Save changes", + saveHint: "Add a short summary of your edit, or leave it blank.", cancel: "Cancel", deleteNote: "Delete note", confirmDeleteNote: @@ -96,6 +98,8 @@ export const en: Messages = { importFileBrowse: "Browse", collabConnecting: "Connecting…", collabActive: "Live collaboration", + collabLive: "Live", + collabEditing: "{count} editing", collabHint: "People currently editing this page", collabYou: "you", discardDraft: "Discard draft", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 3f58d59..a74fdd7 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -88,6 +88,8 @@ export const fr: PartialMessages = { editSummaryPlaceholder: "Décrivez vos modifications", createNote: "Créer la note", save: "Enregistrer", + saveTitle: "Enregistrer les modifications", + saveHint: "Ajoutez un bref résumé de votre modification, ou laissez vide.", cancel: "Annuler", deleteNote: "Supprimer la note", confirmDeleteNote: @@ -98,6 +100,8 @@ export const fr: PartialMessages = { importFileBrowse: "Parcourir", collabConnecting: "Connexion…", collabActive: "Collaboration en direct", + collabLive: "En direct", + collabEditing: "{count} en train d'éditer", collabHint: "Personnes en train de modifier cette page", collabYou: "vous", discardDraft: "Abandonner le brouillon", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index fb15332..5b4126f 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -87,6 +87,8 @@ export interface Messages { editSummaryPlaceholder: string; createNote: string; save: string; + saveTitle: string; + saveHint: string; cancel: string; deleteNote: string; confirmDeleteNote: string; @@ -96,6 +98,8 @@ export interface Messages { importFileBrowse: string; collabConnecting: string; collabActive: string; + collabLive: string; + collabEditing: string; collabHint: string; collabYou: string; discardDraft: string; diff --git a/src/views/edit-note.ts b/src/views/edit-note.ts index 4a6c810..ea26186 100644 --- a/src/views/edit-note.ts +++ b/src/views/edit-note.ts @@ -34,128 +34,112 @@ export function editNotePage( data-collab-ws="${escapeHtml(collab.wsPath)}" data-collab-connecting="${escapeHtml(msg.editor.collabConnecting)}" data-collab-you="${escapeHtml(msg.editor.collabYou)}" + data-collab-live="${escapeHtml(msg.editor.collabLive)}" + data-collab-editing="${escapeHtml(msg.editor.collabEditing)}" data-did="${escapeHtml(collab.did)}" data-handle="${escapeHtml(collab.handle)}" data-display-name="${escapeHtml(collab.displayName)}" data-note-url="${escapeHtml(collab.noteUrl)}"` : ""; - // Presence cluster: occupies the right half of the title row so it sits above - // the preview pane, mirroring the editor|preview split below. A "live" dot + - // label explains what the avatars are, then the avatar strip (filled by the - // client, one chip per connected editor, self included). Empty until the socket - // syncs; the whole cluster is removed if collaboration falls back to plain mode. + // Presence cluster, parked at the right end of the title row. Rendered hidden, + // then revealed by renderPresence() in editor.ts once the collab socket is + // connected — including solo, where the lone avatar confirms live sync is on. + // It's shrink-0, so the title (flex-1) yields just the space it needs. The node + // is removed entirely if collaboration falls back to plain (non-live) mode. const collabPresence = collab?.enabled ? `` : ""; - const discardButton = collab?.enabled - ? `` - : ""; - - // CSS fix: on desktop (md+) the edit form is pinned to the viewport height so the - // editor/preview sit side by side and the CodeMirror scroller shows a scrollbar. - // Below md the form flows naturally and the editor stacks above the preview - // (handled in editor.ts), so the constraints are scoped to md+. - const scrollFixStyle = ` - - `; - + // The editor/preview panes size themselves (see editor.ts), so the form just + // flows in document order — the action bar can never be clipped on short + // viewports the way the old viewport-pinned, overflow-hidden form could be. const formHtml = ` -
-
-
- - -
+ +
+ ${collabPresence}
-
- +
-
- - -
-
+
+ ${msg.editor.cancel} - ${msg.editor.cancel} - ${discardButton}
+ + +
+
+

${msg.editor.saveTitle}

+

${msg.editor.saveHint}

+
+ +
+ + +
+
+
`; - return layout(`${msg.wiki.edit} ${noteTitle}`, scrollFixStyle + formHtml, { + return layout(`${msg.wiki.edit} ${noteTitle}`, formHtml, { ...options, wikiName: wiki.name, wikiSlug: wiki.slug, diff --git a/src/views/new-note.ts b/src/views/new-note.ts index 81643ae..96b3d1d 100644 --- a/src/views/new-note.ts +++ b/src/views/new-note.ts @@ -29,31 +29,11 @@ export function newNotePage( const titleValue = escapeHtml(options?.titleValue ?? ""); const escapedContent = escapeHtml(options?.contentValue ?? ""); - // Simple fix: give the editor a max height and make it scroll internally - const scrollFixStyle = ` - - `; - + // Editor/preview sizing is owned by editor.ts (shared with the edit page), so + // there's no page-local height hack here anymore. return layout( `${msg.wiki.newNote} — ${wiki.name}`, - scrollFixStyle + - ` + `

${msg.wiki.newNote}

${errorHtml}