fixes in styles
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.24"
|
||||
version = "0.2.27"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.24"
|
||||
version = "0.2.27"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
+118
-62
@@ -152,6 +152,8 @@
|
||||
--overlay-82: rgba(4, 6, 9, .82);
|
||||
--glass-92: rgba(13, 16, 21, .92);
|
||||
--glass-toolbar: rgba(17, 21, 28, .88);
|
||||
--top-header-height: 54px;
|
||||
--top-header-control-height: 34px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
@@ -362,7 +364,7 @@ a {
|
||||
|
||||
.site-header {
|
||||
width: min(720px, calc(100% - 32px));
|
||||
min-height: 64px;
|
||||
min-height: var(--top-header-height);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -550,12 +552,29 @@ textarea:focus {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 70px;
|
||||
padding: 0 20px;
|
||||
min-height: var(--top-header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
|
||||
.pad-page .app-header {
|
||||
min-height: var(--top-header-height);
|
||||
padding-block: 6px;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.app-header .secondary-button,
|
||||
.public-header .secondary-button,
|
||||
.pad-page .header-actions>.secondary-button,
|
||||
.pad-page .page-settings>summary.secondary-button {
|
||||
min-height: var(--top-header-control-height);
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.app-header__main,
|
||||
.header-actions {
|
||||
display: flex;
|
||||
@@ -566,7 +585,6 @@ textarea:focus {
|
||||
|
||||
.header-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
@@ -1169,7 +1187,7 @@ dialog::backdrop {
|
||||
.app-header {
|
||||
align-items: flex-start;
|
||||
min-height: auto;
|
||||
padding: 12px 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.app-header__main,
|
||||
@@ -1222,6 +1240,12 @@ dialog::backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header .secondary-button,
|
||||
.public-header .secondary-button,
|
||||
.pad-page .page-settings>summary.secondary-button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -1244,15 +1268,6 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-toggle {
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.markdown-toggle.active {
|
||||
background: var(--surface-3) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.preview--raw {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -1621,7 +1636,7 @@ dialog::backdrop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 64px;
|
||||
min-height: var(--top-header-height);
|
||||
padding: 0 max(20px, calc((100vw - 900px) / 2));
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--glass-92);
|
||||
@@ -2880,15 +2895,20 @@ dialog::backdrop {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-more>summary {
|
||||
display: flex;
|
||||
.markdown-more>summary,
|
||||
.pad-page .toolbar-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -4854,6 +4874,37 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the view controls visible on Full HD displays using 125% scaling.
|
||||
Only the formatting group becomes horizontally scrollable. */
|
||||
@media (min-width: 1500px) and (max-width: 1699px) {
|
||||
.pad-page .editor-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pad-page .toolbar-group {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.pad-page .toolbar-group:has(>.emoji-picker[open]),
|
||||
.pad-page .toolbar-group:has(>.markdown-more[open]) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pad-page :is(.editor-controls, .toolbar-action, .line-toggle, .view-switch) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pad-page .toolbar-fill {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive note editor -------------------------------------------------- */
|
||||
@media (max-width: 1499px) {
|
||||
.pad-page {
|
||||
@@ -4869,7 +4920,7 @@ dialog::backdrop {
|
||||
.pad-page .app-header {
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.pad-page .app-header__main {
|
||||
@@ -4893,9 +4944,9 @@ dialog::backdrop {
|
||||
|
||||
.pad-page .header-menu-toggle {
|
||||
display: inline-flex;
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
@@ -4994,7 +5045,7 @@ dialog::backdrop {
|
||||
|
||||
.pad-page .editor-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
@@ -5012,19 +5063,41 @@ dialog::backdrop {
|
||||
.pad-page .editor-controls,
|
||||
.pad-page .toolbar-action,
|
||||
.pad-page .line-toggle,
|
||||
.pad-page .toolbar-fill,
|
||||
.pad-page #mode-toggle {
|
||||
.pad-page .toolbar-fill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pad-page #mode-toggle {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pad-page .toolbar-group:has(>.emoji-picker[open]),
|
||||
.pad-page .toolbar-group:has(>.markdown-more[open]) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pad-page .view-switch {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
flex: 0 0 auto;
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
|
||||
.pad-page .view-switch button {
|
||||
min-width: 56px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pad-page textarea,
|
||||
.pad-page .preview,
|
||||
.pad-page .authorship-layer {
|
||||
@@ -5055,19 +5128,25 @@ dialog::backdrop {
|
||||
}
|
||||
|
||||
.pad-page .editor-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pad-page #mode-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pad-page .view-switch {
|
||||
position: static;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
justify-self: end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pad-page .view-switch button {
|
||||
width: 100%;
|
||||
width: auto;
|
||||
min-width: 62px;
|
||||
}
|
||||
|
||||
.pad-page .view-switch button[data-view="split"] {
|
||||
@@ -5159,12 +5238,21 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
@media (min-width: 1921px) {
|
||||
.pad-page .workspace.view-split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.document-read-only .preview-editable {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.document-read-only .task-checkbox {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Per-note author color override */
|
||||
.use-global-color {
|
||||
display: inline-grid;
|
||||
@@ -5719,38 +5807,6 @@ dialog::backdrop {
|
||||
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, var(--surface-strong-2));
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 var(--wash-medium);
|
||||
}
|
||||
|
||||
.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. */
|
||||
.page-settings-menu {
|
||||
background: var(--surface-card);
|
||||
|
||||
+4
-3
@@ -122,7 +122,7 @@
|
||||
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
||||
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
|
||||
Line links</label>
|
||||
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
|
||||
<div class="toolbar-fill"></div><button id="mode-toggle" class="toolbar-action active"
|
||||
aria-pressed="true">Markdown</button>
|
||||
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
||||
class="active">Split</button><button data-view="preview">Preview</button></div>
|
||||
@@ -153,7 +153,7 @@
|
||||
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
||||
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
||||
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
|
||||
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
|
||||
wrap="off" placeholder="Start writing…" spellcheck="false" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-column">
|
||||
@@ -251,7 +251,8 @@
|
||||
<kbd>Ctrl/Cmd+Z</kbd><span>Undo last
|
||||
change</span><kbd>Ctrl/Cmd+Shift+Z</kbd><span>Redo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
||||
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span>__EXTRA_SHORTCUTS__
|
||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span><kbd>Tab</kbd><span>Indent by 2
|
||||
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span>__EXTRA_SHORTCUTS__
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -109,3 +109,76 @@ export function bindFormatShortcuts(editor) {
|
||||
applyFormat(editor, format);
|
||||
});
|
||||
}
|
||||
|
||||
function selectedLineRange(value, start, end) {
|
||||
const blockStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
||||
let blockEnd = value.indexOf("\n", end);
|
||||
if (blockEnd < 0) blockEnd = value.length;
|
||||
if (end > start && value[end - 1] === "\n") blockEnd = end - 1;
|
||||
return { blockStart, blockEnd };
|
||||
}
|
||||
|
||||
function mapOffsetThroughEdits(offset, edits) {
|
||||
let delta = 0;
|
||||
for (const edit of edits) {
|
||||
if (offset < edit.start) break;
|
||||
const editEnd = edit.start + edit.remove;
|
||||
if (offset <= editEnd) return edit.start + delta + edit.insert.length;
|
||||
delta += edit.insert.length - edit.remove;
|
||||
}
|
||||
return offset + delta;
|
||||
}
|
||||
|
||||
export function applyIndentation(editor, { outdent = false, size = 2 } = {}) {
|
||||
if (!editor || editor.readOnly) return false;
|
||||
const indent = " ".repeat(Math.max(1, Number(size) || 2));
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const direction = editor.selectionDirection || "none";
|
||||
|
||||
if (!outdent && start === end) {
|
||||
editor.setRangeText(indent, start, end, "end");
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = editor.value;
|
||||
const { blockStart, blockEnd } = selectedLineRange(value, start, end);
|
||||
const block = value.slice(blockStart, blockEnd);
|
||||
const lineStarts = [0];
|
||||
for (let index = 0; index < block.length; index++) {
|
||||
if (block[index] === "\n") lineStarts.push(index + 1);
|
||||
}
|
||||
|
||||
const edits = lineStarts.map(relativeStart => {
|
||||
const absoluteStart = blockStart + relativeStart;
|
||||
if (!outdent) return { start: absoluteStart, remove: 0, insert: indent };
|
||||
const line = value.slice(absoluteStart, value.indexOf("\n", absoluteStart) < 0 ? value.length : value.indexOf("\n", absoluteStart));
|
||||
const removable = line.startsWith("\t") ? 1 : Math.min(indent.length, (line.match(/^ +/) || [""])[0].length);
|
||||
return { start: absoluteStart, remove: removable, insert: "" };
|
||||
}).filter(edit => edit.remove || edit.insert);
|
||||
|
||||
if (!edits.length) return false;
|
||||
let replacement = block;
|
||||
for (let index = edits.length - 1; index >= 0; index--) {
|
||||
const edit = edits[index];
|
||||
const relativeStart = edit.start - blockStart;
|
||||
replacement = `${replacement.slice(0, relativeStart)}${edit.insert}${replacement.slice(relativeStart + edit.remove)}`;
|
||||
}
|
||||
|
||||
const nextStart = mapOffsetThroughEdits(start, edits);
|
||||
const nextEnd = mapOffsetThroughEdits(end, edits);
|
||||
editor.setRangeText(replacement, blockStart, blockEnd, "start");
|
||||
editor.setSelectionRange(nextStart, nextEnd, direction);
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bindIndentationShortcuts(editor, { size = 2 } = {}) {
|
||||
editor.addEventListener("keydown", event => {
|
||||
if (event.key !== "Tab" || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (editor.readOnly) return;
|
||||
event.preventDefault();
|
||||
applyIndentation(editor, { outdent: event.shiftKey, size });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export function alignPreviewLineNumbers(root) {
|
||||
const targetLeft = parseFloat(styles.paddingLeft || "0") - 50;
|
||||
const rootLeft = root.getBoundingClientRect().left;
|
||||
root.querySelectorAll(".preview-source-line").forEach(line => {
|
||||
const lineLeft = line.getBoundingClientRect().left - rootLeft;
|
||||
const lineLeft = line.getBoundingClientRect().left - rootLeft + root.scrollLeft;
|
||||
line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`);
|
||||
});
|
||||
}
|
||||
|
||||
+170
-21
@@ -13,7 +13,7 @@ installGlobalDiagnostics();
|
||||
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||
import { bindEmojiPicker } from "@rustpad/emoji-picker";
|
||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
||||
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||
@@ -27,12 +27,14 @@ export function startNoteEditor(adapter) {
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
|
||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
|
||||
const saveState = document.querySelector("#save-state");
|
||||
editor.readOnly = true;
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
|
||||
const shareToken = new URLSearchParams(location.search).get("share");
|
||||
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
|
||||
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 = "";
|
||||
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 = "", lastServerContent = "", lastServerOwnerMap = "[]", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
const editHistory = {
|
||||
entries: [],
|
||||
@@ -95,6 +97,7 @@ export function startNoteEditor(adapter) {
|
||||
this.lastRecordedAt = now;
|
||||
},
|
||||
move(offset) {
|
||||
if (!canEditDocument()) return false;
|
||||
const nextIndex = this.index + offset;
|
||||
if (nextIndex < 0 || nextIndex >= this.entries.length) return false;
|
||||
this.index = nextIndex;
|
||||
@@ -109,6 +112,7 @@ export function startNoteEditor(adapter) {
|
||||
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
||||
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
|
||||
let compactView = uiState.view === "preview" ? "preview" : "edit";
|
||||
let renderedView = singlePaneQuery.matches ? compactView : uiState.view;
|
||||
let refreshFilesForAliases = () => { };
|
||||
let aliasRefreshTimer = 0;
|
||||
let lastUnresolvedAliasKey = "";
|
||||
@@ -164,6 +168,16 @@ export function startNoteEditor(adapter) {
|
||||
const controls = document.querySelector(".authorship-controls");
|
||||
if (controls) controls.title = canManage ? "Global authorship settings" : "Only the owner can change authorship settings";
|
||||
}
|
||||
function setDocumentReadOnly(readOnly, label = "Read only") {
|
||||
editor.readOnly = readOnly;
|
||||
document.body.classList.toggle("document-read-only", readOnly);
|
||||
if (!readOnly) return;
|
||||
clearTimeout(saveTimer);
|
||||
saveState.textContent = label;
|
||||
const activePreviewEdit = preview.querySelector('.preview-editable[contenteditable="true"]');
|
||||
if (activePreviewEdit) deactivatePreviewEdit(activePreviewEdit);
|
||||
}
|
||||
function canEditDocument() { return !editor.readOnly; }
|
||||
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
|
||||
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
|
||||
function ownerName(owner) { return ownerParts(owner).name; }
|
||||
@@ -517,7 +531,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function activatePreviewEdit(target, offset = null) {
|
||||
if (!target) return;
|
||||
if (!target || !canEditDocument()) return;
|
||||
const active = preview.querySelector('.preview-editable[contenteditable="true"]');
|
||||
if (active && active !== target) {
|
||||
const targetIndex = [...preview.querySelectorAll(".preview-editable")].indexOf(target);
|
||||
@@ -667,6 +681,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function deletePreviewSelection() {
|
||||
if (!canEditDocument()) return false;
|
||||
const range = previewSelectionSourceRange({ expandWholeLines: true });
|
||||
if (!range || range.end <= range.start) return false;
|
||||
editor.setRangeText("", range.start, range.end, "end");
|
||||
@@ -687,21 +702,129 @@ export function startNoteEditor(adapter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function editorLineHeight() {
|
||||
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight);
|
||||
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
|
||||
}
|
||||
|
||||
function scrollRatio(element) {
|
||||
const range = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
return range > 0 ? element.scrollTop / range : 0;
|
||||
}
|
||||
|
||||
function editorScrollAnchor() {
|
||||
return {
|
||||
sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(),
|
||||
ratio: scrollRatio(editor),
|
||||
};
|
||||
}
|
||||
|
||||
function previewLinePositions() {
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const positions = [];
|
||||
preview.querySelectorAll(".preview-source-line[data-source-line]").forEach(node => {
|
||||
const sourceLine = Number(node.dataset.sourceLine);
|
||||
if (!Number.isFinite(sourceLine) || sourceLine < 1 || !node.getClientRects().length) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
positions.push({
|
||||
node,
|
||||
sourceLine,
|
||||
top: rect.top - previewRect.top + preview.scrollTop,
|
||||
bottom: rect.bottom - previewRect.top + preview.scrollTop,
|
||||
});
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function previewScrollAnchor() {
|
||||
const positions = previewLinePositions();
|
||||
if (!positions.length) return { sourceLine: null, ratio: scrollRatio(preview) };
|
||||
const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
|
||||
const viewportTop = preview.scrollTop + paddingTop;
|
||||
let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5);
|
||||
if (currentIndex < 0) currentIndex = positions.length - 1;
|
||||
const current = positions[currentIndex];
|
||||
const next = positions.slice(currentIndex + 1).find(position => position.sourceLine > current.sourceLine);
|
||||
const progress = Math.max(0, Math.min(1, (viewportTop - current.top) / Math.max(1, current.bottom - current.top)));
|
||||
const sourceLine = next
|
||||
? current.sourceLine + progress * (next.sourceLine - current.sourceLine)
|
||||
: current.sourceLine;
|
||||
return { sourceLine, ratio: scrollRatio(preview) };
|
||||
}
|
||||
|
||||
function activeScrollAnchor(view = renderedView) {
|
||||
return view === "preview" ? previewScrollAnchor() : editorScrollAnchor();
|
||||
}
|
||||
|
||||
function setScrollRatio(element, ratio) {
|
||||
const range = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
element.scrollTop = Math.max(0, Math.min(range, ratio * range));
|
||||
}
|
||||
|
||||
function scrollEditorToAnchor(anchor) {
|
||||
if (Number.isFinite(anchor?.sourceLine)) {
|
||||
const range = Math.max(0, editor.scrollHeight - editor.clientHeight);
|
||||
editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight()));
|
||||
} else setScrollRatio(editor, anchor?.ratio || 0);
|
||||
syncEditorLayers();
|
||||
}
|
||||
|
||||
function scrollPreviewToAnchor(anchor) {
|
||||
const positions = previewLinePositions();
|
||||
if (!Number.isFinite(anchor?.sourceLine) || !positions.length) {
|
||||
setScrollRatio(preview, anchor?.ratio || 0);
|
||||
return;
|
||||
}
|
||||
const sourceLine = Math.max(1, anchor.sourceLine);
|
||||
const exact = positions.find(position => position.sourceLine === sourceLine);
|
||||
let targetTop = exact?.top;
|
||||
if (!Number.isFinite(targetTop)) {
|
||||
const before = [...positions].reverse().find(position => position.sourceLine <= sourceLine);
|
||||
const after = positions.find(position => position.sourceLine >= sourceLine);
|
||||
if (before && after && after.sourceLine > before.sourceLine) {
|
||||
const progress = (sourceLine - before.sourceLine) / (after.sourceLine - before.sourceLine);
|
||||
targetTop = before.top + progress * (after.top - before.top);
|
||||
} else targetTop = (before || after)?.top;
|
||||
}
|
||||
if (!Number.isFinite(targetTop)) {
|
||||
setScrollRatio(preview, anchor.ratio || 0);
|
||||
return;
|
||||
}
|
||||
const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
|
||||
const range = Math.max(0, preview.scrollHeight - preview.clientHeight);
|
||||
preview.scrollTop = Math.max(0, Math.min(range, targetTop - paddingTop));
|
||||
}
|
||||
|
||||
function restoreScrollAnchor(anchor) {
|
||||
requestAnimationFrame(() => {
|
||||
const view = activeView();
|
||||
if (view === "edit" || view === "split") scrollEditorToAnchor(anchor);
|
||||
if (view === "preview" || view === "split") scrollPreviewToAnchor(anchor);
|
||||
});
|
||||
}
|
||||
|
||||
function applyUiPreservingScroll(options) {
|
||||
const anchor = activeScrollAnchor();
|
||||
applyUi(options);
|
||||
restoreScrollAnchor(anchor);
|
||||
}
|
||||
|
||||
function syncPreviewScroll() {
|
||||
if (activeView() !== "split") return;
|
||||
const editorRange = Math.max(0, editor.scrollHeight - editor.clientHeight);
|
||||
const previewRange = Math.max(0, preview.scrollHeight - preview.clientHeight);
|
||||
const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0;
|
||||
preview.scrollTop = ratio * previewRange;
|
||||
scrollPreviewToAnchor(editorScrollAnchor());
|
||||
}
|
||||
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(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) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } 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 scheduleDocumentSave() {
|
||||
clearTimeout(saveTimer);
|
||||
document.querySelector("#save-state").textContent = "Saving…";
|
||||
if (!canEditDocument()) {
|
||||
saveState.textContent = "Read only";
|
||||
return;
|
||||
}
|
||||
saveState.textContent = "Saving…";
|
||||
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
|
||||
}
|
||||
function restoreHistorySnapshot(snapshot) {
|
||||
if (!snapshot) return;
|
||||
if (!snapshot || !canEditDocument()) return;
|
||||
const maxOffset = snapshot.content.length;
|
||||
const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset);
|
||||
const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset);
|
||||
@@ -724,6 +847,7 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
function applyUi({ write = false, replace = false } = {}) {
|
||||
const view = activeView();
|
||||
renderedView = view;
|
||||
editorWorkspace.className = `workspace view-${view} editor-workspace-font-${fontFamily.value}`;
|
||||
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
|
||||
document.body.classList.toggle("compact-editor", compactToggle.checked);
|
||||
@@ -735,6 +859,7 @@ export function startNoteEditor(adapter) {
|
||||
});
|
||||
const markdown = uiState.mode === "markdown";
|
||||
modeToggle.classList.toggle("active", markdown);
|
||||
modeToggle.setAttribute("aria-pressed", String(markdown));
|
||||
modeToggle.textContent = markdown ? "Markdown" : "Text";
|
||||
render();
|
||||
if (write) writeEditorState(uiState, { replace });
|
||||
@@ -759,6 +884,8 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function applyRemote(content, ownerMap) {
|
||||
lastServerContent = content;
|
||||
if (ownerMap != null) lastServerOwnerMap = ownerMap;
|
||||
if (content === editor.value) {
|
||||
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
|
||||
previousContent = content;
|
||||
@@ -809,14 +936,15 @@ export function startNoteEditor(adapter) {
|
||||
resourceUnlocked = true;
|
||||
if (passwordDialog.open) passwordDialog.close();
|
||||
const readOnly = message.access_level === "read_only";
|
||||
editor.readOnly = readOnly;
|
||||
setDocumentReadOnly(readOnly);
|
||||
accessLevel.textContent = readOnly ? "Access: read only" : "Access: full";
|
||||
applyRemote(message.content, message.owner_map);
|
||||
saveState.textContent = readOnly ? "Read only" : "Changes are saved automatically";
|
||||
if (!readOnly) editor.focus();
|
||||
},
|
||||
onDocument: message => {
|
||||
applyRemote(message.content, message.owner_map);
|
||||
document.querySelector("#save-state").textContent = `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
|
||||
saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
|
||||
},
|
||||
onPresence: updatePresence,
|
||||
onLatency: updateLatency,
|
||||
@@ -829,9 +957,13 @@ export function startNoteEditor(adapter) {
|
||||
if (/read-only access/i.test(message)) {
|
||||
toast(friendly);
|
||||
accessLevel.textContent = "Access: read only";
|
||||
editor.readOnly = true;
|
||||
setDocumentReadOnly(true, "Read only — changes not saved");
|
||||
applyRemote(lastServerContent, lastServerOwnerMap);
|
||||
queueMicrotask(connect);
|
||||
return;
|
||||
}
|
||||
clearTimeout(saveTimer);
|
||||
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
|
||||
if (/nickname|session|account/i.test(message)) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
} else if (info?.protected && !passwordDialog.open) {
|
||||
@@ -886,16 +1018,19 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => {
|
||||
const anchor = activeScrollAnchor();
|
||||
if (singlePaneQuery.matches) {
|
||||
compactView = button.dataset.view === "preview" ? "preview" : "edit";
|
||||
applyUi();
|
||||
restoreScrollAnchor(anchor);
|
||||
return;
|
||||
}
|
||||
uiState = { ...uiState, view: button.dataset.view };
|
||||
applyUi({ write: true });
|
||||
restoreScrollAnchor(anchor);
|
||||
}));
|
||||
singlePaneQuery.addEventListener("change", () => applyUi());
|
||||
compactLayoutQuery.addEventListener("change", () => applyUi());
|
||||
singlePaneQuery.addEventListener("change", () => applyUiPreservingScroll());
|
||||
compactLayoutQuery.addEventListener("change", () => applyUiPreservingScroll());
|
||||
const headerMenuToggle = document.querySelector("#header-menu-toggle");
|
||||
const headerActions = document.querySelector("#header-actions");
|
||||
const setHeaderMenuOpen = open => {
|
||||
@@ -928,13 +1063,13 @@ export function startNoteEditor(adapter) {
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
|
||||
});
|
||||
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); });
|
||||
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUiPreservingScroll({ write: true }); });
|
||||
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
|
||||
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
|
||||
const mirrorMobileControl = (mobileControl, desktopControl) => mobileControl?.addEventListener("change", () => {
|
||||
if (desktopControl instanceof HTMLInputElement && desktopControl.type === "checkbox") desktopControl.checked = mobileControl.checked;
|
||||
else desktopControl.value = mobileControl.value;
|
||||
@@ -1017,6 +1152,7 @@ export function startNoteEditor(adapter) {
|
||||
window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); });
|
||||
const cancelledPreviewEdits = new WeakSet();
|
||||
function commitPreviewEdit(target, { focusNextLine = false } = {}) {
|
||||
if (!canEditDocument()) { render(); return; }
|
||||
const lineIndex = Number(target.dataset.sourceLine) - 1;
|
||||
if (lineIndex < 0) return;
|
||||
const value = markdownFromPreview(target);
|
||||
@@ -1054,6 +1190,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
function editRawPreviewLine(target) {
|
||||
if (!canEditDocument()) return;
|
||||
const lineIndex = Number(target.dataset.sourceLine) - 1;
|
||||
const lines = editor.value.split("\n");
|
||||
if (lineIndex < 0 || lineIndex >= lines.length) return;
|
||||
@@ -1070,6 +1207,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function insertPreviewLineBreak(target) {
|
||||
if (!canEditDocument()) return;
|
||||
const lineIndex = Number(target.dataset.sourceLine) - 1;
|
||||
if (lineIndex < 0) return;
|
||||
const lines = editor.value.split("\n");
|
||||
@@ -1162,7 +1300,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); });
|
||||
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(revealLinkedLine); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); });
|
||||
window.addEventListener("rustpad:urlchange", updateAddressLabel);
|
||||
gutter.addEventListener("click", async event => {
|
||||
@@ -1230,6 +1368,7 @@ export function startNoteEditor(adapter) {
|
||||
else editHistory.undo();
|
||||
});
|
||||
bindFormatShortcuts(editor);
|
||||
bindIndentationShortcuts(editor, { size: 2 });
|
||||
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());
|
||||
@@ -1245,6 +1384,11 @@ export function startNoteEditor(adapter) {
|
||||
preview.addEventListener("change", event => {
|
||||
const checkbox = event.target.closest(".task-checkbox");
|
||||
if (!checkbox) return;
|
||||
if (!canEditDocument()) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
saveState.textContent = "Read only";
|
||||
return;
|
||||
}
|
||||
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
|
||||
const lines = editor.value.split("\n");
|
||||
if (lineIndex < 0 || lineIndex >= lines.length) return;
|
||||
@@ -1368,7 +1512,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(noteColor);
|
||||
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||
if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||
}
|
||||
userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value));
|
||||
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
|
||||
@@ -1382,7 +1526,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(currentUserColor() || null);
|
||||
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||
if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
|
||||
toast("Global profile color restored");
|
||||
});
|
||||
editor.addEventListener("keydown", continueIndentation);
|
||||
@@ -1391,6 +1535,11 @@ export function startNoteEditor(adapter) {
|
||||
syncPreviewScroll();
|
||||
});
|
||||
editor.addEventListener("input", event => {
|
||||
if (!canEditDocument() && !applyingRemote) {
|
||||
applyRemote(lastServerContent, lastServerOwnerMap);
|
||||
saveState.textContent = "Read only";
|
||||
return;
|
||||
}
|
||||
const nextContent = editor.value;
|
||||
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function importSource(path) {
|
||||
const source = await readFile(new URL(path, import.meta.url), "utf8");
|
||||
return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
|
||||
}
|
||||
|
||||
class FakeEditor {
|
||||
constructor(value, start = 0, end = start) {
|
||||
this.value = value;
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
this.selectionDirection = "none";
|
||||
this.readOnly = false;
|
||||
this.inputEvents = 0;
|
||||
}
|
||||
|
||||
setRangeText(replacement, start, end, mode) {
|
||||
this.value = `${this.value.slice(0, start)}${replacement}${this.value.slice(end)}`;
|
||||
const caret = mode === "end" ? start + replacement.length : start;
|
||||
this.selectionStart = caret;
|
||||
this.selectionEnd = caret;
|
||||
}
|
||||
|
||||
setSelectionRange(start, end, direction = "none") {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
this.selectionDirection = direction;
|
||||
}
|
||||
|
||||
dispatchEvent(event) {
|
||||
if (event.type === "input") this.inputEvents += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const { applyIndentation } = await importSource("../static/js/editor-format.js");
|
||||
const markdownSource = await readFile(new URL("../static/js/markdown.js", import.meta.url), "utf8");
|
||||
const alignSource = markdownSource.match(/export function alignPreviewLineNumbers\(root\) \{[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(alignSource, "alignPreviewLineNumbers source must exist");
|
||||
const alignPreviewLineNumbers = new Function(`${alignSource.replace("export ", "")}; return alignPreviewLineNumbers;`)();
|
||||
|
||||
test("Tab inserts two spaces at the caret", () => {
|
||||
const editor = new FakeEditor("abcd", 2);
|
||||
assert.equal(applyIndentation(editor), true);
|
||||
assert.equal(editor.value, "ab cd");
|
||||
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [4, 4]);
|
||||
assert.equal(editor.inputEvents, 1);
|
||||
});
|
||||
|
||||
test("Tab indents every selected line", () => {
|
||||
const editor = new FakeEditor("alpha\nbeta\ngamma", 0, 10);
|
||||
assert.equal(applyIndentation(editor), true);
|
||||
assert.equal(editor.value, " alpha\n beta\ngamma");
|
||||
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [2, 14]);
|
||||
});
|
||||
|
||||
test("Shift+Tab removes up to two spaces from selected lines", () => {
|
||||
const editor = new FakeEditor(" alpha\n beta", 2, 13);
|
||||
assert.equal(applyIndentation(editor, { outdent: true }), true);
|
||||
assert.equal(editor.value, "alpha\nbeta");
|
||||
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [0, 10]);
|
||||
});
|
||||
|
||||
test("Shift+Tab removes a leading tab", () => {
|
||||
const editor = new FakeEditor("\talpha", 4);
|
||||
assert.equal(applyIndentation(editor, { outdent: true }), true);
|
||||
assert.equal(editor.value, "alpha");
|
||||
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [3, 3]);
|
||||
});
|
||||
|
||||
test("preview line-number alignment ignores horizontal scroll", () => {
|
||||
const previousGetComputedStyle = globalThis.getComputedStyle;
|
||||
globalThis.getComputedStyle = () => ({ paddingLeft: "62px" });
|
||||
let assigned;
|
||||
const line = {
|
||||
getBoundingClientRect: () => ({ left: 70 }),
|
||||
style: { setProperty: (name, value) => { assigned = [name, value]; } },
|
||||
};
|
||||
const root = {
|
||||
scrollLeft: 80,
|
||||
getBoundingClientRect: () => ({ left: 100 }),
|
||||
querySelectorAll: () => [line],
|
||||
};
|
||||
try {
|
||||
alignPreviewLineNumbers(root);
|
||||
assert.deepEqual(assigned, ["--preview-line-left", "-38px"]);
|
||||
} finally {
|
||||
globalThis.getComputedStyle = previousGetComputedStyle;
|
||||
}
|
||||
});
|
||||
|
||||
test("compact editor layout starts below 1500px", async () => {
|
||||
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
|
||||
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
|
||||
assert.equal((css.match(/@media \(max-width: 1499px\)/g) || []).length, 4);
|
||||
assert.doesNotMatch(css, /@media \(max-width: 1920px\)/);
|
||||
assert.match(editorSource, /compactLayoutQuery = window\.matchMedia\("\(max-width: 1499px\)"\)/);
|
||||
assert.match(editorSource, /compactBubbleQuery = matchMedia\("\(max-width: 1499px\)"\)/);
|
||||
});
|
||||
|
||||
test("read-only mode blocks every document write path", async () => {
|
||||
const editorHtml = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
|
||||
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
|
||||
assert.match(editorHtml, /<textarea id="editor"[\s\S]*?readonly><\/textarea>/);
|
||||
assert.match(editorSource, /function canEditDocument\(\) \{ return !editor\.readOnly; \}/);
|
||||
assert.match(editorSource, /function scheduleDocumentSave\(\) \{[\s\S]*?if \(!canEditDocument\(\)\) \{[\s\S]*?saveState\.textContent = "Read only";[\s\S]*?return;/);
|
||||
assert.match(editorSource, /function activatePreviewEdit\(target, offset = null\) \{\s*if \(!target \|\| !canEditDocument\(\)\) return;/);
|
||||
assert.match(editorSource, /function commitPreviewEdit\(target,[\s\S]*?if \(!canEditDocument\(\)\) \{ render\(\); return; \}/);
|
||||
assert.match(editorSource, /if \(socket && canEditDocument\(\)\) socket\.update/g);
|
||||
assert.match(editorSource, /setDocumentReadOnly\(true, "Read only — changes not saved"\);[\s\S]*?applyRemote\(lastServerContent, lastServerOwnerMap\);[\s\S]*?queueMicrotask\(connect\);/);
|
||||
});
|
||||
|
||||
test("narrow editor switches stay compact", async () => {
|
||||
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
|
||||
assert.match(css, /\.pad-page #mode-toggle \{[\s\S]*?width:\s*auto;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*nowrap;/);
|
||||
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.editor-toolbar \{\s*grid-template-columns:\s*minmax\(0, 1fr\) auto;/);
|
||||
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.view-switch \{[\s\S]*?display:\s*inline-flex;[\s\S]*?width:\s*max-content;[\s\S]*?justify-self:\s*end;/);
|
||||
assert.doesNotMatch(css, /\.pad-page \.view-switch \{\s*position:\s*static;\s*display:\s*grid;\s*width:\s*100%;/);
|
||||
});
|
||||
|
||||
test("Full HD scaling keeps Preview visible without compacting the whole editor", async () => {
|
||||
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
|
||||
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-group \{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow-x:\s*auto;/);
|
||||
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-fill \{\s*display:\s*none;/);
|
||||
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page :is\(\.editor-controls, \.toolbar-action, \.line-toggle, \.view-switch\) \{\s*flex:\s*0 0 auto;/);
|
||||
});
|
||||
|
||||
test("Markdown/Text uses the same toolbar action styling as More", async () => {
|
||||
const html = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
|
||||
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
|
||||
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
|
||||
assert.match(html, /id="mode-toggle" class="toolbar-action active"/);
|
||||
assert.doesNotMatch(css, /\.markdown-toggle/);
|
||||
assert.match(css, /\.markdown-more>summary,\s*\.pad-page \.toolbar-action \{/);
|
||||
assert.match(editorSource, /modeToggle\.setAttribute\("aria-pressed", String\(markdown\)\)/);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
|
||||
|
||||
function rule(selector) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(match, `Missing CSS rule: ${selector}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("top-level page headers share one compact height", () => {
|
||||
assert.match(css, /--top-header-height:\s*54px;/);
|
||||
for (const selector of [".site-header", ".app-header", ".public-header"]) {
|
||||
assert.match(rule(selector), /min-height:\s*var\(--top-header-height\)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("header actions use the shared compact control height", () => {
|
||||
assert.match(css, /--top-header-control-height:\s*34px;/);
|
||||
assert.match(css, /\.app-header \.secondary-button,[\s\S]*?min-height:\s*var\(--top-header-control-height\)/);
|
||||
assert.match(css, /\.public-header \.secondary-button,[\s\S]*?min-height:\s*var\(--top-header-control-height\)/);
|
||||
});
|
||||
|
||||
test("small screens retain taller header controls", () => {
|
||||
assert.match(css, /@media \(max-width: 720px\)[\s\S]*?\.public-header \.secondary-button,[\s\S]*?min-height:\s*36px;/);
|
||||
});
|
||||
Reference in New Issue
Block a user