diff --git a/Cargo.lock b/Cargo.lock index be8ce6a..67e50f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.24" +version = "0.1.25" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index e6f00aa..88e35c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.24" +version = "0.1.25" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/static/css/styles.css b/static/css/styles.css index 49cead2..209f8ab 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -441,8 +441,8 @@ textarea:focus { display: none; } -.workspace > .editor-column, -.workspace > .preview-column { +.workspace>.editor-column, +.workspace>.preview-column { transition: opacity .18s ease, transform .18s ease; } @@ -2138,6 +2138,12 @@ dialog::backdrop { border-radius: 4px; outline: none; cursor: text; + user-select: text; + -webkit-user-select: text; +} + +.preview-editable[contenteditable="true"] { + caret-color: auto; } .preview-editable:hover { @@ -3799,7 +3805,7 @@ dialog::backdrop { position: relative; } -.emoji-picker > summary { +.emoji-picker>summary { display: flex; align-items: center; min-height: 34px; @@ -3813,12 +3819,12 @@ dialog::backdrop { white-space: nowrap; } -.emoji-picker > summary::-webkit-details-marker { +.emoji-picker>summary::-webkit-details-marker { display: none; } -.emoji-picker > summary:hover, -.emoji-picker[open] > summary { +.emoji-picker>summary:hover, +.emoji-picker[open]>summary { border-color: var(--border); background: var(--surface-2); color: white; @@ -4014,10 +4020,13 @@ dialog::backdrop { } @media (max-width: 720px) { - .authorship-layer { left: 42px; } + .authorship-layer { + left: 42px; + } + .hide-editor-line-numbers .authorship-layer { - left: 0; -} + left: 0; + } } /* Multiple authors can contribute to one line. */ @@ -4245,7 +4254,7 @@ dialog::backdrop { display: flex; } - .pad-page .header-actions > * { + .pad-page .header-actions>* { width: 100%; flex: 0 0 auto; } @@ -4255,6 +4264,23 @@ dialog::backdrop { justify-content: flex-start; } + .pad-page .header-actions .page-settings>summary { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 36px; + align-items: center; + justify-content: flex-start; + } + + .pad-page .header-actions .page-settings-menu { + position: static; + width: 100%; + min-width: 0; + margin-top: 8px; + box-shadow: none; + } + .pad-page .header-actions .public-task-toggle { min-height: 40px; padding: 8px 10px; @@ -4420,7 +4446,10 @@ dialog::backdrop { } /* Quick actions appear when the editor header switches to its compact menu. */ -.mobile-editor-bubble { display: none; } +.mobile-editor-bubble { + display: none; +} + @media (max-width: 1499px) { .pad-page .mobile-editor-bubble { position: fixed; @@ -4432,13 +4461,14 @@ dialog::backdrop { gap: 5px; max-width: calc(100vw - 20px); padding: 5px; - border: 1px solid rgba(255,255,255,.12); + border: 1px solid rgba(255, 255, 255, .12); border-radius: 999px; - background: rgba(17,21,28,.88); - box-shadow: 0 8px 24px rgba(0,0,0,.28); + background: rgba(17, 21, 28, .88); + box-shadow: 0 8px 24px rgba(0, 0, 0, .28); backdrop-filter: blur(10px); } - .mobile-editor-bubble > button { + + .mobile-editor-bubble>button { display: inline-grid; width: 34px; height: 34px; @@ -4446,17 +4476,19 @@ dialog::backdrop { place-items: center; border: 0; border-radius: 50%; - background: rgba(255,255,255,.07); + background: rgba(255, 255, 255, .07); color: var(--text); font-size: 1rem; } + .mobile-color-dot { width: 15px; height: 15px; - border: 2px solid rgba(255,255,255,.75); + border: 2px solid rgba(255, 255, 255, .75); border-radius: 50%; background: var(--owner, var(--accent)); } + .mobile-connection-status { display: inline-flex; align-items: center; @@ -4467,12 +4499,16 @@ dialog::backdrop { font-size: .72rem; white-space: nowrap; } + .mobile-connection-status #mobile-status-text { max-width: 92px; overflow: hidden; text-overflow: ellipsis; } - .pad-page .toast { bottom: calc(62px + env(safe-area-inset-bottom, 0px)); } + + .pad-page .toast { + bottom: calc(62px + env(safe-area-inset-bottom, 0px)); + } } /* Movable quick actions for compact editor screens. */ @@ -4496,35 +4532,12 @@ dialog::backdrop { max-height: min(560px, calc(100dvh - 96px)); } - .mobile-time-button { - position: relative; - } - - .mobile-time-preview { - position: absolute; - right: 8px; - bottom: calc(100% + 8px); - padding: 6px 9px; - border: 1px solid var(--border-strong); - border-radius: 8px; - background: var(--surface); - box-shadow: 0 8px 24px rgb(0 0 0 / 32%); - color: var(--text); - font-size: .78rem; - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - - .mobile-time-preview[hidden] { - display: none; - } - .pad-page .mobile-editor-bubble.is-dragging { transition: none; cursor: grabbing; } - .mobile-editor-bubble > .mobile-bubble-drag { + .mobile-editor-bubble>.mobile-bubble-drag { width: 24px; background: transparent; color: var(--muted-2); @@ -4562,39 +4575,120 @@ dialog::backdrop { } /* Authorship display modes */ -.editor-column-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.authorship-mode-control { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--panel) 88%, transparent); } -.authorship-mode-control button { min-height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11px; } -.authorship-mode-control button.active { background: var(--surface-strong, #262b35); color: var(--text); } -.participant-badges { display: flex; flex-wrap: wrap; align-items: center; align-content: center; gap: 6px; min-width: 0; min-height: 0; padding: 7px 12px; border-bottom: 1px solid var(--border); } -.participant-badges[hidden] { display: none; } -.participant-badge { display: inline-flex; flex: 0 0 auto; align-items: center; width: auto; max-width: 100%; min-height: 0; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, transparent); font: 600 11px/1.3 system-ui, sans-serif; } -@media (max-width: 720px) { - .editor-column-label { align-items: flex-start; } - .authorship-mode-control button { padding-inline: 7px; } - .participant-badges { padding: 6px 8px; } - .public-task-toggle { width: 100%; } +.editor-column-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } -.public-page-options { display: grid; gap: 4px; align-content: center; } -.public-page-options .public-task-toggle { min-height: 24px; } -@media (max-width: 720px) { .public-page-options { width: 100%; } } +.authorship-mode-control { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--panel) 88%, transparent); +} + +.authorship-mode-control button { + min-height: 26px; + padding: 0 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 11px; +} + +.authorship-mode-control button.active { + background: var(--surface-strong, #262b35); + color: var(--text); +} + +.participant-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: center; + gap: 6px; + min-width: 0; + min-height: 0; + padding: 7px 12px; + border-bottom: 1px solid var(--border); +} + +.participant-badges[hidden] { + display: none; +} + +.participant-badge { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + width: auto; + max-width: 100%; + min-height: 0; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--owner) 18%, transparent); + font: 600 11px/1.3 system-ui, sans-serif; +} + +@media (max-width: 720px) { + .editor-column-label { + align-items: flex-start; + } + + .authorship-mode-control button { + padding-inline: 7px; + } + + .participant-badges { + padding: 6px 8px; + } + + .public-task-toggle { + width: 100%; + } +} + +.public-page-options { + display: grid; + gap: 4px; + align-content: center; +} + +.public-page-options .public-task-toggle { + min-height: 24px; +} + +@media (max-width: 720px) { + .public-page-options { + width: 100%; + } +} /* Keep the whole editor surface consistent in Simple and Full modes. */ .editor-shell { background: #0d1015; } + .editor-shell textarea { z-index: 3; background: transparent; } + .authorship-layer { z-index: 2; background: transparent; } + .owner-labels { z-index: 4; } + .line-gutter { z-index: 5; } @@ -4688,37 +4782,105 @@ dialog::backdrop { min-height: 0; } -.pad-page .editor-column > .column-label, -.pad-page .editor-column > .participant-badges { +.pad-page .editor-column>.column-label, +.pad-page .editor-column>.participant-badges { flex: 0 0 auto; } -.pad-page .editor-column > .editor-shell { +.pad-page .editor-column>.editor-shell { flex: 1 1 auto; width: 100%; min-height: 0; } /* Compact Page settings dropdown. */ -.page-settings { position: relative; } -.page-settings > summary { list-style: none; cursor: pointer; } -.page-settings > summary::-webkit-details-marker { display: none; } -.page-settings-menu { position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; display: grid; gap: 4px; min-width: 220px; padding: 8px; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); box-shadow: 0 12px 30px rgba(0,0,0,.28); } -.page-settings-menu .public-task-toggle { min-height: 30px; padding: 5px 7px; border-radius: 6px; } -.page-settings-menu .public-task-toggle:hover { background: color-mix(in srgb, var(--surface-strong, #262b35) 72%, transparent); } -#publish-page:disabled { opacity: .45; cursor: not-allowed; } +.page-settings { + position: relative; +} + +.page-settings>summary { + list-style: none; + cursor: pointer; +} + +.page-settings>summary::-webkit-details-marker { + display: none; +} + +.page-settings-menu { + position: absolute; + z-index: 30; + top: calc(100% + 6px); + right: 0; + display: grid; + gap: 4px; + min-width: 220px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--panel); + box-shadow: 0 12px 30px rgba(0, 0, 0, .28); +} + +.page-settings-menu .public-task-toggle { + min-height: 30px; + padding: 5px 7px; + border-radius: 6px; +} + +.page-settings-menu .public-task-toggle:hover { + background: color-mix(in srgb, var(--surface-strong, #262b35) 72%, transparent); +} + +#publish-page:disabled { + opacity: .45; + cursor: not-allowed; +} /* Keep the Simple/Full switch inside the editor label frame. */ -.authorship-mode-control { padding: 1px; border-radius: 7px; } -.authorship-mode-control button { min-height: 20px; height: 20px; padding: 0 7px; border-radius: 5px; line-height: 20px; } +.authorship-mode-control { + padding: 1px; + border-radius: 7px; +} + +.authorship-mode-control button { + min-height: 20px; + height: 20px; + padding: 0 7px; + border-radius: 5px; + line-height: 20px; +} /* Make secondary editor actions read clearly as buttons. */ -.markdown-more > summary, -.pad-page .toolbar-action { display: inline-flex; align-items: center; justify-content: center; min-height: 30px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-strong, #262b35); color: var(--text); font-weight: 600; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.04); } -.markdown-more > summary:hover, -.pad-page .toolbar-action:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); filter: brightness(1.08); } -.markdown-more > summary { list-style: none; } -.markdown-more > summary::-webkit-details-marker { display: none; } +.markdown-more>summary, +.pad-page .toolbar-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface-strong, #262b35); + color: var(--text); + font-weight: 600; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04); +} + +.markdown-more>summary:hover, +.pad-page .toolbar-action:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + filter: brightness(1.08); +} + +.markdown-more>summary { + list-style: none; +} + +.markdown-more>summary::-webkit-details-marker { + display: none; +} /* Final UI fixes: opaque Page settings and per-note authorship controls. */ @@ -4727,32 +4889,151 @@ dialog::backdrop { opacity: 1; backdrop-filter: none; } -.authorship-controls { display: inline-flex; align-items: center; gap: 7px; } -.authorship-color-toggle { display: inline-flex; align-items: center; gap: 5px; min-height: 22px; color: var(--muted); font-size: 11px; cursor: pointer; } -.authorship-color-toggle input { width: 28px; height: 16px; margin: 0; accent-color: var(--accent); } -.authorship-layer { overflow: hidden; padding: 0; } -.authorship-canvas { position: absolute; top: 0; left: 0; box-sizing: border-box; color: transparent; white-space: pre; tab-size: 4; will-change: transform; } -.share-link-row .share-link-info { justify-self: end; width: min(100%, 520px); text-align: right; } -.share-link-row .share-link-inline { width: 100%; text-align: left; } -@media (max-width: 800px) { - .share-link-row .share-link-info { justify-self: stretch; width: 100%; text-align: left; } - .authorship-controls { max-width: 100%; gap: 5px; } + +.authorship-controls { + display: inline-flex; + align-items: center; + gap: 7px; } -.page-settings-menu .public-task-toggle { background: #171c24; } -.page-settings-menu .public-task-toggle:hover { background: #242b36; } +.authorship-color-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 22px; + color: var(--muted); + font-size: 11px; + cursor: pointer; +} + +.authorship-color-toggle input { + width: 28px; + height: 16px; + margin: 0; + accent-color: var(--accent); +} + +.authorship-layer { + overflow: hidden; + padding: 0; +} + +.authorship-canvas { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + color: transparent; + white-space: pre; + tab-size: 4; + will-change: transform; +} + +.share-link-row .share-link-info { + justify-self: end; + width: min(100%, 520px); + text-align: right; +} + +.share-link-row .share-link-inline { + width: 100%; + text-align: left; +} + +@media (max-width: 800px) { + .share-link-row .share-link-info { + justify-self: stretch; + width: 100%; + text-align: left; + } + + .authorship-controls { + max-width: 100%; + gap: 5px; + } +} + +.page-settings-menu .public-task-toggle { + background: #171c24; +} + +.page-settings-menu .public-task-toggle:hover { + background: #242b36; +} /* Shared editor display settings */ -.authorship-controls { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } -.switch-control { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 11px; color: var(--muted); user-select: none; } -.switch-control input { position: absolute; opacity: 0; pointer-events: none; } -.switch-control__track { position: relative; width: 30px; height: 16px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-strong, #262b35); transition: .15s ease; } -.switch-control__track::after { content: ""; position: absolute; width: 10px; height: 10px; left: 2px; top: 2px; border-radius: 50%; background: var(--muted); transition: .15s ease; } -.switch-control input:checked + .switch-control__track { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 28%, var(--surface-strong, #262b35)); } -.switch-control input:checked + .switch-control__track::after { transform: translateX(14px); background: var(--accent); } -.editor-settings-save { min-height: 22px; height: 22px; padding: 0 8px; font-size: 11px; } -.editor-settings-save:disabled { opacity: .45; cursor: not-allowed; } -.share-link-row .share-link-info { margin-top: 8px; justify-self: start; text-align: left; } +.authorship-controls { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; +} + +.switch-control { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 11px; + color: var(--muted); + user-select: none; +} + +.switch-control input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.switch-control__track { + position: relative; + width: 30px; + height: 16px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-strong, #262b35); + transition: .15s ease; +} + +.switch-control__track::after { + content: ""; + position: absolute; + width: 10px; + height: 10px; + left: 2px; + top: 2px; + border-radius: 50%; + background: var(--muted); + transition: .15s ease; +} + +.switch-control input:checked+.switch-control__track { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 28%, var(--surface-strong, #262b35)); +} + +.switch-control input:checked+.switch-control__track::after { + transform: translateX(14px); + background: var(--accent); +} + +.editor-settings-save { + min-height: 22px; + height: 22px; + padding: 0 8px; + font-size: 11px; +} + +.editor-settings-save:disabled { + opacity: .45; + cursor: not-allowed; +} + +.share-link-row .share-link-info { + margin-top: 8px; + justify-self: start; + text-align: left; +} .preview-editable--source { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace); @@ -4768,17 +5049,21 @@ dialog::backdrop { padding-top: 12px; border-top: 1px solid var(--border); } + .share-link-list-wrap h5 { margin: 0; font-size: .86rem; } + .share-link-list { width: 100%; } + .share-link-row { grid-template-columns: minmax(130px, auto) 100px auto auto; align-items: end; } + .share-link-row .share-link-info { grid-column: 1 / -1; width: 100%; @@ -4786,14 +5071,17 @@ dialog::backdrop { text-align: left; margin-top: 0; } + .share-link-row .share-link-inline { width: 100%; } + @media (max-width: 800px) { .share-link-row { grid-template-columns: 1fr; } + .share-link-row .share-link-info { grid-column: 1; } -} +} \ No newline at end of file diff --git a/static/home.html b/static/home.html index 72c7c21..187573b 100644 --- a/static/home.html +++ b/static/home.html @@ -118,7 +118,9 @@

My notes and workspaces

Items created while signed in are assigned to your account.

-

Access rules: Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.

+

Access rules: Public items open from their link; a password + adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or + valid share links. Unauthorized visitors receive a not-found response.

diff --git a/static/js/authorship.js b/static/js/authorship.js index 58387d0..2ce09c8 100644 --- a/static/js/authorship.js +++ b/static/js/authorship.js @@ -171,6 +171,17 @@ export function lineOwners(content, model) { }); } +export function syncAuthorshipLayer(layer, editor) { + if (!layer || !editor) return; + const canvas = layer.querySelector(".authorship-canvas"); + if (!canvas) return; + canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`; + // The canvas already mirrors the textarea scroll through its transform. + // Keeping the overlay element itself at zero prevents a double offset. + layer.scrollTop = 0; + layer.scrollLeft = 0; +} + export function renderAuthorshipLayer(layer, editor, model, colorFor) { if (!layer) return; const style = getComputedStyle(editor); @@ -202,6 +213,6 @@ export function renderAuthorshipLayer(layer, editor, model, colorFor) { } if (cursor < text.length) canvas.append(document.createTextNode(text.slice(cursor))); if (!text.endsWith("\n")) canvas.append(document.createTextNode("\n")); - canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`; layer.replaceChildren(canvas); + syncAuthorshipLayer(layer, editor); } diff --git a/static/js/editor-format.js b/static/js/editor-format.js index 63a557f..c817bb6 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -11,6 +11,18 @@ function toggleWrap(editor, before, after = before, placeholder = "tekst") { editor.setRangeText(selected, start - before.length, end + after.length, "select"); return; } + if (selected.includes("\n") && !before.includes("\n") && !after.includes("\n")) { + const lines = selected.split("\n"); + const nonEmpty = lines.filter(line => line.length > 0); + const allWrapped = nonEmpty.length > 0 && nonEmpty.every(line => line.startsWith(before) && line.endsWith(after) && line.length >= before.length + after.length); + const replacement = lines.map(line => { + if (!line) return line; + return allWrapped ? line.slice(before.length, line.length - after.length) : `${before}${line}${after}`; + }).join("\n"); + editor.setRangeText(replacement, start, end, "select"); + return; + } + if (selected.startsWith(before) && selected.endsWith(after) && selected.length >= before.length + after.length) { editor.setRangeText(selected.slice(before.length, -after.length), start, end, "select"); return; diff --git a/static/js/markdown.js b/static/js/markdown.js index c33fc32..89c8f3c 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -93,7 +93,7 @@ function normalizeLanguage(value) { return languageAliases[language] || language.replace(/[^a-z0-9_-]/g, ""); } -const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`; +const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`; const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line); @@ -135,7 +135,7 @@ function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType if (item.checked != null) { body += ``; } - body += `${inline(item.text)}`; + body += `${inline(item.text)}`; index++; while (index < lines.length) { @@ -275,13 +275,13 @@ export function renderMarkdown(source, lineOffset = 0) { closeList(); const headers = splitTableRow(line); html += `
`; - headers.forEach((cell, i) => html += ``); + headers.forEach((cell, i) => html += ``); html += ``; index += 2; while (index < lines.length && lines[index].includes("|") && lines[index].trim()) { const cells = splitTableRow(lines[index]); html += ``; - headers.forEach((_, i) => html += ``); + headers.forEach((_, i) => html += ``); html += ``; index++; } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 2d38e97..d44c33c 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -1,7 +1,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; installGlobalDiagnostics(); -import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship"; +import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship"; import { copyText } from "@rustpad/clipboard"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { bindEmojiPicker } from "@rustpad/emoji-picker"; @@ -102,6 +102,18 @@ export function startNoteEditor(adapter) { return badge; })); } + function syncOwnerLabels() { + ownerLabels.querySelectorAll(".owner-label-group[data-content-top]").forEach(group => { + group.style.top = `${Number(group.dataset.contentTop) - editor.scrollTop}px`; + }); + } + + function syncEditorLayers() { + gutter.scrollTop = editor.scrollTop; + syncAuthorshipLayer(authorshipLayer, editor); + syncOwnerLabels(); + } + function renderGutter() { const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lines = Array.from({ length: lineCount }); @@ -123,44 +135,98 @@ export function startNoteEditor(adapter) { ownerLabels.innerHTML = lines.map((_, i) => { const authors = authorsByLine[i] || []; if (!authors.length) return ""; - const top = paddingTop + i * lineHeight - editor.scrollTop; + const top = paddingTop + i * lineHeight; const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000"); if (signature === previousAuthorSignature) return ""; previousAuthorSignature = signature; const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join(""); - return `${badges}`; + return `${badges}`; }).join(""); } else ownerLabels.replaceChildren(); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); else authorshipLayer.replaceChildren(); + syncEditorLayers(); document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked); } function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); } + function previewNodeMarkdownParts(current) { + if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null }; + const tag = current.tagName.toLowerCase(); + if (tag === "strong" || tag === "b") return { open: "**", close: "**", atomic: null }; + if (tag === "em" || tag === "i") return { open: "*", close: "*", atomic: null }; + if (tag === "s" || tag === "del") return { open: "~~", close: "~~", atomic: null }; + if (tag === "mark") return { open: "==", close: "==", atomic: null }; + if (tag === "code") return { open: "`", close: "`", atomic: null }; + if (tag === "sub") return { open: "~", close: "~", atomic: null }; + if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null }; + if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null }; + if (tag === "img") { + const src = current.getAttribute("src") || ""; + const alt = current.getAttribute("alt") || ""; + const title = current.getAttribute("title"); + return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, """)}"` : ""})` }; + } + if (tag === "br") return { open: "", close: "", atomic: "\n" }; + return { open: "", close: "", atomic: null }; + } + + function previewNodeMarkdown(current) { + if (current.nodeType === Node.TEXT_NODE) return (current.nodeValue || "").replace(/\u00a0/g, " "); + if (current.nodeType !== Node.ELEMENT_NODE) return ""; + const parts = previewNodeMarkdownParts(current); + if (parts.atomic !== null) return parts.atomic; + const body = [...current.childNodes].map(previewNodeMarkdown).join(""); + return `${parts.open}${body}${parts.close}`; + } + function markdownFromPreview(node) { - const walk = current => { - if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || ""; - if (current.nodeType !== Node.ELEMENT_NODE) return ""; - const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join(""); - if (tag === "strong" || tag === "b") return `**${body}**`; - if (tag === "em" || tag === "i") return `*${body}*`; - if (tag === "s" || tag === "del") return `~~${body}~~`; - if (tag === "mark") return `==${body}==`; - if (tag === "code") return "`" + body + "`"; - if (tag === "sub") return `~${body}~`; - if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`; - if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`; - if (tag === "img") { - const src = current.getAttribute("src") || ""; - const alt = current.getAttribute("alt") || ""; - const title = current.getAttribute("title"); - return `![${alt}](${src}${title ? ` "${title.replace(/"/g, """)}"` : ""})`; + return [...node.childNodes].map(previewNodeMarkdown).join(""); + } + + function markdownPointOffset(root, container, offset) { + let result = 0; + let found = false; + const contains = (parent, child) => parent === child || (parent.nodeType === Node.ELEMENT_NODE && parent.contains(child)); + const walk = (current, isRoot = false) => { + if (found) return; + if (current === container) { + if (current.nodeType === Node.TEXT_NODE) result += Math.max(0, Math.min(offset, (current.nodeValue || "").length)); + else if (current.nodeType === Node.ELEMENT_NODE) { + const parts = isRoot ? { open: "", close: "", atomic: null } : previewNodeMarkdownParts(current); + if (parts.atomic !== null) result += offset > 0 ? parts.atomic.length : 0; + else { + result += parts.open.length; + const children = [...current.childNodes]; + for (let index = 0; index < Math.min(offset, children.length); index++) result += previewNodeMarkdown(children[index]).length; + } + } + found = true; + return; } - if (tag === "br") return "\n"; - return body; + if (current.nodeType === Node.TEXT_NODE) { + result += (current.nodeValue || "").length; + return; + } + if (current.nodeType !== Node.ELEMENT_NODE) return; + const parts = isRoot ? { open: "", close: "", atomic: null } : previewNodeMarkdownParts(current); + if (parts.atomic !== null) { + result += parts.atomic.length; + return; + } + result += parts.open.length; + for (const child of current.childNodes) { + if (contains(child, container)) { + walk(child); + return; + } + result += previewNodeMarkdown(child).length; + } + result += parts.close.length; }; - return [...node.childNodes].map(walk).join("").replace(/\u00a0/g, " "); + walk(root, true); + return found ? result : 0; } function previewCaretOffset(target) { @@ -186,11 +252,40 @@ export function startNoteEditor(adapter) { const range = document.createRange(); range.selectNodeContents(target); range.collapse(false); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } + function deactivatePreviewEdit(target) { + if (!target) return; + target.removeAttribute("contenteditable"); + target.removeAttribute("spellcheck"); + target.classList.remove("preview-editable--active"); + } + + function activatePreviewEdit(target, offset = null) { + if (!target) return; + const active = preview.querySelector('.preview-editable[contenteditable="true"]'); + if (active && active !== target) { + const targetIndex = [...preview.querySelectorAll(".preview-editable")].indexOf(target); + active.blur(); + target = preview.querySelectorAll(".preview-editable")[targetIndex]; + if (!target) return; + } + target.setAttribute("contenteditable", "true"); + target.setAttribute("spellcheck", "true"); + target.classList.add("preview-editable--active"); + target.focus({ preventScroll: true }); + placePreviewCaret(target, offset == null ? previewCaretOffset(target) : offset); + } + function movePreviewCaret(target, direction) { const editables = [...preview.querySelectorAll(".preview-editable")]; - const index = editables.indexOf(target), next = editables[index + direction]; + const index = editables.indexOf(target); + if (index < 0 || !editables[index + direction]) return false; + const offset = previewCaretOffset(target); + target.blur(); + const next = [...preview.querySelectorAll(".preview-editable")][index + direction]; if (!next) return false; - const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(next, offset); next.scrollIntoView({ block: "nearest" }); return true; + activatePreviewEdit(next, offset); + next.scrollIntoView({ block: "nearest" }); + return true; } function continueIndentation(event) { if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; @@ -212,6 +307,129 @@ export function startNoteEditor(adapter) { const cells = body.split("|").map(cell => cell.trim()); while (cells.length <= index) cells.push(""); cells[index] = value.replace(/\|/g, "|"); return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`; } + function sourceLineBounds(lineIndex) { + const lines = editor.value.split("\n"); + if (lineIndex < 0 || lineIndex >= lines.length) return null; + let start = 0; + for (let index = 0; index < lineIndex; index++) start += lines[index].length + 1; + return { start, end: start + lines[lineIndex].length, text: lines[lineIndex], lineIndex }; + } + + function tableCellBounds(line, cellIndex) { + const first = line.search(/\S|$/); + const trailingWhitespace = (line.match(/\s*$/) || [""])[0].length; + let bodyStart = first; + let bodyEnd = line.length - trailingWhitespace; + if (line[bodyStart] === "|") bodyStart++; + if (bodyEnd > bodyStart && line[bodyEnd - 1] === "|") bodyEnd--; + const body = line.slice(bodyStart, bodyEnd); + const segments = []; + let segmentStart = 0; + for (let index = 0; index <= body.length; index++) { + if (index === body.length || body[index] === "|") { + const raw = body.slice(segmentStart, index); + const left = (raw.match(/^\s*/) || [""])[0].length; + const right = (raw.match(/\s*$/) || [""])[0].length; + segments.push({ start: bodyStart + segmentStart + left, end: bodyStart + index - right }); + segmentStart = index + 1; + } + } + return segments[cellIndex] || { start: bodyStart, end: bodyStart }; + } + + function editableSourceBounds(target) { + const lineIndex = Number(target?.dataset.sourceLine) - 1; + const line = sourceLineBounds(lineIndex); + if (!line) return null; + if (target.dataset.tableCell !== undefined) { + const cell = tableCellBounds(line.text, Number(target.dataset.tableCell)); + return { start: line.start + cell.start, end: line.start + cell.end, line }; + } + const prefix = target.dataset.rawSourceEdit === "true" ? "" : (target.dataset.sourcePrefix || ""); + const suffix = target.dataset.rawSourceEdit === "true" ? "" : (target.dataset.sourceSuffix || ""); + return { + start: Math.min(line.end, line.start + prefix.length), + end: Math.max(line.start, line.end - suffix.length), + line, + }; + } + + function editableAtBoundary(container, offset, preferPrevious = false) { + const element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement; + const direct = element?.closest?.(".preview-editable"); + if (direct && preview.contains(direct)) return direct; + if (container.nodeType !== Node.ELEMENT_NODE) return null; + const children = [...container.childNodes]; + const candidate = preferPrevious ? children[Math.max(0, offset - 1)] : children[Math.min(offset, children.length - 1)]; + const candidates = candidate ? [candidate] : []; + for (const node of candidates) { + const candidateElement = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + if (candidateElement?.matches?.(".preview-editable")) return candidateElement; + const nested = candidateElement?.querySelectorAll?.(".preview-editable"); + if (nested?.length) return preferPrevious ? nested[nested.length - 1] : nested[0]; + } + const source = element?.closest?.(".preview-source-line"); + const nested = source?.querySelectorAll?.(".preview-editable"); + return nested?.length ? (preferPrevious ? nested[nested.length - 1] : nested[0]) : null; + } + + function previewPointSourceOffset(container, offset, preferPrevious = false) { + const target = editableAtBoundary(container, offset, preferPrevious); + const bounds = editableSourceBounds(target); + if (!target || !bounds) return null; + let bodyOffset; + if (target === container || target.contains(container)) bodyOffset = markdownPointOffset(target, container, offset); + else bodyOffset = preferPrevious ? bounds.end - bounds.start : 0; + return { + offset: Math.max(bounds.start, Math.min(bounds.end, bounds.start + bodyOffset)), + target, + bounds, + }; + } + + function previewSelectionSourceRange({ expandWholeLines = false } = {}) { + const selection = window.getSelection(); + if (!selection?.rangeCount || selection.isCollapsed) return null; + const range = selection.getRangeAt(0); + const startInside = range.startContainer === preview || preview.contains(range.startContainer); + const endInside = range.endContainer === preview || preview.contains(range.endContainer); + if (!startInside || !endInside) return null; + const startPoint = previewPointSourceOffset(range.startContainer, range.startOffset, false); + const endPoint = previewPointSourceOffset(range.endContainer, range.endOffset, true); + if (!startPoint || !endPoint) return null; + let start = Math.min(startPoint.offset, endPoint.offset); + let end = Math.max(startPoint.offset, endPoint.offset); + if (expandWholeLines && startPoint.bounds.line.lineIndex !== endPoint.bounds.line.lineIndex) { + if (start === startPoint.bounds.start) start = startPoint.bounds.line.start; + if (end === endPoint.bounds.end) { + end = endPoint.bounds.line.end; + if (end < editor.value.length && editor.value[end] === "\n") end++; + } + } + return { start, end }; + } + + function deletePreviewSelection() { + const range = previewSelectionSourceRange({ expandWholeLines: true }); + if (!range || range.end <= range.start) return false; + editor.setRangeText("", range.start, range.end, "end"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + return true; + } + + function previewShortcutFormat(event) { + const primary = event.ctrlKey || event.metaKey; + if (primary && !event.shiftKey && event.key.toLowerCase() === "b") return "bold"; + if (primary && !event.shiftKey && event.key.toLowerCase() === "i") return "italic"; + if (primary && event.shiftKey && event.key.toLowerCase() === "x") return "strike"; + if (primary && !event.shiftKey && event.key.toLowerCase() === "k") return "link"; + if (primary && event.shiftKey && event.key === "7") return "number"; + if (primary && event.shiftKey && event.key === "8") return "bullet"; + if (primary && event.shiftKey && event.key === "9") return "task"; + if (event.altKey && /^[1-4]$/.test(event.key)) return `heading${event.key}`; + return null; + } + function syncPreviewScroll() { if (activeView() !== "split") return; const editorRange = Math.max(0, editor.scrollHeight - editor.clientHeight); @@ -219,7 +437,7 @@ export function startNoteEditor(adapter) { const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0; preview.scrollTop = ratio * previewRange; } - function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `
${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); } + function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `
${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); } function activeView() { return compactLayoutQuery.matches ? compactView : uiState.view; } @@ -242,7 +460,48 @@ export function startNoteEditor(adapter) { if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); } - function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; } + function previewEditSnapshot() { + const target = document.activeElement?.closest?.('.preview-editable[contenteditable="true"]'); + if (!target || !preview.contains(target)) return null; + return { + index: [...preview.querySelectorAll(".preview-editable")].indexOf(target), + offset: previewCaretOffset(target), + raw: target.dataset.rawSourceEdit === "true", + }; + } + + function restorePreviewEdit(snapshot) { + if (!snapshot || snapshot.index < 0) return; + const target = preview.querySelectorAll(".preview-editable")[snapshot.index]; + if (!target) return; + activatePreviewEdit(target, snapshot.offset); + if (snapshot.raw) editRawPreviewLine(target); + } + + function applyRemote(content, ownerMap) { + if (content === editor.value) { + if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); + previousContent = content; + renderGutter(); + return; + } + const previewSnapshot = previewEditSnapshot(); + const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; + const mapped = mapSelectionThroughEdit(previous, content, start, end); + applyingRemote = true; + editor.value = content; + authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); + previousContent = content; + editor.setSelectionRange(mapped.start, mapped.end, direction); + editor.scrollTop = scrollTop; + editor.scrollLeft = scrollLeft; + applyingRemote = false; + render(); + editor.scrollTop = scrollTop; + editor.scrollLeft = scrollLeft; + syncEditorLayers(); + restorePreviewEdit(previewSnapshot); + } const { loadFiles } = bindNoteFiles({ editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files), @@ -339,23 +598,7 @@ export function startNoteEditor(adapter) { clearUnread(); requestAnimationFrame(() => chatInput.focus()); }); - const mobileTimeButton = document.querySelector("#mobile-time-button"); - const mobileTimePreview = document.querySelector("#mobile-time-preview"); - let mobileTimeTimer = null; - function updateMobileTime() { - if (!mobileTimePreview) return; - mobileTimePreview.textContent = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date()); - } - mobileTimeButton?.addEventListener("click", event => { - event.stopPropagation(); - const willOpen = mobileTimePreview.hidden; - mobileTimePreview.hidden = !willOpen; - clearInterval(mobileTimeTimer); - mobileTimeTimer = null; - if (willOpen) { updateMobileTime(); mobileTimeTimer = setInterval(updateMobileTime, 1000); } - }); document.addEventListener("click", event => { - if (!event.target.closest("#mobile-time-button, #mobile-time-preview") && mobileTimePreview) { mobileTimePreview.hidden = true; clearInterval(mobileTimeTimer); mobileTimeTimer = null; } if (roomDetails.classList.contains("is-mobile-open") && !event.target.closest("#mobile-chat-button, #room-details")) { roomDetails.open = false; roomDetails.classList.remove("is-mobile-open"); } }); const mobileBubble = document.querySelector("#mobile-editor-bubble"); @@ -407,13 +650,19 @@ export function startNoteEditor(adapter) { const value = markdownFromPreview(target); const lines = editor.value.split("\n"); if (target.dataset.rawSourceEdit === "true") { - if (value === lines[lineIndex]) return; + if (value === lines[lineIndex]) { + render(); + return; + } lines[lineIndex] = value.replace(/\n/g, ""); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); return; } - if (!focusNextLine && value === target.dataset.originalValue) return; + if (!focusNextLine && value === target.dataset.originalValue) { + deactivatePreviewEdit(target); + return; + } if (focusNextLine) cancelledPreviewEdits.add(target); if (target.dataset.tableCell !== undefined) { lines[lineIndex] = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value.replace(/\n/g, " ")); @@ -429,8 +678,7 @@ export function startNoteEditor(adapter) { if (focusNextLine) { const nextLine = lineIndex + Math.max(2, value.split("\n").length); const next = preview.querySelector(`[data-source-line="${nextLine}"].preview-editable`); - next?.focus(); - if (next) placePreviewCaret(next, 0); + if (next) activatePreviewEdit(next, 0); } } function editRawPreviewLine(target) { @@ -438,6 +686,9 @@ export function startNoteEditor(adapter) { const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; const caretOffset = Math.min(previewCaretOffset(target), lines[lineIndex].length); + target.setAttribute("contenteditable", "true"); + target.setAttribute("spellcheck", "true"); + target.classList.add("preview-editable--active"); target.dataset.rawSourceEdit = "true"; target.dataset.originalValue = lines[lineIndex]; target.textContent = lines[lineIndex]; @@ -470,8 +721,7 @@ export function startNoteEditor(adapter) { const restoreFocus = () => { const next = preview.querySelector(`[data-source-line="${sourceLine}"].preview-editable`); if (!next) return; - next.focus({ preventScroll: true }); - placePreviewCaret(next, 0); + activatePreviewEdit(next, 0); }; restoreFocus(); queueMicrotask(() => { @@ -501,7 +751,115 @@ export function startNoteEditor(adapter) { saveEditorSettingsButton.disabled = !info?.can_save_editor_settings; } }); - window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("focusin", event => { const target = event.target.closest(".preview-editable"); if (!target) return; target.dataset.originalHtml = target.innerHTML; target.dataset.originalValue = markdownFromPreview(target); }); preview.addEventListener("beforeinput", event => { if (!event.target.closest(".preview-editable")) return; if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault(); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (target.dataset.rawSourceEdit === "true") { cancelledPreviewEdits.add(target); render(); } else editRawPreviewLine(target); return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); if (event.altKey) insertPreviewLineBreak(target); else target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (cancelledPreviewEdits.has(target)) { cancelledPreviewEdits.delete(target); return; } commitPreviewEdit(target); }, { capture: true }); + window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); + window.addEventListener("rustpad:urlchange", updateAddressLabel); + document.querySelector("#copy-link").addEventListener("click", async () => { + try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } + catch (error) { toast(error.message); } + }); + + let pendingPreviewFormatRange = null; + document.querySelectorAll("[data-format]").forEach(button => { + button.addEventListener("pointerdown", event => { + pendingPreviewFormatRange = previewSelectionSourceRange(); + if (pendingPreviewFormatRange) event.preventDefault(); + }); + button.addEventListener("click", () => { + const range = pendingPreviewFormatRange || previewSelectionSourceRange(); + pendingPreviewFormatRange = null; + if (range) editor.setSelectionRange(range.start, range.end); + applyFormat(editor, button.dataset.format); + button.closest("details")?.removeAttribute("open"); + }); + }); + bindFormatShortcuts(editor); + bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); + document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); + document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); + + preview.addEventListener("click", event => { + const target = event.target.closest(".preview-editable"); + if (!target || event.target.closest("a, button, input, img")) return; + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) return; + const offset = previewCaretOffset(target); + activatePreviewEdit(target, offset); + }); + preview.addEventListener("change", event => { + const checkbox = event.target.closest(".task-checkbox"); + if (!checkbox) return; + const lineIndex = Number(checkbox.dataset.sourceLine) - 1; + const lines = editor.value.split("\n"); + if (lineIndex < 0 || lineIndex >= lines.length) return; + lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); + editor.value = lines.join("\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }); + preview.addEventListener("focusin", event => { + const target = event.target.closest('.preview-editable[contenteditable="true"]'); + if (!target) return; + target.dataset.originalHtml = target.innerHTML; + target.dataset.originalValue = markdownFromPreview(target); + }); + preview.addEventListener("beforeinput", event => { + if (!event.target.closest('.preview-editable[contenteditable="true"]')) return; + if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault(); + }); + preview.addEventListener("keydown", event => { + const target = event.target.closest('.preview-editable[contenteditable="true"]'); + if (!target) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (target.dataset.rawSourceEdit === "true") { + cancelledPreviewEdits.add(target); + render(); + } else editRawPreviewLine(target); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (event.altKey) insertPreviewLineBreak(target); + else target.blur(); + return; + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); + } + }); + preview.addEventListener("blur", event => { + const target = event.target.closest(".preview-editable"); + if (!target) return; + if (cancelledPreviewEdits.has(target)) { + cancelledPreviewEdits.delete(target); + return; + } + commitPreviewEdit(target); + }, { capture: true }); + + document.addEventListener("keydown", event => { + if (document.activeElement?.closest?.('.preview-editable[contenteditable="true"]')) return; + const range = previewSelectionSourceRange(); + if (!range) return; + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + deletePreviewSelection(); + return; + } + const format = previewShortcutFormat(event); + if (!format) return; + event.preventDefault(); + editor.setSelectionRange(range.start, range.end); + applyFormat(editor, format); + }); + document.addEventListener("cut", event => { + const range = previewSelectionSourceRange(); + if (!range) return; + event.preventDefault(); + event.clipboardData?.setData("text/plain", window.getSelection()?.toString() || ""); + deletePreviewSelection(); + }); const publishPageButton = document.querySelector("#publish-page"); function updatePageControls() { const enabled = publicPageEnabled.checked; @@ -553,7 +911,23 @@ export function startNoteEditor(adapter) { if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); toast("Global profile color restored"); }); - editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); syncPreviewScroll(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); }); + editor.addEventListener("keydown", continueIndentation); + editor.addEventListener("scroll", () => { + syncEditorLayers(); + syncPreviewScroll(); + }); + editor.addEventListener("input", () => { + const nextContent = editor.value; + authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); + authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); + authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); + previousContent = nextContent; + render(); + if (applyingRemote) return; + clearTimeout(saveTimer); + document.querySelector("#save-state").textContent = "Saving…"; + saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); + }); passwordDialog.addEventListener("cancel", event => { if (info?.protected && !resourceUnlocked) { event.preventDefault(); diff --git a/static/note.html b/static/note.html index fb5e292..640f4c9 100644 --- a/static/note.html +++ b/static/note.html @@ -20,19 +20,33 @@

- + type="color" aria-label="Override color for this note">
-
Page settings
+
+
+ Page settings +
+
+
@@ -49,7 +63,8 @@
😀 Emoji
- +
@@ -101,10 +116,24 @@
-
Editor
+
Editor +
+
+
+
+
-
@@ -114,9 +143,12 @@
-
@@ -185,8 +218,8 @@ guest
${inline(cell)}${inline(cell)}
${inline(cells[i] || "")}${inline(cells[i] || "")}