v0.2.87
This commit is contained in:
1 parent
acfe926066
commit
df403c0aa7
26 files changed
+1502
-144
No files matched your search
+206
-6
@@ -3171,7 +3171,7 @@ dialog::backdrop {
|
||||
font-size: .7rem;
|
||||
}
|
||||
|
||||
.file-meta > span:not(.file-flag) + span:not(.file-flag)::before {
|
||||
.file-meta>span:not(.file-flag)+span:not(.file-flag)::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
color: var(--border-strong);
|
||||
@@ -3360,8 +3360,13 @@ dialog::backdrop {
|
||||
}
|
||||
|
||||
@keyframes file-skeleton-pulse {
|
||||
from { background-position: 100% 0; }
|
||||
to { background-position: -100% 0; }
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-spinner {
|
||||
@@ -3382,10 +3387,13 @@ dialog::backdrop {
|
||||
}
|
||||
|
||||
@keyframes ui-spinner-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.file-row,
|
||||
.file-skeleton,
|
||||
.ui-spinner {
|
||||
@@ -9091,7 +9099,6 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
/* PDF export */
|
||||
#pdf-export-dialog {
|
||||
width: min(520px, calc(100% - 28px));
|
||||
max-width: 520px;
|
||||
@@ -9164,7 +9171,200 @@ dialog::backdrop {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#pdf-export-dialog .dialog-actions > button {
|
||||
#pdf-export-dialog .dialog-actions>button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-body .markdown-mention {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: .1em .48em;
|
||||
border: 1px solid color-mix(in srgb, var(--owner) 62%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--owner) 18%, var(--surface-inset));
|
||||
color: var(--text-on-owner);
|
||||
font: 600 .85em/1.25 system-ui, sans-serif;
|
||||
vertical-align: baseline;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown-body .heading-permalink {
|
||||
margin-left: .45em;
|
||||
color: var(--text-muted-strong);
|
||||
font: 600 .64em/1 system-ui, sans-serif;
|
||||
opacity: .35;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.markdown-body :is(h1, h2, h3, h4, h5, h6):hover>.heading-permalink,
|
||||
.markdown-body .heading-permalink:hover,
|
||||
.markdown-body .heading-permalink:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mention-suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
box-sizing: border-box;
|
||||
width: min(290px, calc(100% - 12px));
|
||||
max-height: min(300px, 75%);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 14px 36px rgb(0 0 0 / .25);
|
||||
color: var(--text);
|
||||
font: 500 13px/1.4 system-ui, sans-serif;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mention-suggestions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mention-suggestions__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
margin: 0;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mention-suggestions__option:hover,
|
||||
.mention-suggestions__option[aria-selected="true"] {
|
||||
background: var(--accent-a15, color-mix(in srgb, var(--accent) 15%, transparent));
|
||||
}
|
||||
|
||||
.mention-suggestions__option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.mention-suggestions__dot {
|
||||
flex: 0 0 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--owner);
|
||||
}
|
||||
|
||||
.mention-caret-mirror {
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
width: max-content;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mentioned-users {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mentioned-users[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mentioned-users__label {
|
||||
color: var(--muted);
|
||||
font: 600 11px/1.4 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.mentioned-users__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mentioned-users__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
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);
|
||||
color: var(--text);
|
||||
font: 600 11px/1.3 system-ui, sans-serif;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mentioned-users__badge:hover {
|
||||
background: color-mix(in srgb, var(--owner) 30%, transparent);
|
||||
}
|
||||
|
||||
.mentioned-users__badge:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mentioned-users__count {
|
||||
border-radius: 999px;
|
||||
padding: 1px 5px;
|
||||
background: color-mix(in srgb, var(--owner) 22%, transparent);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdown-body .markdown-mention.is-mention-target {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
background: color-mix(in srgb, var(--owner) 32%, var(--surface-inset));
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mentioned-users {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.pad-page .editor-column>.mentioned-users {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pad-page .preview-column {
|
||||
grid-template-rows: 30px auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pad-page .preview-column>#preview-label {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.pad-page .preview-column>#mentioned-users-preview {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.pad-page .preview-column>#preview {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.workspace:not(.view-preview) .mentioned-users--preview {
|
||||
display: none;
|
||||
}
|
||||
+46
-21
@@ -27,7 +27,8 @@
|
||||
<p id="document-url" class="document-url"></p>
|
||||
</div>
|
||||
<button id="favorite-toggle" class="favorite-toggle" type="button" aria-pressed="false"
|
||||
title="Add to favorites" aria-label="Add to favorites" hidden><span aria-hidden="true">☆</span></button>
|
||||
title="Add to favorites" aria-label="Add to favorites" hidden><span
|
||||
aria-hidden="true">☆</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
|
||||
@@ -42,7 +43,8 @@
|
||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
|
||||
link</button><button id="export-pdf" class="secondary-button" type="button" title="Export PDF">PDF</button>
|
||||
link</button><button id="export-pdf" class="secondary-button" type="button"
|
||||
title="Export PDF">PDF</button>
|
||||
<details class="page-settings">
|
||||
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
|
||||
class="page-settings__status" aria-hidden="true"></span>Page</span><span
|
||||
@@ -124,11 +126,13 @@
|
||||
data-format="definition">Definition</button><button type="button"
|
||||
data-format="highlight">Highlight</button><button type="button"
|
||||
data-format="subscript">Subscript</button><button type="button"
|
||||
data-format="superscript">Superscript</button><button type="button" data-format="page-break" title="Page break (PDF) · Ctrl/Cmd+Enter">Page break (PDF)</button></div>
|
||||
data-format="superscript">Superscript</button><button type="button"
|
||||
data-format="page-break" title="Page break (PDF) · Ctrl/Cmd+Enter">Page break
|
||||
(PDF)</button></div>
|
||||
</details>
|
||||
</div>
|
||||
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
|
||||
title="Upload a file"><span aria-hidden="true"></span> Upload</button>
|
||||
<button id="mobile-upload-button" class="mobile-upload-button" type="button" title="Upload a file"><span
|
||||
aria-hidden="true"></span> Upload</button>
|
||||
<div class="editor-controls"><label>Font<select class="app-select-input" id="font-family">
|
||||
<option value="mono">Mono</option>
|
||||
<option value="system">System</option>
|
||||
@@ -141,8 +145,8 @@
|
||||
<option value="18">18</option>
|
||||
<option value="20">20</option>
|
||||
<option value="22">22</option>
|
||||
</select></label></div><button id="upload-button"
|
||||
class="toolbar-action" title="Upload a file">Upload</button><input id="file-input" type="file" hidden><label
|
||||
</select></label></div><button id="upload-button" class="toolbar-action"
|
||||
title="Upload a file">Upload</button><input id="file-input" type="file" hidden><label
|
||||
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
||||
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
||||
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
||||
@@ -152,8 +156,8 @@
|
||||
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
|
||||
class="control-label-full">Markdown</span><span class="control-label-short"
|
||||
aria-hidden="true">M</span></button>
|
||||
<button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button"
|
||||
aria-pressed="false" aria-label="Hide editor toolbar" title="Hide editor toolbar"><span
|
||||
<button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button" aria-pressed="false"
|
||||
aria-label="Hide editor toolbar" title="Hide editor toolbar"><span
|
||||
class="toolbar-collapse-toggle__icon" aria-hidden="true">⌃</span></button>
|
||||
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active"
|
||||
aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
|
||||
@@ -185,6 +189,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="participant-badges" class="participant-badges" aria-label="Participants" hidden></div>
|
||||
<div id="mentioned-users" class="mentioned-users" role="group" aria-label="Mentioned"
|
||||
data-i18n-aria-label="editor.mentionedUsers" hidden>
|
||||
<span class="mentioned-users__label" data-i18n="editor.mentionedUsers">Mentioned</span>
|
||||
<div id="mentioned-users-list" class="mentioned-users__list"></div>
|
||||
</div>
|
||||
<div class="editor-shell">
|
||||
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
||||
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
||||
@@ -194,6 +203,11 @@
|
||||
</div>
|
||||
<div class="preview-column">
|
||||
<div id="preview-label" class="column-label">Markdown preview</div>
|
||||
<div id="mentioned-users-preview" class="mentioned-users mentioned-users--preview" role="group"
|
||||
aria-label="Mentioned" data-i18n-aria-label="editor.mentionedUsers" hidden>
|
||||
<span class="mentioned-users__label" data-i18n="editor.mentionedUsers">Mentioned</span>
|
||||
<div id="mentioned-users-preview-list" class="mentioned-users__list"></div>
|
||||
</div>
|
||||
<article id="preview" class="preview markdown-body"></article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,9 +286,12 @@
|
||||
<p>Author, time, and version preview</p>
|
||||
</div><button id="close-history" class="icon-button">×</button>
|
||||
</div>
|
||||
<div class="history-view-switch" role="group" aria-label="History view" data-i18n-aria-label="editor.historyView">
|
||||
<button type="button" data-history-view="list" class="active" aria-pressed="true" data-i18n="editor.historyListView">List</button>
|
||||
<button type="button" data-history-view="archive" aria-pressed="false" data-i18n="editor.historyArchiveView">Archive</button>
|
||||
<div class="history-view-switch" role="group" aria-label="History view"
|
||||
data-i18n-aria-label="editor.historyView">
|
||||
<button type="button" data-history-view="list" class="active" aria-pressed="true"
|
||||
data-i18n="editor.historyListView">List</button>
|
||||
<button type="button" data-history-view="archive" aria-pressed="false"
|
||||
data-i18n="editor.historyArchiveView">Archive</button>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</aside>
|
||||
@@ -283,7 +300,8 @@
|
||||
<form id="pdf-export-form" method="dialog" class="dialog-panel pdf-export-panel">
|
||||
<button class="modal-close" type="button" data-pdf-cancel aria-label="Close dialog">×</button>
|
||||
<h2>Export PDF</h2>
|
||||
<p class="dialog-copy" data-i18n="editor.pdfPageBreakHelp">Use Page break (PDF) or Ctrl/Cmd+Enter to start a new PDF page.</p>
|
||||
<p class="dialog-copy" data-i18n="editor.pdfPageBreakHelp">Use Page break (PDF) or Ctrl/Cmd+Enter to start a
|
||||
new PDF page.</p>
|
||||
<div class="pdf-export-fields">
|
||||
<label><span>Page format</span><select class="app-select-input" id="pdf-page-format">
|
||||
<option value="a4-portrait">A4 — Portrait</option>
|
||||
@@ -303,12 +321,18 @@
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="pdf-export-options">
|
||||
<label class="public-task-toggle pdf-export-title-toggle"><input id="pdf-include-title" type="checkbox"> Include document title</label>
|
||||
<label class="public-task-toggle pdf-export-color-toggle"><input id="pdf-preserve-colors" type="checkbox"> <span data-i18n="editor.preservePdfColors">Preserve colors and backgrounds</span></label>
|
||||
<label class="public-task-toggle pdf-export-title-toggle"><input id="pdf-include-title" type="checkbox">
|
||||
Include document title</label>
|
||||
<label class="public-task-toggle pdf-export-color-toggle"><input id="pdf-preserve-colors"
|
||||
type="checkbox"> <span data-i18n="editor.preservePdfColors">Preserve colors and
|
||||
backgrounds</span></label>
|
||||
</div>
|
||||
<p class="pdf-export-color-help" data-i18n="editor.pdfColorHelp">Keeps syntax highlighting, alerts, quotes, highlights, table headers, and other colors in the PDF.</p>
|
||||
<p class="pdf-export-color-help" data-i18n="editor.pdfColorHelp">Keeps syntax highlighting, alerts, quotes,
|
||||
highlights, table headers, and other colors in the PDF.</p>
|
||||
<p class="pdf-export-help">The browser print dialog will open. Choose Save as PDF.</p>
|
||||
<div class="dialog-actions"><button class="secondary-button" value="cancel" type="button" data-pdf-cancel>Cancel</button><button id="generate-pdf" class="primary-button" type="submit">Generate PDF</button></div>
|
||||
<div class="dialog-actions"><button class="secondary-button" value="cancel" type="button"
|
||||
data-pdf-cancel>Cancel</button><button id="generate-pdf" class="primary-button"
|
||||
type="submit">Generate PDF</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="shortcuts-dialog">
|
||||
@@ -324,7 +348,8 @@
|
||||
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><kbd>Tab</kbd><span>Indent by 2
|
||||
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span><kbd>Ctrl/Cmd+Enter</kbd><span>Page break (PDF)</span>__EXTRA_SHORTCUTS__
|
||||
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span><kbd>Ctrl/Cmd+Enter</kbd><span>Page
|
||||
break (PDF)</span>__EXTRA_SHORTCUTS__
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -336,8 +361,7 @@
|
||||
<p>Copy a direct link or ready Markdown/Alias code.</p>
|
||||
</div>
|
||||
<div class="files-head-actions">
|
||||
<button id="files-upload-button" class="primary-button"
|
||||
type="button">Upload file</button>
|
||||
<button id="files-upload-button" class="primary-button" type="button">Upload file</button>
|
||||
<button id="close-files" class="icon-button" type="button" aria-label="Close files">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,7 +480,8 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications"
|
||||
aria-relevant="additions removals"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+78
-24
@@ -18,6 +18,42 @@ function escapeHtml(value) {
|
||||
const emoji = EMOJI_SHORTCODES;
|
||||
let markdownFiles = new Map();
|
||||
let markdownFileRoutes = new Map();
|
||||
let markdownMentions = [];
|
||||
let markdownMentionMatcher = null;
|
||||
let markdownMentionLookup = new Map();
|
||||
|
||||
export function setMarkdownMentions(users) {
|
||||
const names = new Set();
|
||||
markdownMentions = (Array.isArray(users) ? users : []).flatMap(user => {
|
||||
const nickname = String(user?.nickname || "").trim();
|
||||
const key = nickname.toLocaleLowerCase();
|
||||
if (!nickname || names.has(key)) return [];
|
||||
names.add(key);
|
||||
const color = /^#[0-9a-f]{6}$/i.test(user?.color || "") ? user.color : defaultMentionColor(nickname);
|
||||
return [{ nickname, color }];
|
||||
}).sort((a, b) => b.nickname.length - a.nickname.length);
|
||||
markdownMentionLookup = new Map(markdownMentions.map(user => [escapeHtml(user.nickname).toLocaleLowerCase(), user]));
|
||||
markdownMentionMatcher = markdownMentions.length
|
||||
? new RegExp(`(^|[^\\p{L}\\p{N}_@])@(${markdownMentions.map(user => escapeRegex(escapeHtml(user.nickname))).join("|")})(?![\\p{L}\\p{N}_-]|\\.[\\p{L}\\p{N}_-])`, "giu")
|
||||
: null;
|
||||
}
|
||||
|
||||
function defaultMentionColor(name) {
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
|
||||
return `hsl(${hash} 70% 62%)`;
|
||||
}
|
||||
|
||||
function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
|
||||
function highlightMentions(html) {
|
||||
if (!markdownMentionMatcher) return html;
|
||||
// Process text nodes only, not markup, links or attributes.
|
||||
return html.split(/(<[^>]*>)/g).map(part => part.startsWith("<") ? part : part.replace(markdownMentionMatcher, (match, before, name) => {
|
||||
const user = markdownMentionLookup.get(name.toLocaleLowerCase());
|
||||
return user ? `${before}<span class="markdown-mention" style="--owner:${user.color}" title="${escapeHtml(user.nickname)}">@${escapeHtml(user.nickname)}</span>` : match;
|
||||
})).join("");
|
||||
}
|
||||
|
||||
function attachmentRoute(value) {
|
||||
try {
|
||||
@@ -175,7 +211,7 @@ function inline(value) {
|
||||
return `${prefix}${stash(`<a href="${safeUrl(clean)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">${clean}</a>`)}${suffix}`;
|
||||
});
|
||||
|
||||
return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
|
||||
return highlightMentions(html).replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
|
||||
}
|
||||
|
||||
|
||||
@@ -400,11 +436,21 @@ export function alignPreviewLineNumbers(root) {
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMarkdown(source, lineOffset = 0) {
|
||||
let html = "", inCode = false, fence = "", language = "", codeLineStart = null, code = [], codeStart = 0;
|
||||
function stripFenceIndent(line, columns) {
|
||||
let removed = 0, index = 0;
|
||||
while (index < line.length && removed < columns) {
|
||||
if (line[index] === " ") { removed++; index++; }
|
||||
else if (line[index] === "\t" && removed + 4 <= columns) { removed += 4; index++; }
|
||||
else break;
|
||||
}
|
||||
return line.slice(index);
|
||||
}
|
||||
|
||||
export function renderMarkdown(source, lineOffset = 0, documentHeadings = null) {
|
||||
let html = "", inCode = false, fence = "", fenceIndent = 0, language = "", codeLineStart = null, code = [], codeStart = 0;
|
||||
const lines = String(source).split("\n");
|
||||
const headings = collectHeadings(lines);
|
||||
const headingByLine = new Map(headings.map(item => [item.index, item]));
|
||||
const headings = documentHeadings || collectHeadings(lines).map(item => ({ ...item, index: item.index + lineOffset }));
|
||||
const headingByLine = new Map(headings.map(item => [item.index - lineOffset, item]));
|
||||
const footnotes = new Map();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
@@ -425,39 +471,45 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
const closeCode = () => {
|
||||
const body = escapeHtml(code.join("\n"));
|
||||
const lang = normalizeLanguage(language);
|
||||
const indentStyle = fenceIndent > 0 ? ` style="margin-left:${fenceIndent}ch"` : "";
|
||||
if (lang === "mermaid") {
|
||||
html += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`;
|
||||
html += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}"${indentStyle}>${body}</div>`;
|
||||
} else if (codeLineStart !== null) {
|
||||
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join("");
|
||||
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}><code${languageClass}>${numbered}</code></pre>`;
|
||||
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}${indentStyle}><code${languageClass}>${numbered}</code></pre>`;
|
||||
} else {
|
||||
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
html += `<pre${attrs(codeStart, false, "", "", lineOffset)}><code${languageClass}>${body}</code></pre>`;
|
||||
html += `<pre${attrs(codeStart, false, "", "", lineOffset)}${indentStyle}><code${languageClass}>${body}</code></pre>`;
|
||||
}
|
||||
code = []; language = ""; codeLineStart = null; fence = "";
|
||||
code = []; language = ""; codeLineStart = null; fence = ""; fenceIndent = 0;
|
||||
};
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)\s*([^\s]*)\s*$/);
|
||||
const fenceMatch = line.match(/^([ \t]*)(`{3,}|~{3,})[ \t]*([^\s]*)[ \t]*$/);
|
||||
if (fenceMatch) {
|
||||
closeList();
|
||||
if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode();
|
||||
else if (!inCode) {
|
||||
fence = fenceMatch[1];
|
||||
const info = fenceMatch[2] || "";
|
||||
// Generic syntax for every fenced code block:
|
||||
// ```rust=, ```python=101, ```= or ```=101.
|
||||
const marker = fenceMatch[2];
|
||||
if (inCode && marker[0] === fence[0] && marker.length >= fence.length && !fenceMatch[3]) {
|
||||
closeCode();
|
||||
inCode = false;
|
||||
continue;
|
||||
}
|
||||
if (!inCode) {
|
||||
closeList();
|
||||
fence = marker;
|
||||
fenceIndent = fenceMatch[1].replace(/\t/g, " ").length;
|
||||
const info = fenceMatch[3] || "";
|
||||
// ```rust=, ```python=101, ```= and ```=101 enable line numbers.
|
||||
const numbered = info.match(/^(.*?)=(\d*)$/);
|
||||
language = numbered ? numbered[1] : info;
|
||||
codeLineStart = numbered ? Number(numbered[2] || 1) : null;
|
||||
codeStart = index;
|
||||
inCode = true;
|
||||
continue;
|
||||
}
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode) { code.push(line); continue; }
|
||||
if (inCode) { code.push(stripFenceIndent(line, fenceIndent)); continue; }
|
||||
|
||||
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
|
||||
if (standaloneMedia) {
|
||||
@@ -500,7 +552,7 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
}
|
||||
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
|
||||
const body = lines.slice(bodyStart, end).join("\n");
|
||||
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}" data-source-end-line="${end + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(body, lineOffset + bodyStart)}</div></details>`;
|
||||
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}" data-source-end-line="${end + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(body, lineOffset + bodyStart, headings)}</div></details>`;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
@@ -508,7 +560,7 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
|
||||
if (/^\s*\[TOC\]\s*$/i.test(line)) {
|
||||
closeList();
|
||||
const tocHeadings = headings.filter(item => item.index > index);
|
||||
const tocHeadings = headings.filter(item => item.index > index + lineOffset);
|
||||
if (tocHeadings.length) {
|
||||
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="${escapeHtml(t("markdown.toc", {}, "Table of contents"))}">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
|
||||
}
|
||||
@@ -523,7 +575,7 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
if (end < lines.length) {
|
||||
const type = alertStart[1].toLowerCase();
|
||||
const body = lines.slice(index + 1, end).join("\n");
|
||||
html += `<aside class="markdown-alert markdown-alert--${type}" role="note">${renderMarkdown(body, lineOffset + index + 1)}</aside>`;
|
||||
html += `<aside class="markdown-alert markdown-alert--${type}" role="note">${renderMarkdown(body, lineOffset + index + 1, headings)}</aside>`;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
@@ -558,7 +610,9 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
const resolved = headingByLine.get(index);
|
||||
const id = resolved?.id || heading[3] || headingSlug(heading[2]);
|
||||
const suffix = heading[3] ? ` {#${heading[3]}}` : "";
|
||||
html += `<h${n} id="${escapeHtml(id)}"${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}</h${n}>`;
|
||||
const permalinkLabel = t("markdown.headingPermalink", {}, "Link to heading");
|
||||
const permalink = `<a class="heading-permalink" href="#${encodeURIComponent(id)}" aria-label="${escapeHtml(permalinkLabel)}" title="${escapeHtml(permalinkLabel)}" contenteditable="false">#</a>`;
|
||||
html += `<h${n} id="${escapeHtml(id)}"${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}${permalink}</h${n}>`;
|
||||
} else {
|
||||
closeList();
|
||||
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import { t } from "@rustpad/i18n";
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MENTION_SUFFIX = /[\p{L}\p{M}\p{N}_.+-]{0,64}$/u;
|
||||
const MENTION_BOUNDARY = /[\p{L}\p{N}_@]/u;
|
||||
|
||||
// A suggestion is offered only for an @word being typed at a collapsed caret.
|
||||
// Do not interpret addresses such as john@example.org as mentions.
|
||||
export function mentionAtCaret(value, caret) {
|
||||
if (caret < 1 || caret > value.length) return null;
|
||||
const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
|
||||
const before = value.slice(lineStart, caret);
|
||||
const suffix = MENTION_SUFFIX.exec(before);
|
||||
if (!suffix) return null;
|
||||
const at = caret - suffix[0].length - 1;
|
||||
if (at < lineStart || value[at] !== "@") return null;
|
||||
if (at > 0 && MENTION_BOUNDARY.test(value[at - 1])) return null;
|
||||
return { start: at, end: caret, query: suffix[0] };
|
||||
}
|
||||
|
||||
function fallbackColor(name) {
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
|
||||
return `hsl(${hash} 70% 62%)`;
|
||||
}
|
||||
|
||||
export function normalizeMentionUsers(users) {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(users) ? users : []).flatMap(user => {
|
||||
const nickname = String(user?.nickname || "").trim();
|
||||
const key = nickname.toLocaleLowerCase();
|
||||
if (!nickname || seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
return [{
|
||||
nickname,
|
||||
color: /^#[0-9a-f]{6}$/i.test(user?.color || "") ? user.color : fallbackColor(nickname),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function filterMentionUsers(users, query) {
|
||||
const lower = String(query || "").toLocaleLowerCase();
|
||||
return users.filter(user => user.nickname.toLocaleLowerCase().includes(lower))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.nickname.toLocaleLowerCase().startsWith(lower);
|
||||
const bStarts = b.nickname.toLocaleLowerCase().startsWith(lower);
|
||||
return Number(bStarts) - Number(aStarts) || a.nickname.localeCompare(b.nickname);
|
||||
})
|
||||
.slice(0, MAX_RESULTS);
|
||||
}
|
||||
|
||||
export function createMentionAutocomplete(editor) {
|
||||
const shell = editor.closest(".editor-shell");
|
||||
if (!shell) throw new Error("Mention suggestions require an editor shell.");
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "mention-suggestions";
|
||||
menu.className = "mention-suggestions";
|
||||
menu.setAttribute("role", "listbox");
|
||||
menu.setAttribute("aria-label", t("editor.mentionMembers", {}, "Mention a member"));
|
||||
menu.hidden = true;
|
||||
shell.append(menu);
|
||||
|
||||
// A single-line offscreen mirror measures the caret's horizontal position,
|
||||
// including proportional fonts and tabs, without relying on text estimates.
|
||||
const mirror = document.createElement("div");
|
||||
mirror.className = "mention-caret-mirror";
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
shell.append(mirror);
|
||||
|
||||
editor.setAttribute("aria-controls", menu.id);
|
||||
editor.setAttribute("aria-haspopup", "listbox");
|
||||
editor.setAttribute("aria-expanded", "false");
|
||||
|
||||
let members = [];
|
||||
let matches = [];
|
||||
let activeMatch = null;
|
||||
let selectedIndex = 0;
|
||||
let composing = false;
|
||||
let dismissedAt = null;
|
||||
let blurTimer = null;
|
||||
|
||||
function close({ dismissed = false } = {}) {
|
||||
if (dismissed && activeMatch) dismissedAt = `${activeMatch.start}:${activeMatch.end}:${activeMatch.query}`;
|
||||
activeMatch = null;
|
||||
matches = [];
|
||||
menu.hidden = true;
|
||||
menu.replaceChildren();
|
||||
editor.setAttribute("aria-expanded", "false");
|
||||
editor.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (menu.hidden || !activeMatch) return;
|
||||
const style = getComputedStyle(editor);
|
||||
const lineStart = editor.value.lastIndexOf("\n", activeMatch.end - 1) + 1;
|
||||
const beforeCaret = editor.value.slice(lineStart, activeMatch.end);
|
||||
mirror.style.font = style.font;
|
||||
mirror.style.letterSpacing = style.letterSpacing;
|
||||
mirror.style.tabSize = style.tabSize;
|
||||
mirror.textContent = beforeCaret;
|
||||
const cursor = document.createElement("span");
|
||||
cursor.textContent = "\u200b";
|
||||
mirror.append(cursor);
|
||||
const measuredX = cursor.getBoundingClientRect().left - mirror.getBoundingClientRect().left;
|
||||
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const editorRect = editor.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.72;
|
||||
const lineIndex = editor.value.slice(0, lineStart).split("\n").length - 1;
|
||||
const x = editorRect.left - shellRect.left + (parseFloat(style.borderLeftWidth) || 0)
|
||||
+ (parseFloat(style.paddingLeft) || 0) + measuredX - editor.scrollLeft;
|
||||
const y = editorRect.top - shellRect.top + (parseFloat(style.borderTopWidth) || 0)
|
||||
+ (parseFloat(style.paddingTop) || 0) + lineIndex * lineHeight - editor.scrollTop;
|
||||
if (x < -5 || x > shell.clientWidth + 5 || y < -lineHeight || y > shell.clientHeight) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const edge = 6;
|
||||
const bottom = y + lineHeight + 4;
|
||||
const top = bottom + menu.offsetHeight > shell.clientHeight - edge
|
||||
? y - menu.offsetHeight - 4 : bottom;
|
||||
menu.style.left = `${Math.max(edge, Math.min(x, shell.clientWidth - menu.offsetWidth - edge))}px`;
|
||||
menu.style.top = `${Math.max(edge, Math.min(top, shell.clientHeight - menu.offsetHeight - edge))}px`;
|
||||
}
|
||||
|
||||
function markSelected() {
|
||||
[...menu.querySelectorAll('[role="option"]')].forEach((option, i) => {
|
||||
const selected = i === selectedIndex;
|
||||
option.setAttribute("aria-selected", String(selected));
|
||||
if (selected) {
|
||||
editor.setAttribute("aria-activedescendant", option.id);
|
||||
if (option.offsetTop < menu.scrollTop) menu.scrollTop = option.offsetTop;
|
||||
else if (option.offsetTop + option.offsetHeight > menu.scrollTop + menu.clientHeight) {
|
||||
menu.scrollTop = option.offsetTop + option.offsetHeight - menu.clientHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function accept(index) {
|
||||
const user = matches[index];
|
||||
if (!user || !activeMatch || editor.readOnly) return;
|
||||
const { start, end } = activeMatch;
|
||||
if (editor.value.slice(start, end) !== `@${activeMatch.query}`) { close(); return; }
|
||||
const after = editor.value[end] || "";
|
||||
const separator = !after || !/[\s.,;:!?)}\]]/u.test(after) ? " " : "";
|
||||
close();
|
||||
dismissedAt = null;
|
||||
editor.focus();
|
||||
const inserted = `@${user.nickname}${separator}`;
|
||||
editor.setRangeText(inserted, start, end, "end");
|
||||
// Use the normal input pipeline: live collaboration, authorship and undo/redo.
|
||||
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText", data: inserted }));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (composing || editor.readOnly || document.activeElement !== editor ||
|
||||
editor.selectionStart !== editor.selectionEnd || !members.length) { close(); return; }
|
||||
const match = mentionAtCaret(editor.value, editor.selectionStart);
|
||||
if (!match) { dismissedAt = null; close(); return; }
|
||||
const key = `${match.start}:${match.end}:${match.query}`;
|
||||
if (dismissedAt === key) return;
|
||||
const choices = filterMentionUsers(members, match.query);
|
||||
if (!choices.length) { close(); return; }
|
||||
const previous = activeMatch;
|
||||
matches = choices;
|
||||
activeMatch = match;
|
||||
if (!previous || previous.start !== match.start || previous.query !== match.query) selectedIndex = 0;
|
||||
menu.replaceChildren();
|
||||
choices.forEach((user, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.id = `mention-suggestion-${index}`;
|
||||
button.className = "mention-suggestions__option";
|
||||
button.setAttribute("role", "option");
|
||||
button.style.setProperty("--owner", user.color);
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "mention-suggestions__dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `@${user.nickname}`;
|
||||
button.append(dot, label);
|
||||
// Keep textarea selection intact while clicking with a mouse.
|
||||
button.addEventListener("mousedown", event => event.preventDefault());
|
||||
button.addEventListener("click", () => accept(index));
|
||||
menu.append(button);
|
||||
});
|
||||
menu.hidden = false;
|
||||
editor.setAttribute("aria-expanded", "true");
|
||||
markSelected();
|
||||
position();
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (menu.hidden || !activeMatch || event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
close({ dismissed: true });
|
||||
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
selectedIndex = (selectedIndex + (event.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length;
|
||||
markSelected();
|
||||
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
accept(selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur(event) {
|
||||
clearTimeout(blurTimer);
|
||||
if (event.relatedTarget && menu.contains(event.relatedTarget)) return;
|
||||
blurTimer = setTimeout(() => {
|
||||
if (document.activeElement !== editor && !menu.contains(document.activeElement)) close();
|
||||
}, 120);
|
||||
}
|
||||
function onOutsidePointer(event) {
|
||||
if (!menu.contains(event.target) && event.target !== editor) close();
|
||||
}
|
||||
function onCompositionStart() { composing = true; close(); }
|
||||
function onCompositionEnd() { composing = false; refresh(); }
|
||||
const onScroll = () => { if (!menu.hidden) position(); };
|
||||
const onSelection = () => { if (document.activeElement === editor && !menu.hidden) refresh(); };
|
||||
|
||||
editor.addEventListener("keydown", onKeydown);
|
||||
editor.addEventListener("input", refresh);
|
||||
editor.addEventListener("click", refresh);
|
||||
editor.addEventListener("keyup", refresh);
|
||||
editor.addEventListener("focus", refresh);
|
||||
editor.addEventListener("blur", onBlur);
|
||||
editor.addEventListener("scroll", onScroll);
|
||||
editor.addEventListener("compositionstart", onCompositionStart);
|
||||
editor.addEventListener("compositionend", onCompositionEnd);
|
||||
document.addEventListener("pointerdown", onOutsidePointer);
|
||||
document.addEventListener("selectionchange", onSelection);
|
||||
window.addEventListener("resize", onScroll);
|
||||
|
||||
return {
|
||||
setUsers(users) { members = normalizeMentionUsers(users); dismissedAt = null; close(); refresh(); },
|
||||
close,
|
||||
refresh,
|
||||
destroy() {
|
||||
clearTimeout(blurTimer);
|
||||
editor.removeEventListener("keydown", onKeydown);
|
||||
editor.removeEventListener("input", refresh);
|
||||
editor.removeEventListener("click", refresh);
|
||||
editor.removeEventListener("keyup", refresh);
|
||||
editor.removeEventListener("focus", refresh);
|
||||
editor.removeEventListener("blur", onBlur);
|
||||
editor.removeEventListener("scroll", onScroll);
|
||||
editor.removeEventListener("compositionstart", onCompositionStart);
|
||||
editor.removeEventListener("compositionend", onCompositionEnd);
|
||||
document.removeEventListener("pointerdown", onOutsidePointer);
|
||||
document.removeEventListener("selectionchange", onSelection);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
editor.removeAttribute("aria-controls");
|
||||
editor.removeAttribute("aria-haspopup");
|
||||
editor.removeAttribute("aria-expanded");
|
||||
editor.removeAttribute("aria-activedescendant");
|
||||
menu.remove();
|
||||
mirror.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import { t } from "@rustpad/i18n";
|
||||
import { normalizeMentionUsers } from "@rustpad/mention-autocomplete";
|
||||
|
||||
function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
|
||||
// Find real @mentions in the source, not arbitrary text that resembles an email
|
||||
// address or an @username inside a code fence, inline code, link or URL.
|
||||
// Offsets use UTF-16 code units, just like textarea.setSelectionRange().
|
||||
export function collectMentionOccurrences(source, authorizedUsers, { markdown = true } = {}) {
|
||||
const members = normalizeMentionUsers(authorizedUsers);
|
||||
if (!members.length) return [];
|
||||
const lookup = new Map(members.map(member => [member.nickname.toLocaleLowerCase(), member]));
|
||||
const names = members.map(member => member.nickname).sort((a, b) => b.length - a.length);
|
||||
const matcher = new RegExp(`(^|[^\\p{L}\\p{N}_@])@(${names.map(escapeRegex).join("|")})(?![\\p{L}\\p{N}_-]|\\.[\\p{L}\\p{N}_-])`, "giu");
|
||||
const found = [];
|
||||
let offset = 0, fence = "";
|
||||
|
||||
for (const [lineIndex, line] of String(source).split("\n").entries()) {
|
||||
if (markdown) {
|
||||
const fenceMatch = line.match(/^([ \t]*)(`{3,}|~{3,})[ \t]*([^\s]*)[ \t]*$/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2];
|
||||
if (!fence) fence = marker;
|
||||
else if (marker[0] === fence[0] && marker.length >= fence.length && !fenceMatch[3]) fence = "";
|
||||
offset += line.length + 1;
|
||||
continue;
|
||||
}
|
||||
if (fence) { offset += line.length + 1; continue; }
|
||||
}
|
||||
|
||||
const excluded = [];
|
||||
if (markdown) {
|
||||
// The Markdown renderer stashes these constructs before highlighting mentions.
|
||||
const hidden = [
|
||||
/`[^`]+`/g,
|
||||
/!?\[[^\]]*\]\([^\s)]+(?:\s+["'][^"']*["'])?\)/g,
|
||||
/\[(?:file|image|img|video)=[^\]\n]*\]/gi,
|
||||
/(?:https?:\/\/|mailto:)[^\s<>]+/gi,
|
||||
];
|
||||
for (const expression of hidden) {
|
||||
for (const match of line.matchAll(expression)) excluded.push([match.index, match.index + match[0].length]);
|
||||
}
|
||||
}
|
||||
|
||||
matcher.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = matcher.exec(line)) !== null) {
|
||||
const startInLine = match.index + match[1].length;
|
||||
if (excluded.some(([start, end]) => startInLine >= start && startInLine < end)) continue;
|
||||
const member = lookup.get(match[2].toLocaleLowerCase());
|
||||
if (!member) continue;
|
||||
const start = offset + startInLine;
|
||||
found.push({
|
||||
key: member.nickname.toLocaleLowerCase(),
|
||||
nickname: member.nickname,
|
||||
color: member.color,
|
||||
start,
|
||||
end: start + 1 + match[2].length,
|
||||
sourceLine: lineIndex + 1,
|
||||
});
|
||||
}
|
||||
offset += line.length + 1;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function createMentionNavigator({ container, list, previewContainer, previewList, editor, preview, getView, onEditorScroll }) {
|
||||
const targets = [{ container, list }];
|
||||
if (previewContainer && previewList) targets.push({ container: previewContainer, list: previewList });
|
||||
let users = [], entries = new Map(), lastJump = new Map();
|
||||
|
||||
function setUsers(value) {
|
||||
users = normalizeMentionUsers(value);
|
||||
lastJump.clear();
|
||||
}
|
||||
|
||||
function render({ markdown = true } = {}) {
|
||||
const found = collectMentionOccurrences(editor.value, users, { markdown });
|
||||
entries = new Map();
|
||||
for (const mention of found) {
|
||||
if (!entries.has(mention.key)) entries.set(mention.key, []);
|
||||
entries.get(mention.key).push(mention);
|
||||
}
|
||||
// Keep a reference to each visible mention, so the jump can target the
|
||||
// exact chip even if several mentions are on the same source line.
|
||||
if (markdown) {
|
||||
const visible = new Map();
|
||||
for (const node of preview.querySelectorAll(".markdown-mention")) {
|
||||
const key = (node.textContent || "").replace(/^@/, "").toLocaleLowerCase();
|
||||
const sourceLine = Number(node.closest("[data-source-line]")?.dataset.sourceLine);
|
||||
if (!Number.isInteger(sourceLine)) continue;
|
||||
const lookupKey = `${key}\u0000${sourceLine}`;
|
||||
if (!visible.has(lookupKey)) visible.set(lookupKey, []);
|
||||
visible.get(lookupKey).push(node);
|
||||
}
|
||||
for (const [key, matches] of entries) {
|
||||
for (const mention of matches) {
|
||||
const candidates = visible.get(`${key}\u0000${mention.sourceLine}`);
|
||||
mention.previewNode = candidates?.shift() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { container, list } of targets) {
|
||||
container.hidden = entries.size === 0;
|
||||
const badges = [];
|
||||
for (const [key, matches] of entries) {
|
||||
const first = matches[0];
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mentioned-users__badge";
|
||||
button.dataset.mentionKey = key;
|
||||
button.style.setProperty("--owner", first.color);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = `@${first.nickname}`;
|
||||
const count = document.createElement("span");
|
||||
count.className = "mentioned-users__count";
|
||||
count.textContent = String(matches.length);
|
||||
button.append(name, count);
|
||||
updateButtonHint(button, first.nickname, matches.length);
|
||||
badges.push(button);
|
||||
}
|
||||
list.replaceChildren(...badges);
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonHint(button, nickname, count, current = 0) {
|
||||
const label = t("editor.mentionJump", { name: nickname, position: current + 1, count },
|
||||
`@${nickname}: go to mention ${current + 1} of ${count}`);
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function navigatePreview(mention) {
|
||||
const node = mention.previewNode?.isConnected ? mention.previewNode
|
||||
: preview.querySelector(`[data-source-line="${mention.sourceLine}"]`);
|
||||
if (!node) return;
|
||||
for (let parent = node.parentElement; parent && parent !== preview; parent = parent.parentElement) {
|
||||
if (parent.matches("details.markdown-details")) parent.open = true;
|
||||
}
|
||||
preview.querySelector(".markdown-mention.is-mention-target")?.classList.remove("is-mention-target");
|
||||
if (node.classList.contains("markdown-mention")) node.classList.add("is-mention-target");
|
||||
const bounds = node.getBoundingClientRect();
|
||||
const viewport = preview.getBoundingClientRect();
|
||||
preview.scrollTop += bounds.top - viewport.top - Math.min(72, preview.clientHeight * 0.2);
|
||||
}
|
||||
|
||||
function jumpTo(key) {
|
||||
const matches = entries.get(key);
|
||||
if (!matches?.length) return null;
|
||||
const previous = lastJump.get(key) ?? -1;
|
||||
const index = Math.max(0, matches.findIndex(item => item.start > previous));
|
||||
const mention = matches[index];
|
||||
lastJump.set(key, mention.start);
|
||||
const view = getView();
|
||||
if (view !== "preview") {
|
||||
const style = getComputedStyle(editor);
|
||||
const lineHeight = parseFloat(style.lineHeight) || 29;
|
||||
const paddingTop = parseFloat(style.paddingTop) || 0;
|
||||
const target = paddingTop + (mention.sourceLine - 1) * lineHeight;
|
||||
editor.scrollTop = Math.max(0, target - Math.min(72, editor.clientHeight * 0.2));
|
||||
editor.setSelectionRange(mention.start, mention.end);
|
||||
editor.focus({ preventScroll: true });
|
||||
onEditorScroll?.();
|
||||
}
|
||||
if (view !== "edit") {
|
||||
navigatePreview(mention);
|
||||
// Split view scroll synchronization may run after the textarea scroll event.
|
||||
if (view === "split") requestAnimationFrame(() => {
|
||||
if (lastJump.get(key) === mention.start) navigatePreview(mention);
|
||||
});
|
||||
}
|
||||
for (const { list } of targets) {
|
||||
const button = [...list.querySelectorAll("[data-mention-key]")].find(node => node.dataset.mentionKey === key);
|
||||
if (button) {
|
||||
button.querySelector(".mentioned-users__count").textContent = `${index + 1}/${matches.length}`;
|
||||
updateButtonHint(button, mention.nickname, matches.length, (index + 1) % matches.length);
|
||||
}
|
||||
}
|
||||
return { ...mention, index, count: matches.length };
|
||||
}
|
||||
|
||||
for (const { list } of targets) {
|
||||
list.addEventListener("click", event => {
|
||||
const button = event.target.closest("button[data-mention-key]");
|
||||
if (button && list.contains(button)) jumpTo(button.dataset.mentionKey);
|
||||
});
|
||||
}
|
||||
|
||||
return { setUsers, render, jumpTo };
|
||||
}
|
||||
@@ -17,10 +17,12 @@ import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/co
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||
import { createMentionAutocomplete } from "@rustpad/mention-autocomplete";
|
||||
import { createMentionNavigator } from "@rustpad/mention-navigation";
|
||||
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
|
||||
import { previewEditingHost } from "@rustpad/preview-edit";
|
||||
import { createRenderQueue } from "@rustpad/render-queue";
|
||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, setMarkdownMentions, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
||||
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
||||
import { bindNoteFiles } from "@rustpad/note-files";
|
||||
@@ -49,7 +51,7 @@ export function startNoteEditor(adapter) {
|
||||
? crypto.randomUUID().replaceAll("-", "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
const collaboration = new CollaborationSession(collaborationClientId);
|
||||
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 = "", flushRequested = false, accountSession = null;
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", lastRevealedHeadingHash = "", flushRequested = false, accountSession = null;
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
let pendingPreviewViewport = null;
|
||||
const editHistory = {
|
||||
@@ -192,6 +194,7 @@ export function startNoteEditor(adapter) {
|
||||
editor.readOnly = readOnly;
|
||||
document.body.classList.toggle("document-read-only", readOnly);
|
||||
if (!readOnly) return;
|
||||
mentionAutocomplete.close();
|
||||
clearTimeout(saveTimer);
|
||||
saveState.textContent = label;
|
||||
const activePreviewEdit = preview.querySelector('.preview-editable[contenteditable="true"]');
|
||||
@@ -260,6 +263,37 @@ export function startNoteEditor(adapter) {
|
||||
renderFavoriteButton(false);
|
||||
}
|
||||
}
|
||||
const mentionAutocomplete = createMentionAutocomplete(editor);
|
||||
const mentionNavigator = createMentionNavigator({
|
||||
container: document.querySelector("#mentioned-users"),
|
||||
list: document.querySelector("#mentioned-users-list"),
|
||||
previewContainer: document.querySelector("#mentioned-users-preview"),
|
||||
previewList: document.querySelector("#mentioned-users-preview-list"),
|
||||
editor, preview, getView: () => activeView(), onEditorScroll: () => syncEditorLayers(),
|
||||
});
|
||||
let mentionLoadId = 0;
|
||||
async function refreshMentionUsers() {
|
||||
const requestId = ++mentionLoadId;
|
||||
setMarkdownMentions([]);
|
||||
mentionAutocomplete.setUsers([]);
|
||||
mentionNavigator.setUsers([]);
|
||||
if (!accountSession) { render(); return; }
|
||||
try {
|
||||
const params = new URLSearchParams({ kind: adapter.access.kind, slug: adapter.access.key });
|
||||
const response = await api(`/api/auth/resources/mentions?${params}`);
|
||||
if (requestId !== mentionLoadId) return;
|
||||
setMarkdownMentions(response.users || []);
|
||||
mentionAutocomplete.setUsers(response.users || []);
|
||||
mentionNavigator.setUsers(response.users || []);
|
||||
} catch {
|
||||
if (requestId !== mentionLoadId) return;
|
||||
setMarkdownMentions([]);
|
||||
mentionAutocomplete.setUsers([]);
|
||||
mentionNavigator.setUsers([]);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadNoteInfo() {
|
||||
info = await adapter.loadInfo(sessionHeaders());
|
||||
const documentTitle = document.querySelector("#document-title");
|
||||
@@ -270,6 +304,7 @@ export function startNoteEditor(adapter) {
|
||||
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
||||
if (!getAuthToken()) noteColor = readGuestColor();
|
||||
updateMarkdownFiles(info.files || []);
|
||||
void refreshMentionUsers();
|
||||
if (info.personal_editor_settings) {
|
||||
compactToggle.checked = info.compact_view !== false;
|
||||
lineToggle.checked = info.editor_line_numbers !== false;
|
||||
@@ -315,11 +350,11 @@ export function startNoteEditor(adapter) {
|
||||
setDiagnosticField("quality", qualityLabel);
|
||||
setDiagnosticField("latency", Number.isFinite(latency.current)
|
||||
? t("diagnostics.latency", {
|
||||
current: formatNumber(latency.current),
|
||||
average: formatNumber(latency.average),
|
||||
minimum: formatNumber(latency.minimum),
|
||||
maximum: formatNumber(latency.maximum),
|
||||
}, `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}–${latency.maximum} ms`)
|
||||
current: formatNumber(latency.current),
|
||||
average: formatNumber(latency.average),
|
||||
minimum: formatNumber(latency.minimum),
|
||||
maximum: formatNumber(latency.maximum),
|
||||
}, `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}–${latency.maximum} ms`)
|
||||
: t("diagnostics.waitHeartbeat", {}, "Waiting for heartbeat"));
|
||||
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${formatNumber(latency.jitter)} ms` : "—");
|
||||
setDiagnosticField("uptime", runtime.authenticated_at
|
||||
@@ -513,6 +548,29 @@ export function startNoteEditor(adapter) {
|
||||
syncEditorLayers();
|
||||
}
|
||||
|
||||
function revealHeadingAnchor() {
|
||||
if (!location.hash || location.hash === lastRevealedHeadingHash || uiState.mode !== "markdown") return;
|
||||
let id;
|
||||
try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; }
|
||||
if (!id || /^L[1-9]\d*$/i.test(id)) return;
|
||||
const heading = [...preview.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")]
|
||||
.find(element => element.id === id);
|
||||
if (!heading) return;
|
||||
for (let parent = heading.parentElement; parent && parent !== preview; parent = parent.parentElement) {
|
||||
if (parent.matches("details.markdown-details")) parent.open = true;
|
||||
}
|
||||
if (renderedView !== "edit") {
|
||||
const headingBounds = heading.getBoundingClientRect();
|
||||
const previewBounds = preview.getBoundingClientRect();
|
||||
preview.scrollTop += headingBounds.top - previewBounds.top - 16;
|
||||
}
|
||||
if (renderedView !== "preview") {
|
||||
const sourceLine = Number(heading.dataset.sourceLine);
|
||||
if (Number.isFinite(sourceLine)) scrollEditorToAnchor({ sourceLine });
|
||||
}
|
||||
lastRevealedHeadingHash = location.hash;
|
||||
}
|
||||
|
||||
function revealLinkedLine() {
|
||||
if (!location.hash || location.hash === lastRevealedLineHash) return;
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
@@ -540,6 +598,7 @@ export function startNoteEditor(adapter) {
|
||||
function previewNodeMarkdownParts(current) {
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
|
||||
const tag = current.tagName.toLowerCase();
|
||||
if (current.classList.contains("heading-permalink")) return { open: "", close: "", atomic: "" };
|
||||
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 };
|
||||
@@ -1176,6 +1235,7 @@ export function startNoteEditor(adapter) {
|
||||
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";
|
||||
}
|
||||
mentionNavigator.render({ markdown: uiState.mode === "markdown" });
|
||||
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`;
|
||||
@@ -1183,6 +1243,7 @@ export function startNoteEditor(adapter) {
|
||||
requestAnimationFrame(() => {
|
||||
if (previewViewport) restorePreviewViewport(previewViewport);
|
||||
else syncPreviewScroll();
|
||||
revealHeadingAnchor();
|
||||
});
|
||||
}
|
||||
const render = createRenderQueue(renderNow);
|
||||
@@ -1252,6 +1313,7 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
function restoreHistorySnapshot(snapshot) {
|
||||
if (!snapshot || !canEditDocument()) return;
|
||||
mentionAutocomplete.close();
|
||||
const previous = editor.value;
|
||||
const viewportScrollTop = editor.scrollTop;
|
||||
const viewportScrollLeft = editor.scrollLeft;
|
||||
@@ -1340,6 +1402,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function applyEditorDocument(content, ownerMap, { resetHistory = true } = {}) {
|
||||
if (content !== editor.value) mentionAutocomplete.close();
|
||||
if (content === editor.value) {
|
||||
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
|
||||
previousContent = content;
|
||||
@@ -1966,8 +2029,20 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(revealLinkedLine); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); });
|
||||
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
preview.addEventListener("click", event => {
|
||||
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
|
||||
if (!link) return;
|
||||
const targetHash = link.getAttribute("href");
|
||||
let headingId;
|
||||
try { headingId = decodeURIComponent(targetHash.slice(1)); } catch { return; }
|
||||
if (![...preview.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")].some(node => node.id === headingId)) return;
|
||||
event.preventDefault();
|
||||
if (location.hash !== targetHash) history.pushState(null, "", targetHash);
|
||||
lastRevealedHeadingHash = "";
|
||||
revealHeadingAnchor();
|
||||
});
|
||||
window.addEventListener("rustpad:urlchange", updateAddressLabel);
|
||||
gutter.addEventListener("click", async event => {
|
||||
const button = event.target.closest(".line-number-button[data-line]");
|
||||
@@ -2020,10 +2095,12 @@ export function startNoteEditor(adapter) {
|
||||
});
|
||||
window.addEventListener("rustpad:session-change", event => {
|
||||
accountSession = event.detail?.session || null;
|
||||
void refreshMentionUsers();
|
||||
syncFavoriteButton();
|
||||
});
|
||||
window.addEventListener("rustpad:session-expired", () => {
|
||||
accountSession = null;
|
||||
void refreshMentionUsers();
|
||||
renderFavoriteButton(false);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user