This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 12:19:33 +02:00
1 parent acfe926066
commit df403c0aa7
26 files changed
+1477 -119

No files matched your search

Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.86" version = "0.2.87"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.86" version = "0.2.87"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+23
View File
@@ -50,6 +50,10 @@ The selected interface language is stored in the browser and can be changed from
- History with snippets, previews, and version restore. - History with snippets, previews, and version restore.
- Alert blocks: `success`, `info`, `warning`, and `danger`. - Alert blocks: `success`, `info`, `warning`, and `danger`.
- Table of contents generated with `[TOC]`, using headings after the marker and nesting them by level. - Table of contents generated with `[TOC]`, using headings after the marker and nesting them by level.
- Permalinks to Markdown headings, including custom heading IDs and duplicate heading names.
- Inline `@nickname` colored mentions for active account owners and users directly granted access to a note/pad.
- Mentioned users are listed below the participant/author badges with a count of matching mentions; repeated clicks jump to the next occurrence and cycle through them in editor, split, or preview views.
- Indented fenced code blocks shift their whole preview block rather than just the code text.
- Optional line numbers in fenced code blocks. - Optional line numbers in fenced code blocks.
- PDF export with selectable A5/A4/A3, Letter, 16:9, and 4:3 page formats; optional color/background preservation keeps syntax highlighting, alerts, quotes, highlights, and table styling; `[PAGEBREAK]` creates an explicit hard PDF page break while `---` remains a normal Markdown horizontal rule. - PDF export with selectable A5/A4/A3, Letter, 16:9, and 4:3 page formats; optional color/background preservation keeps syntax highlighting, alerts, quotes, highlights, and table styling; `[PAGEBREAK]` creates an explicit hard PDF page break while `---` remains a normal Markdown horizontal rule.
@@ -103,6 +107,25 @@ print("Hello")
The `=number` suffix is a RustPad extension and may not be supported by other Markdown renderers. The `=number` suffix is a RustPad extension and may not be supported by other Markdown renderers.
Indent the opening and closing backtick fences and their content to move the entire displayed code block right by the same number of columns; RustPad strips the common indentation from the code itself.
## Heading links and member mentions
In Markdown preview, each heading includes a `#` link. Use regular Markdown links to jump to the section, for example:
```markdown
- [Run the upgrade](#run-the-upgrade)
## Run the upgrade
Step-by-step instructions here.
```
Heading IDs are created from the heading text (for example, `## Run the upgrade` becomes `#run-the-upgrade`). Repeated headings receive `-2`, `-3`, etc. You can also specify a stable ID with `## Instructions {#upgrade-steps}` and link to it using `[Instructions](#upgrade-steps)`. Opening a heading link scrolls the preview to that heading or the editor to its source line when in edit view. The existing `[TOC]` also links to these anchors.
A typed `@nickname` is shown with a colored badge only when the nickname belongs to an active registered owner or user with an accepted direct share of this pad or workspace. The list is retrieved only for logged-in owners and directly shared accounts; guests and users without a direct grant do not receive a member directory. In the Markdown source editor, typing `@` or a partial nickname such as `@do` opens an autocomplete menu of these authorized members. Use Up/Down to choose, Enter or Tab to insert the complete `@nickname`, Esc to dismiss, or click/tap a name. The list is refreshed after login or a resource reload and is never shown to guests. Mentions are visual only and do not send notifications.
The **Mentioned** row below the author/participant badges lists the recognized users currently mentioned in the note, with counts. Click `@nickname` to jump to the first mention, then click it again to cycle through the next occurrences (returning to the start). The editor selects the mention, the preview highlights it, and collapsed Markdown details sections open for a matching mention. Mentions in fenced/inline code, Markdown links, URLs, and emails are ignored. Only members returned by the resource access API are shown; the row disappears when none are mentioned or access is lost.
## Data ## Data
- SQLite database: `data/db/rustpad.db` - SQLite database: `data/db/rustpad.db`
+4
View File
@@ -466,6 +466,9 @@
"editor.markdown": "Markdown", "editor.markdown": "Markdown",
"editor.markdownPreview": "Markdown preview", "editor.markdownPreview": "Markdown preview",
"editor.mermaid": "Mermaid diagram", "editor.mermaid": "Mermaid diagram",
"editor.mentionMembers": "Mention an authorized member",
"editor.mentionedUsers": "Mentioned",
"editor.mentionJump": "@{name}: go to mention {position} of {count}",
"editor.minimum8": "Minimum 8 characters.", "editor.minimum8": "Minimum 8 characters.",
"editor.missedMerged": "A missed update was merged with your local edits.", "editor.missedMerged": "A missed update was merged with your local edits.",
"editor.mono": "Mono", "editor.mono": "Mono",
@@ -697,6 +700,7 @@
"markdown.download": "Download {label}", "markdown.download": "Download {label}",
"markdown.open": "Open {title}", "markdown.open": "Open {title}",
"markdown.playbackUnavailable": "Playback is unavailable.", "markdown.playbackUnavailable": "Playback is unavailable.",
"markdown.headingPermalink": "Link to this heading",
"markdown.toc": "Table of contents", "markdown.toc": "Table of contents",
"markdown.youtubeVideo": "YouTube video", "markdown.youtubeVideo": "YouTube video",
"pagination.items": "Page {page} of {pages} · {count} items", "pagination.items": "Page {page} of {pages} · {count} items",
+4
View File
@@ -466,6 +466,9 @@
"editor.markdown": "Markdown", "editor.markdown": "Markdown",
"editor.markdownPreview": "Podgląd Markdown", "editor.markdownPreview": "Podgląd Markdown",
"editor.mermaid": "Diagram Mermaid", "editor.mermaid": "Diagram Mermaid",
"editor.mentionMembers": "Wspomnij użytkownika z dostępem",
"editor.mentionedUsers": "Wspomniani",
"editor.mentionJump": "@{name}: przejdź do wzmianki {position} z {count}",
"editor.minimum8": "Minimum 8 znaków.", "editor.minimum8": "Minimum 8 znaków.",
"editor.missedMerged": "Pominięta aktualizacja została scalona z lokalnymi zmianami.", "editor.missedMerged": "Pominięta aktualizacja została scalona z lokalnymi zmianami.",
"editor.mono": "Monospace", "editor.mono": "Monospace",
@@ -697,6 +700,7 @@
"markdown.download": "Pobierz {label}", "markdown.download": "Pobierz {label}",
"markdown.open": "Otwórz {title}", "markdown.open": "Otwórz {title}",
"markdown.playbackUnavailable": "Odtwarzanie jest niedostępne.", "markdown.playbackUnavailable": "Odtwarzanie jest niedostępne.",
"markdown.headingPermalink": "Link do nagłówka",
"markdown.toc": "Spis treści", "markdown.toc": "Spis treści",
"markdown.youtubeVideo": "Film YouTube", "markdown.youtubeVideo": "Film YouTube",
"pagination.items": "Strona {page} z {pages} · elementów: {count}", "pagination.items": "Strona {page} z {pages} · elementów: {count}",
+5 -26
View File
@@ -288,12 +288,7 @@ pub async fn list_favorites(
for note in notes { for note in notes {
if (note.private_resource != 0 || note.protected_resource != 0) if (note.private_resource != 0 || note.protected_resource != 0)
&& !has_header_resource_access( && !has_header_resource_access(&state, &headers, "workspace", &note.workspace_slug)
&state,
&headers,
"workspace",
&note.workspace_slug,
)
.await? .await?
{ {
continue; continue;
@@ -389,11 +384,7 @@ mod tests {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
header::COOKIE, header::COOKIE,
HeaderValue::from_str(&format!( HeaderValue::from_str(&format!("{}={token}", crate::security::SESSION_COOKIE)).unwrap(),
"{}={token}",
crate::security::SESSION_COOKIE
))
.unwrap(),
); );
(headers, user_id) (headers, user_id)
} }
@@ -412,11 +403,7 @@ mod tests {
}; };
for _ in 0..2 { for _ in 0..2 {
let result = add_favorite( let result = add_favorite(State(state.clone()), headers.clone(), Json(target()))
State(state.clone()),
headers.clone(),
Json(target()),
)
.await .await
.unwrap(); .unwrap();
assert!(result.0.favorite); assert!(result.0.favorite);
@@ -828,11 +815,7 @@ mod tests {
workspace_slug: None, workspace_slug: None,
}; };
let anonymous = add_favorite( let anonymous = add_favorite(State(state.clone()), HeaderMap::new(), Json(target()))
State(state.clone()),
HeaderMap::new(),
Json(target()),
)
.await .await
.expect_err("anonymous favorite must fail"); .expect_err("anonymous favorite must fail");
assert_eq!(anonymous.into_response().status(), StatusCode::FORBIDDEN); assert_eq!(anonymous.into_response().status(), StatusCode::FORBIDDEN);
@@ -843,11 +826,7 @@ mod tests {
.execute(state.db.pool()) .execute(state.db.pool())
.await .await
.unwrap(); .unwrap();
let inactive = add_favorite( let inactive = add_favorite(State(state), inactive_headers, Json(target()))
State(state),
inactive_headers,
Json(target()),
)
.await .await
.expect_err("inactive account favorite must fail"); .expect_err("inactive account favorite must fail");
assert_eq!(inactive.into_response().status(), StatusCode::FORBIDDEN); assert_eq!(inactive.into_response().status(), StatusCode::FORBIDDEN);
+5 -2
View File
@@ -125,6 +125,7 @@ pub fn router(
"/api/auth/resources/privacy", "/api/auth/resources/privacy",
post(auth::set_resource_privacy), post(auth::set_resource_privacy),
) )
.route("/api/auth/resources/mentions", get(auth::resource_mentions))
.route( .route(
"/api/auth/resources/sharing", "/api/auth/resources/sharing",
get(auth::resource_sharing) get(auth::resource_sharing)
@@ -261,7 +262,6 @@ pub fn router(
.with_state(state) .with_state(state)
} }
async fn language_catalog() -> Response { async fn language_catalog() -> Response {
let mut response = Json(crate::i18n::metadata()).into_response(); let mut response = Json(crate::i18n::metadata()).into_response();
response.headers_mut().insert( response.headers_mut().insert(
@@ -375,7 +375,10 @@ fn is_file_path(path: &str) -> bool {
} }
fn is_asset_path(path: &str) -> bool { fn is_asset_path(path: &str) -> bool {
path == "/assets" || path.starts_with("/assets/") || path == "/lang" || path.starts_with("/lang/") path == "/assets"
|| path.starts_with("/assets/")
|| path == "/lang"
|| path.starts_with("/lang/")
} }
fn is_icon_path(path: &str) -> bool { fn is_icon_path(path: &str) -> bool {
+22 -17
View File
@@ -81,15 +81,8 @@ async fn share_session_redirect(
) -> Response { ) -> Response {
let client_key = crate::security::client_key(headers); let client_key = crate::security::client_key(headers);
let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) { let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) {
Some(share) => match auth::create_share_session( Some(share) => {
state, match auth::create_share_session(state, kind, slug, share, &client_key).await {
kind,
slug,
share,
&client_key,
)
.await
{
Ok(Some(session)) => Some(crate::security::share_session_cookie( Ok(Some(session)) => Some(crate::security::share_session_cookie(
kind, kind,
slug, slug,
@@ -101,7 +94,8 @@ async fn share_session_redirect(
tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session"); tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session");
None None
} }
}, }
}
None => None, None => None,
}; };
@@ -118,10 +112,9 @@ async fn share_session_redirect(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"), HeaderValue::from_static("no-store, max-age=0"),
); );
response.headers_mut().insert( response
"referrer-policy", .headers_mut()
HeaderValue::from_static("no-referrer"), .insert("referrer-policy", HeaderValue::from_static("no-referrer"));
);
response response
} }
@@ -383,7 +376,11 @@ pub(super) async fn workspace(
) )
.replace( .replace(
"__WORKSPACE_TITLE_I18N__", "__WORKSPACE_TITLE_I18N__",
if workspace.is_private != 0 { r#"data-i18n="common.workspace""# } else { "" }, if workspace.is_private != 0 {
r#"data-i18n="common.workspace""#
} else {
""
},
); );
assets::render_html( assets::render_html(
&html, &html,
@@ -460,7 +457,11 @@ pub(super) async fn note(
} else { } else {
&note.title &note.title
}, },
if workspace.is_private != 0 { Some("common.note") } else { None }, if workspace.is_private != 0 {
Some("common.note")
} else {
None
},
if workspace.is_private != 0 { if workspace.is_private != 0 {
"Workspace" "Workspace"
} else { } else {
@@ -548,7 +549,11 @@ pub(super) fn error_response(
let html = include_str!("../../static/error.html") let html = include_str!("../../static/error.html")
.replace( .replace(
"__APP_THEME_BOOTSTRAP__", "__APP_THEME_BOOTSTRAP__",
&format!("{}{}", assets::theme_bootstrap(), assets::language_bootstrap()), &format!(
"{}{}",
assets::theme_bootstrap(),
assets::language_bootstrap()
),
) )
.replace( .replace(
"__APP_STYLESHEET__", "__APP_STYLESHEET__",
+2
View File
@@ -31,6 +31,8 @@ const MODULES: &[&str] = &[
"logger", "logger",
"line-links", "line-links",
"markdown", "markdown",
"mention-autocomplete",
"mention-navigation",
"modal", "modal",
"note-api", "note-api",
"note-editor", "note-editor",
+64 -6
View File
@@ -1791,6 +1791,69 @@ pub async fn remove_resource_user(
Ok(Json(serde_json::json!({"ok":true}))) Ok(Json(serde_json::json!({"ok":true})))
} }
/// Return only explicitly authorized account names; never expose e-mail addresses.
/// The requester must themselves own the resource or have an accepted direct share.
pub async fn resource_mentions(
State(state): State<SharedState>,
headers: HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, AuthError> {
let user = require_user(&state, &headers).await?;
let kind = params.get("kind").map(String::as_str).unwrap_or("");
let slug = params.get("slug").map(String::as_str).unwrap_or("").trim();
let (query, owner_query) = match kind {
"workspace" => (
queries::RESOURCE_MENTION_USERS_WORKSPACE,
queries::USER_OWNS_WORKSPACE,
),
"pad" => (queries::RESOURCE_MENTION_USERS_PAD, queries::USER_OWNS_PAD),
_ => return Err(AuthError::bad_request("Unknown resource type.")),
};
if slug.is_empty() {
return Err(AuthError::bad_request("Missing slug."));
}
let owned: i64 = sqlx::query_scalar(queries::get(state.db.kind(), owner_query))
.bind(user.id)
.bind(slug)
.fetch_one(state.db.pool())
.await
.map_err(AuthError::database)?;
if owned == 0 {
let access: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_BY_USER,
))
.bind(kind)
.bind(slug)
.bind(user.id)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
if access.is_none() {
return Err(AuthError::forbidden(
"This resource was not shared with your account.",
));
}
}
let rows = sqlx::query(queries::get(state.db.kind(), query))
.bind(slug)
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let users: Vec<_> = rows
.iter()
.map(|row| -> Result<serde_json::Value, AuthError> {
Ok(serde_json::json!({
"nickname": crate::row_decode::text(row, 0).map_err(AuthError::database)?,
"color": crate::row_decode::optional_text(row, 1).map_err(AuthError::database)?,
}))
})
.collect::<Result<_, _>>()?;
Ok(Json(serde_json::json!({ "users": users })))
}
pub async fn resource_sharing( pub async fn resource_sharing(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
@@ -2262,12 +2325,7 @@ pub async fn logout(
let mut revoked_access_tokens = 0_u64; let mut revoked_access_tokens = 0_u64;
if session_token.is_some() || has_access_tokens { if session_token.is_some() || has_access_tokens {
let mut tx = state let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
.db
.pool()
.begin()
.await
.map_err(AuthError::database)?;
if let Some(token) = session_token.as_deref() { if let Some(token) = session_token.as_deref() {
let result = sqlx::query(queries::get( let result = sqlx::query(queries::get(
+3 -6
View File
@@ -76,8 +76,6 @@ pub struct Note {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ArchivedNote { pub struct ArchivedNote {
#[serde(skip_serializing)]
pub id: i64,
pub slug: String, pub slug: String,
pub title: String, pub title: String,
pub created_at: String, pub created_at: String,
@@ -345,20 +343,20 @@ pub async fn list_archived_notes(
) -> Result<Vec<ArchivedNote>, sqlx::Error> { ) -> Result<Vec<ArchivedNote>, sqlx::Error> {
let query = match pool.kind() { let query = match pool.kind() {
DatabaseKind::Sqlite => { DatabaseKind::Sqlite => {
r#"SELECT id, slug, title, created_at, updated_at, deleted_at, protected, created_by r#"SELECT slug, title, created_at, updated_at, deleted_at, protected, created_by
FROM notes FROM notes
WHERE workspace_id = ? AND deleted_at IS NOT NULL WHERE workspace_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC, id DESC"# ORDER BY deleted_at DESC, id DESC"#
} }
DatabaseKind::Postgres => { DatabaseKind::Postgres => {
r#"SELECT id, slug, title, created_at, updated_at, deleted_at, r#"SELECT slug, title, created_at, updated_at, deleted_at,
CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by
FROM notes FROM notes
WHERE workspace_id = $1 AND deleted_at IS NOT NULL WHERE workspace_id = $1 AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC, id DESC"# ORDER BY deleted_at DESC, id DESC"#
} }
DatabaseKind::MySql => { DatabaseKind::MySql => {
r#"SELECT id, CAST(slug AS CHAR CHARACTER SET utf8mb4) AS slug, r#"SELECT CAST(slug AS CHAR CHARACTER SET utf8mb4) AS slug,
CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, created_at, updated_at, deleted_at, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, created_at, updated_at, deleted_at,
CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected,
CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by
@@ -374,7 +372,6 @@ pub async fn list_archived_notes(
rows.into_iter() rows.into_iter()
.map(|row| { .map(|row| {
Ok(ArchivedNote { Ok(ArchivedNote {
id: row.try_get("id")?,
slug: crate::row_decode::text(&row, "slug")?, slug: crate::row_decode::text(&row, "slug")?,
title: crate::row_decode::text(&row, "title")?, title: crate::row_decode::text(&row, "title")?,
created_at: crate::row_decode::text(&row, "created_at")?, created_at: crate::row_decode::text(&row, "created_at")?,
+4
View File
@@ -114,6 +114,8 @@ pub enum Query {
SHARE_INVITATION_FIND_TOKEN, SHARE_INVITATION_FIND_TOKEN,
SHARE_INVITATION_ACCEPT, SHARE_INVITATION_ACCEPT,
RESOURCE_SHARING_USERS, RESOURCE_SHARING_USERS,
RESOURCE_MENTION_USERS_WORKSPACE,
RESOURCE_MENTION_USERS_PAD,
RESOURCE_SHARING_LINKS, RESOURCE_SHARING_LINKS,
RESOURCE_SHARING_PENDING, RESOURCE_SHARING_PENDING,
SHARE_LINK_INSERT, SHARE_LINK_INSERT,
@@ -296,6 +298,8 @@ pub const SHARE_INVITATION_DELETE_TOKEN: Query = Query::SHARE_INVITATION_DELETE_
pub const SHARE_INVITATION_FIND_TOKEN: Query = Query::SHARE_INVITATION_FIND_TOKEN; pub const SHARE_INVITATION_FIND_TOKEN: Query = Query::SHARE_INVITATION_FIND_TOKEN;
pub const SHARE_INVITATION_ACCEPT: Query = Query::SHARE_INVITATION_ACCEPT; pub const SHARE_INVITATION_ACCEPT: Query = Query::SHARE_INVITATION_ACCEPT;
pub const RESOURCE_SHARING_USERS: Query = Query::RESOURCE_SHARING_USERS; pub const RESOURCE_SHARING_USERS: Query = Query::RESOURCE_SHARING_USERS;
pub const RESOURCE_MENTION_USERS_WORKSPACE: Query = Query::RESOURCE_MENTION_USERS_WORKSPACE;
pub const RESOURCE_MENTION_USERS_PAD: Query = Query::RESOURCE_MENTION_USERS_PAD;
pub const RESOURCE_SHARING_LINKS: Query = Query::RESOURCE_SHARING_LINKS; pub const RESOURCE_SHARING_LINKS: Query = Query::RESOURCE_SHARING_LINKS;
pub const RESOURCE_SHARING_PENDING: Query = Query::RESOURCE_SHARING_PENDING; pub const RESOURCE_SHARING_PENDING: Query = Query::RESOURCE_SHARING_PENDING;
pub const SHARE_LINK_INSERT: Query = Query::SHARE_LINK_INSERT; pub const SHARE_LINK_INSERT: Query = Query::SHARE_LINK_INSERT;
+12 -2
View File
@@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
} }
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_UPDATE_LANGUAGE => {
r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => { Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -276,6 +278,12 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_SHARING_USERS => { Query::RESOURCE_SHARING_USERS => {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
} }
Query::RESOURCE_MENTION_USERS_WORKSPACE => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_MENTION_USERS_PAD => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_SHARING_LINKS => { Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
} }
@@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str {
Query::Q001 => { Query::Q001 => {
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS SIGNED) AS archive_deleted_notes, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"# r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS SIGNED) AS archive_deleted_notes, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"#
} }
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q002 => {
r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#
}
Query::Q003 => { Query::Q003 => {
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"#
} }
+12 -2
View File
@@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"# r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
} }
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#, Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"#, Query::AUTH_UPDATE_LANGUAGE => {
r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#, Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
Query::RESOURCE_COLOR_BY_USER => { Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
@@ -278,6 +280,12 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_SHARING_USERS => { Query::RESOURCE_SHARING_USERS => {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"# r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
} }
Query::RESOURCE_MENTION_USERS_WORKSPACE => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = TRUE AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = $1 AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = $2 AND rp.resource_slug = $3 AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_MENTION_USERS_PAD => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = TRUE AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = $1 AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = $2 AND rp.resource_slug = $3 AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_SHARING_LINKS => { Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"# r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
} }
@@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str {
Query::Q001 => { Query::Q001 => {
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS BIGINT) AS archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = $1"# r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS BIGINT) AS archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = $1"#
} }
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#, Query::Q002 => {
r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#
}
Query::Q003 => { Query::Q003 => {
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"#
} }
+12 -2
View File
@@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
} }
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_UPDATE_LANGUAGE => {
r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => { Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -276,6 +278,12 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_SHARING_USERS => { Query::RESOURCE_SHARING_USERS => {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
} }
Query::RESOURCE_MENTION_USERS_WORKSPACE => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_MENTION_USERS_PAD => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_SHARING_LINKS => { Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
} }
@@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str {
Query::Q001 => { Query::Q001 => {
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = ?"# r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = ?"#
} }
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q002 => {
r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#
}
Query::Q003 => { Query::Q003 => {
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"#
} }
+206 -6
View File
@@ -3171,7 +3171,7 @@ dialog::backdrop {
font-size: .7rem; 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: "·"; content: "·";
margin-right: 8px; margin-right: 8px;
color: var(--border-strong); color: var(--border-strong);
@@ -3360,8 +3360,13 @@ dialog::backdrop {
} }
@keyframes file-skeleton-pulse { @keyframes file-skeleton-pulse {
from { background-position: 100% 0; } from {
to { background-position: -100% 0; } background-position: 100% 0;
}
to {
background-position: -100% 0;
}
} }
.ui-spinner { .ui-spinner {
@@ -3382,10 +3387,13 @@ dialog::backdrop {
} }
@keyframes ui-spinner-rotate { @keyframes ui-spinner-rotate {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.file-row, .file-row,
.file-skeleton, .file-skeleton,
.ui-spinner { .ui-spinner {
@@ -9091,7 +9099,6 @@ dialog::backdrop {
} }
} }
/* PDF export */
#pdf-export-dialog { #pdf-export-dialog {
width: min(520px, calc(100% - 28px)); width: min(520px, calc(100% - 28px));
max-width: 520px; max-width: 520px;
@@ -9164,7 +9171,200 @@ dialog::backdrop {
align-items: stretch; align-items: stretch;
} }
#pdf-export-dialog .dialog-actions > button { #pdf-export-dialog .dialog-actions>button {
flex: 1 1 0; 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
View File
@@ -27,7 +27,8 @@
<p id="document-url" class="document-url"></p> <p id="document-url" class="document-url"></p>
</div> </div>
<button id="favorite-toggle" class="favorite-toggle" type="button" aria-pressed="false" <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>
</div> </div>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button" <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> <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button> </button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy <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"> <details class="page-settings">
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span <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 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="definition">Definition</button><button type="button"
data-format="highlight">Highlight</button><button type="button" data-format="highlight">Highlight</button><button type="button"
data-format="subscript">Subscript</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> </details>
</div> </div>
<button id="mobile-upload-button" class="mobile-upload-button" type="button" <button id="mobile-upload-button" class="mobile-upload-button" type="button" title="Upload a file"><span
title="Upload a file"><span aria-hidden="true"></span> Upload</button> aria-hidden="true"></span> Upload</button>
<div class="editor-controls"><label>Font<select class="app-select-input" id="font-family"> <div class="editor-controls"><label>Font<select class="app-select-input" id="font-family">
<option value="mono">Mono</option> <option value="mono">Mono</option>
<option value="system">System</option> <option value="system">System</option>
@@ -141,8 +145,8 @@
<option value="18">18</option> <option value="18">18</option>
<option value="20">20</option> <option value="20">20</option>
<option value="22">22</option> <option value="22">22</option>
</select></label></div><button id="upload-button" </select></label></div><button id="upload-button" class="toolbar-action"
class="toolbar-action" title="Upload a file">Upload</button><input id="file-input" type="file" hidden><label 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 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"> 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> 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 aria-pressed="true" aria-label="Markdown" title="Markdown"><span
class="control-label-full">Markdown</span><span class="control-label-short" class="control-label-full">Markdown</span><span class="control-label-short"
aria-hidden="true">M</span></button> aria-hidden="true">M</span></button>
<button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button" <button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button" aria-pressed="false"
aria-pressed="false" aria-label="Hide editor toolbar" title="Hide editor toolbar"><span aria-label="Hide editor toolbar" title="Hide editor toolbar"><span
class="toolbar-collapse-toggle__icon" aria-hidden="true">⌃</span></button> class="toolbar-collapse-toggle__icon" aria-hidden="true">⌃</span></button>
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active" <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 aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
@@ -185,6 +189,11 @@
</div> </div>
</div> </div>
<div id="participant-badges" class="participant-badges" aria-label="Participants" hidden></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 class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div> <div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div> <div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
@@ -194,6 +203,11 @@
</div> </div>
<div class="preview-column"> <div class="preview-column">
<div id="preview-label" class="column-label">Markdown preview</div> <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> <article id="preview" class="preview markdown-body"></article>
</div> </div>
</div> </div>
@@ -272,9 +286,12 @@
<p>Author, time, and version preview</p> <p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button> </div><button id="close-history" class="icon-button">×</button>
</div> </div>
<div class="history-view-switch" role="group" aria-label="History view" data-i18n-aria-label="editor.historyView"> <div class="history-view-switch" role="group" aria-label="History view"
<button type="button" data-history-view="list" class="active" aria-pressed="true" data-i18n="editor.historyListView">List</button> data-i18n-aria-label="editor.historyView">
<button type="button" data-history-view="archive" aria-pressed="false" data-i18n="editor.historyArchiveView">Archive</button> <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>
<div id="history-list" class="history-list"></div> <div id="history-list" class="history-list"></div>
</aside> </aside>
@@ -283,7 +300,8 @@
<form id="pdf-export-form" method="dialog" class="dialog-panel pdf-export-panel"> <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> <button class="modal-close" type="button" data-pdf-cancel aria-label="Close dialog">×</button>
<h2>Export PDF</h2> <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"> <div class="pdf-export-fields">
<label><span>Page format</span><select class="app-select-input" id="pdf-page-format"> <label><span>Page format</span><select class="app-select-input" id="pdf-page-format">
<option value="a4-portrait">A4 — Portrait</option> <option value="a4-portrait">A4 — Portrait</option>
@@ -303,12 +321,18 @@
</select></label> </select></label>
</div> </div>
<div class="pdf-export-options"> <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-title-toggle"><input id="pdf-include-title" type="checkbox">
<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> 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> </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> <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> </form>
</dialog> </dialog>
<dialog id="shortcuts-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 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>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 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>
</div> </div>
</dialog> </dialog>
@@ -336,8 +361,7 @@
<p>Copy a direct link or ready Markdown/Alias code.</p> <p>Copy a direct link or ready Markdown/Alias code.</p>
</div> </div>
<div class="files-head-actions"> <div class="files-head-actions">
<button id="files-upload-button" class="primary-button" <button id="files-upload-button" class="primary-button" type="button">Upload file</button>
type="button">Upload file</button>
<button id="close-files" class="icon-button" type="button" aria-label="Close files">×</button> <button id="close-files" class="icon-button" type="button" aria-label="Close files">×</button>
</div> </div>
</div> </div>
@@ -456,7 +480,8 @@
</div> </div>
</details> </details>
</div> </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> </body>
</html> </html>
+77 -23
View File
@@ -18,6 +18,42 @@ function escapeHtml(value) {
const emoji = EMOJI_SHORTCODES; const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map(); let markdownFiles = new Map();
let markdownFileRoutes = 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) { function attachmentRoute(value) {
try { 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 `${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) { function stripFenceIndent(line, columns) {
let html = "", inCode = false, fence = "", language = "", codeLineStart = null, code = [], codeStart = 0; 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 lines = String(source).split("\n");
const headings = collectHeadings(lines); const headings = documentHeadings || collectHeadings(lines).map(item => ({ ...item, index: item.index + lineOffset }));
const headingByLine = new Map(headings.map(item => [item.index, item])); const headingByLine = new Map(headings.map(item => [item.index - lineOffset, item]));
const footnotes = new Map(); const footnotes = new Map();
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
@@ -425,39 +471,45 @@ export function renderMarkdown(source, lineOffset = 0) {
const closeCode = () => { const closeCode = () => {
const body = escapeHtml(code.join("\n")); const body = escapeHtml(code.join("\n"));
const lang = normalizeLanguage(language); const lang = normalizeLanguage(language);
const indentStyle = fenceIndent > 0 ? ` style="margin-left:${fenceIndent}ch"` : "";
if (lang === "mermaid") { 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) { } else if (codeLineStart !== null) {
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join(""); 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)}"` : ""; 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 { } else {
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; 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++) { for (let index = 0; index < lines.length; index++) {
const line = lines[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) { if (fenceMatch) {
const marker = fenceMatch[2];
if (inCode && marker[0] === fence[0] && marker.length >= fence.length && !fenceMatch[3]) {
closeCode();
inCode = false;
continue;
}
if (!inCode) {
closeList(); closeList();
if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode(); fence = marker;
else if (!inCode) { fenceIndent = fenceMatch[1].replace(/\t/g, " ").length;
fence = fenceMatch[1]; const info = fenceMatch[3] || "";
const info = fenceMatch[2] || ""; // ```rust=, ```python=101, ```= and ```=101 enable line numbers.
// Generic syntax for every fenced code block:
// ```rust=, ```python=101, ```= or ```=101.
const numbered = info.match(/^(.*?)=(\d*)$/); const numbered = info.match(/^(.*?)=(\d*)$/);
language = numbered ? numbered[1] : info; language = numbered ? numbered[1] : info;
codeLineStart = numbered ? Number(numbered[2] || 1) : null; codeLineStart = numbered ? Number(numbered[2] || 1) : null;
codeStart = index; codeStart = index;
} inCode = true;
inCode = !inCode;
continue; continue;
} }
if (inCode) { code.push(line); continue; } }
if (inCode) { code.push(stripFenceIndent(line, fenceIndent)); continue; }
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1); const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
if (standaloneMedia) { if (standaloneMedia) {
@@ -500,7 +552,7 @@ export function renderMarkdown(source, lineOffset = 0) {
} }
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++; while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
const body = lines.slice(bodyStart, end).join("\n"); 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; index = end;
continue; continue;
} }
@@ -508,7 +560,7 @@ export function renderMarkdown(source, lineOffset = 0) {
if (/^\s*\[TOC\]\s*$/i.test(line)) { if (/^\s*\[TOC\]\s*$/i.test(line)) {
closeList(); closeList();
const tocHeadings = headings.filter(item => item.index > index); const tocHeadings = headings.filter(item => item.index > index + lineOffset);
if (tocHeadings.length) { 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>`; 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) { if (end < lines.length) {
const type = alertStart[1].toLowerCase(); const type = alertStart[1].toLowerCase();
const body = lines.slice(index + 1, end).join("\n"); 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; index = end;
continue; continue;
} }
@@ -558,7 +610,9 @@ export function renderMarkdown(source, lineOffset = 0) {
const resolved = headingByLine.get(index); const resolved = headingByLine.get(index);
const id = resolved?.id || heading[3] || headingSlug(heading[2]); const id = resolved?.id || heading[3] || headingSlug(heading[2]);
const suffix = heading[3] ? ` {#${heading[3]}}` : ""; 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 { } else {
closeList(); closeList();
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]); const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
+275
View File
@@ -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();
},
};
}
+200
View File
@@ -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 };
}
+81 -4
View File
@@ -17,10 +17,12 @@ import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/co
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format"; 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 { updateImageAliasInLineBySource } from "@rustpad/image-alias";
import { previewEditingHost } from "@rustpad/preview-edit"; import { previewEditingHost } from "@rustpad/preview-edit";
import { createRenderQueue } from "@rustpad/render-queue"; 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 { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files"; import { bindNoteFiles } from "@rustpad/note-files";
@@ -49,7 +51,7 @@ export function startNoteEditor(adapter) {
? crypto.randomUUID().replaceAll("-", "") ? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId); 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 editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
let pendingPreviewViewport = null; let pendingPreviewViewport = null;
const editHistory = { const editHistory = {
@@ -192,6 +194,7 @@ export function startNoteEditor(adapter) {
editor.readOnly = readOnly; editor.readOnly = readOnly;
document.body.classList.toggle("document-read-only", readOnly); document.body.classList.toggle("document-read-only", readOnly);
if (!readOnly) return; if (!readOnly) return;
mentionAutocomplete.close();
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveState.textContent = label; saveState.textContent = label;
const activePreviewEdit = preview.querySelector('.preview-editable[contenteditable="true"]'); const activePreviewEdit = preview.querySelector('.preview-editable[contenteditable="true"]');
@@ -260,6 +263,37 @@ export function startNoteEditor(adapter) {
renderFavoriteButton(false); 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() { async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders()); info = await adapter.loadInfo(sessionHeaders());
const documentTitle = document.querySelector("#document-title"); const documentTitle = document.querySelector("#document-title");
@@ -270,6 +304,7 @@ export function startNoteEditor(adapter) {
globalColor = info.global_color || ""; noteColor = info.note_color || ""; globalColor = info.global_color || ""; noteColor = info.note_color || "";
if (!getAuthToken()) noteColor = readGuestColor(); if (!getAuthToken()) noteColor = readGuestColor();
updateMarkdownFiles(info.files || []); updateMarkdownFiles(info.files || []);
void refreshMentionUsers();
if (info.personal_editor_settings) { if (info.personal_editor_settings) {
compactToggle.checked = info.compact_view !== false; compactToggle.checked = info.compact_view !== false;
lineToggle.checked = info.editor_line_numbers !== false; lineToggle.checked = info.editor_line_numbers !== false;
@@ -513,6 +548,29 @@ export function startNoteEditor(adapter) {
syncEditorLayers(); 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() { function revealLinkedLine() {
if (!location.hash || location.hash === lastRevealedLineHash) return; if (!location.hash || location.hash === lastRevealedLineHash) return;
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
@@ -540,6 +598,7 @@ export function startNoteEditor(adapter) {
function previewNodeMarkdownParts(current) { function previewNodeMarkdownParts(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null }; if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
const tag = current.tagName.toLowerCase(); 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 === "strong" || tag === "b") return { open: "**", close: "**", atomic: null };
if (tag === "em" || tag === "i") return { open: "*", close: "*", atomic: null }; if (tag === "em" || tag === "i") return { open: "*", close: "*", atomic: null };
if (tag === "s" || tag === "del") return { open: "~~", close: "~~", atomic: null }; if (tag === "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(""); 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"; document.querySelector("#preview-label").textContent = "Text preview";
} }
mentionNavigator.render({ markdown: uiState.mode === "markdown" });
alignPreviewLineNumbers(preview); alignPreviewLineNumbers(preview);
document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#characters").textContent = `${editor.value.length} characters`;
document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`;
@@ -1183,6 +1243,7 @@ export function startNoteEditor(adapter) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (previewViewport) restorePreviewViewport(previewViewport); if (previewViewport) restorePreviewViewport(previewViewport);
else syncPreviewScroll(); else syncPreviewScroll();
revealHeadingAnchor();
}); });
} }
const render = createRenderQueue(renderNow); const render = createRenderQueue(renderNow);
@@ -1252,6 +1313,7 @@ export function startNoteEditor(adapter) {
function restoreHistorySnapshot(snapshot) { function restoreHistorySnapshot(snapshot) {
if (!snapshot || !canEditDocument()) return; if (!snapshot || !canEditDocument()) return;
mentionAutocomplete.close();
const previous = editor.value; const previous = editor.value;
const viewportScrollTop = editor.scrollTop; const viewportScrollTop = editor.scrollTop;
const viewportScrollLeft = editor.scrollLeft; const viewportScrollLeft = editor.scrollLeft;
@@ -1340,6 +1402,7 @@ export function startNoteEditor(adapter) {
} }
function applyEditorDocument(content, ownerMap, { resetHistory = true } = {}) { function applyEditorDocument(content, ownerMap, { resetHistory = true } = {}) {
if (content !== editor.value) mentionAutocomplete.close();
if (content === editor.value) { if (content === editor.value) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content; 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("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); }); 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); window.addEventListener("rustpad:urlchange", updateAddressLabel);
gutter.addEventListener("click", async event => { gutter.addEventListener("click", async event => {
const button = event.target.closest(".line-number-button[data-line]"); const button = event.target.closest(".line-number-button[data-line]");
@@ -2020,10 +2095,12 @@ export function startNoteEditor(adapter) {
}); });
window.addEventListener("rustpad:session-change", event => { window.addEventListener("rustpad:session-change", event => {
accountSession = event.detail?.session || null; accountSession = event.detail?.session || null;
void refreshMentionUsers();
syncFavoriteButton(); syncFavoriteButton();
}); });
window.addEventListener("rustpad:session-expired", () => { window.addEventListener("rustpad:session-expired", () => {
accountSession = null; accountSession = null;
void refreshMentionUsers();
renderFavoriteButton(false); renderFavoriteButton(false);
}); });
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
globalThis.location = {origin:'https://example.org'};
let source = readFileSync('static/js/markdown.js','utf8')
.replace(/^import .*;$/gm, '');
source = `const EMOJI_SHORTCODES = {}; const t = (key,vars,alternative) => alternative; const parseImageAlias = () => null;\n` + source;
const {renderMarkdown, setMarkdownMentions} = await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
function has(re, text, message) { assert.match(text,re,message); }
let html = renderMarkdown('# Instrukcja testowa\n[Przejdź](#instrukcja-testowa)\n# Instrukcja testowa\n[TOC]\n## Etap drugi');
has(/<h1 id="instrukcja-testowa"/,html,'first anchor');
has(/<h1 id="instrukcja-testowa-2"/,html,'duplicate heading anchor');
has(/href="#instrukcja-testowa"/,html,'link to section');
has(/heading-permalink/,html,'visible link icon');
has(/href="#etap-drugi"/,html,'TOC anchor');
html = renderMarkdown(' ```js\n const value = 1;\n console.log(value);\n ```\nafter');
has(/<pre[^>]*style="margin-left:2ch"[^>]*><code class="language-javascript">const value = 1;\n console\.log\(value\);<\/code><\/pre>/,html,'code container indentation + dedent');
has(/<p[^>]*>after<\/p>/,html,'code closed');
html = renderMarkdown(' ```python=42\n print(42)\n ```');
has(/class="code-with-lines[^>]*style="margin-left:4ch"/,html,'numbered fenced code whole block shifted');
has(/data-line="42">print\(42\)/,html,'numbered content dedented');
html = renderMarkdown(' ```\n ~~~\n ```');
has(/<code>~~~<\/code>/,html,'other marker must remain inside code');
html = renderMarkdown('```\n## hidden in code\n```\n## Visible');
assert.doesNotMatch(html, /id="hidden-in-code"/);
has(/id="visible"/,html,'headings outside code');
setMarkdownMentions([{nickname:'Ala',color:'#aabbcc'},{nickname:'John+Dev',color:'red'}]);
html = renderMarkdown('Cześć @Ala i @John+Dev! @Other @Alana a@Ala `@Ala` [@Ala](https://site.test)');
assert.equal((html.match(/class="markdown-mention"/g)||[]).length,2,'valid mentions only');
has(/style="--owner:#aabbcc"/,html,'real author color');
has(/@Other @Alana a@Ala/,html,'nonmembers and suffixes not chips');
has(/<code>@Ala<\/code>/,html,'inline code excluded');
has(/<a href="https:\/\/site.test\/"[^>]*>@Ala<\/a>/,html,'links excluded');
assert.doesNotMatch(renderMarkdown('```\n@Ala\n```'), /markdown-mention/);
html = renderMarkdown('Sentence @Ala. Domain @Ala.example');
assert.equal((html.match(/class="markdown-mention"/g) || []).length, 1, 'punctuation after a mention is allowed, domains are not');
setMarkdownMentions([]);
assert.doesNotMatch(renderMarkdown('@Ala'), /markdown-mention/);
console.log('PASS: anchors, TOC, duplicate headings, indented fences, numbered blocks, mentions, exclusions');
html = renderMarkdown('[TOC]\n# Procedura\n<details>\n<summary>Szczegóły</summary>\n## Procedura\n</details>');
has(/id="procedura"/,html);
has(/id="procedura-2"/,html,'nested heading anchor unique');
has(/href="#procedura-2"/,html,'TOC link for heading inside details');
console.log('PASS: anchors in nested details match TOC');
setMarkdownMentions([{nickname: 'a[1]\\x', color: '#123456'}, {nickname: '<f>&"', color: '#112233'}]);
html = renderMarkdown('Hello @a[1]\\x and @<f>&" but not @a[1]\\xy');
assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames');
assert.doesNotMatch(html, /<f>/, 'no raw HTML from nickname');
console.log('PASS: special-character nicknames remain escaped');
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Browser checks for @mention suggestions. Run from repository root."""
from __future__ import annotations
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
HTML = '''<!doctype html><html lang="pl"><head>
<meta charset="utf-8">
__STYLES__
<style>
body { padding: 18px; }
.editor-shell { width: min(580px, 90vw); height: 340px; }
#editor { height: 100%; }
</style></head><body>
<div class="editor-shell">
<div class="line-gutter"></div>
<textarea id="editor" wrap="off" spellcheck="false"></textarea>
</div>
<script type="module">
__SCRIPT__
window.autocomplete = createMentionAutocomplete(document.querySelector('#editor'));
window.mentionInputTypes = [];
document.querySelector('#editor').addEventListener('input', e => window.mentionInputTypes.push(e.inputType));
</script></body></html>'''
def main():
source = (ROOT / 'static/js/mention-autocomplete.js').read_text()
source = source.replace('import { t } from "@rustpad/i18n";', 'const t = (key, vars, fallback) => fallback;')
styles = (ROOT / 'static/css/styles.css').read_text()
html = HTML.replace('__STYLES__', f'<style>{styles}</style>').replace('__SCRIPT__', source)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])
try:
page = browser.new_page(viewport={"width": 870, "height": 550})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.set_content(html)
page.wait_for_function("!!window.autocomplete")
editor = page.locator('#editor')
options = page.locator('.mention-suggestions__option')
page.evaluate('''() => window.autocomplete.setUsers([
{nickname:'Dominik',color:'#ff0000'},
{nickname:'Dora',color:'#00ff00'},
{nickname:'Jan Domański',color:'#ff8800'},
{nickname:'Anna',color:'red'}
])''')
editor.fill('Witaj @do')
assert options.count() == 3, options.all_text_contents()
assert options.all_text_contents() == ['@Dominik', '@Dora', '@Jan Domański']
assert editor.get_attribute('aria-expanded') == 'true'
assert options.nth(0).get_attribute('aria-selected') == 'true'
editor.press('ArrowDown')
assert options.nth(1).get_attribute('aria-selected') == 'true'
editor.press('Enter')
assert editor.input_value() == 'Witaj @Dora '
assert page.evaluate('window.mentionInputTypes.at(-1)') == 'insertReplacementText'
assert page.locator('.mention-suggestions').is_hidden()
editor.fill('Test @do')
editor.press('ArrowDown')
editor.press('ArrowDown')
editor.press('Tab')
assert editor.input_value() == 'Test @Jan Domański '
editor.fill('@d')
assert options.count() == 3
editor.press('Escape')
assert page.locator('.mention-suggestions').is_hidden()
editor.press('ArrowDown')
assert page.locator('.mention-suggestions').is_hidden(), 'Escape must dismiss the query'
editor.fill('@do')
options.first.click()
assert editor.input_value() == '@Dominik '
editor.fill('user@example.org')
assert page.locator('.mention-suggestions').is_hidden(), 'email should not trigger suggestions'
editor.fill('hello @unknown')
assert page.locator('.mention-suggestions').is_hidden(), 'only authorized members should be shown'
editor.fill('Hi @')
assert options.count() == 4
page.evaluate('window.autocomplete.setUsers([])')
assert page.locator('.mention-suggestions').is_hidden(), 'clear users on logout'
editor.fill('Hi @do')
assert page.locator('.mention-suggestions').is_hidden(), 'no guest suggestions'
page.evaluate('window.autocomplete.setUsers([{nickname:"Dominik",color:"#ff0000"}])')
editor.fill('Hi @do')
assert options.count() == 1
geometry = page.locator('.mention-suggestions').bounding_box()
shell_geometry = page.locator('.editor-shell').bounding_box()
assert geometry['x'] >= shell_geometry['x'] - 1
assert geometry['y'] >= shell_geometry['y'] - 1
assert geometry['x'] + geometry['width'] <= shell_geometry['x'] + shell_geometry['width'] + 1
assert geometry['y'] + geometry['height'] <= shell_geometry['y'] + shell_geometry['height'] + 1
editor.fill('Edycja @do.')
page.evaluate('''() => {let e = document.querySelector('#editor'); e.setSelectionRange(10,10); e.dispatchEvent(new Event('keyup'));}''')
assert options.count() == 1
editor.press('Enter')
assert editor.input_value() == 'Edycja @Dominik.' # no unnecessary space before punctuation
assert not errors, errors
print('PASS: Chromium editor completion, keyboard/mouse, privacy, positioning, punctuation')
finally:
browser.close()
if __name__ == '__main__':
main()
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
let source = readFileSync('static/js/mention-autocomplete.js', 'utf8');
source = source.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, vars, fallback) => fallback;');
const { mentionAtCaret, filterMentionUsers, normalizeMentionUsers } = await import(
'data:text/javascript;base64,' + Buffer.from(source).toString('base64')
);
assert.deepEqual(mentionAtCaret('Cześć @do', 9), { start: 6, end: 9, query: 'do' });
assert.deepEqual(mentionAtCaret('@', 1), { start: 0, end: 1, query: '' });
assert.deepEqual(mentionAtCaret('Pierwsza linia\nWitaj (@Żół', 26), { start: 22, end: 26, query: 'Żół' });
assert.equal(mentionAtCaret('john@example.org', 12), null, 'do not suggest in addresses');
assert.equal(mentionAtCaret('abc@do', 6), null, 'no mention inside a word');
assert.equal(mentionAtCaret('Hello @do ', 10), null, 'space ends a query');
assert.equal(mentionAtCaret('Hello @do-more', 14)?.query, 'do-more', 'support dashes');
assert.equal(mentionAtCaret('Hello @@do', 10), null, 'double at is not a mention');
assert.equal(mentionAtCaret('Hello @do', -1), null);
const members = normalizeMentionUsers([
{ nickname: 'Dominik', color: '#ff0000', email: 'secret@example.org' },
{ nickname: 'DOMINIK', color: '#aaaaaa' },
{ nickname: 'Dora', color: 'red' },
{ nickname: 'Jan Domański', color: '#00ff00' },
{ nickname: '', color: '#00ff00' },
{ nickname: 'Joanna', color: '#123abc' },
]);
assert.equal(members.length, 4, 'deduplicate case insensitive, skip empty');
assert.deepEqual(filterMentionUsers(members, 'do').map(user => user.nickname), ['Dominik', 'Dora', 'Jan Domański']);
assert.equal(filterMentionUsers(members, 'DO')[0].nickname, 'Dominik');
assert.equal(filterMentionUsers(members, 'zz').length, 0);
assert.equal(members[0].color, '#ff0000');
assert.match(members[1].color, /^hsl\(/, 'invalid user color falls back safely');
assert.equal(Object.hasOwn(members[0], 'email'), false, 'no email addresses in UI model');
console.log('PASS: mention context, filtering, normalization, privacy');
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Browser integration test for the mentioned-users bar and cyclic jumps."""
from __future__ import annotations
import base64
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
HTML = '''<!doctype html><html lang="pl"><head><meta charset="utf-8">
<style>__STYLES__</style>
<style>
body { padding: 20px; }
.workspace { width: min(970px, calc(100vw - 40px)); height: 400px; grid-template-columns: 1fr 1fr; }
.editor-column { display: flex; flex-direction: column; height: 100%; border: 1px solid #aaa; }
.preview-column { display: grid; min-width: 0; min-height: 0; }
.editor-shell { flex: 1 1 auto; min-height: 0; }
#editor { box-sizing: border-box; width: 100%; height: 100%; padding: 18px; line-height: 22px; }
#preview { box-sizing: border-box; width: 100%; height: 100%; overflow: auto; border: 1px solid #aaa; padding: 18px; }
</style>
</head><body class="pad-page">
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label">Editor</div>
<div class="participant-badges"><span class="participant-badge">Autor</span></div>
<div id="mentioned-users" class="mentioned-users" role="group" hidden>
<span class="mentioned-users__label">Wspomniani</span>
<div id="mentioned-users-list" class="mentioned-users__list"></div>
</div>
<div class="editor-shell"><textarea id="editor" wrap="off"></textarea></div>
</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" hidden>
<span class="mentioned-users__label">Wspomniani</span>
<div id="mentioned-users-preview-list" class="mentioned-users__list"></div>
</div>
<article class="markdown-body preview" id="preview"></article>
</div>
</div>
<script type="module">
import {createMentionNavigator} from '__NAVIGATION_URL__';
import {setMarkdownMentions,renderMarkdown} from '__MARKDOWN_URL__';
const editor = document.querySelector('#editor');
const preview = document.querySelector('#preview');
let view = 'split', members = [];
const navigator = 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: () => view, onEditorScroll: () => {},
});
window.runCase = (source, users) => {
editor.value = source; members = users;
navigator.setUsers(members);
setMarkdownMentions(members);
preview.innerHTML = renderMarkdown(source);
navigator.render({markdown:true});
};
window.setView = value => {
view=value;
document.querySelector('#editor-workspace').className = `workspace view-${value}`;
};
window.setUsers = value => {
navigator.setUsers(value);
setMarkdownMentions(value);
preview.innerHTML = renderMarkdown(editor.value);
navigator.render({markdown:true});
};
window.ready = true;
</script></body></html>'''
def as_data_url(source):
return 'data:text/javascript;base64,' + base64.b64encode(source.encode('utf-8')).decode('ascii')
def create_fixture():
autocomplete = (ROOT / 'static/js/mention-autocomplete.js').read_text()
autocomplete = autocomplete.replace('import { t } from "@rustpad/i18n";',
'const t = (key, values, fallback) => fallback;')
navigation = (ROOT / 'static/js/mention-navigation.js').read_text()
navigation = navigation.replace('import { t } from "@rustpad/i18n";',
'const t = (key, values, fallback) => fallback;')
navigation = navigation.replace('"@rustpad/mention-autocomplete"',
f'"{as_data_url(autocomplete)}"')
markdown = (ROOT / 'static/js/markdown.js').read_text()
markdown = '\n'.join(line for line in markdown.split('\n') if not line.startswith('import '))
markdown = ('const EMOJI_SHORTCODES = {}; '
'const t = (key, values, fallback) => fallback; '
'const parseImageAlias = () => null;\n') + markdown
styles = (ROOT / 'static/css/styles.css').read_text()
return (HTML.replace('__STYLES__', styles)
.replace('__NAVIGATION_URL__', as_data_url(navigation))
.replace('__MARKDOWN_URL__', as_data_url(markdown)))
def main():
fixture = create_fixture()
with sync_playwright() as playwright:
browser = playwright.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox'])
try:
page = browser.new_page(viewport={'width': 1020, 'height': 680})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(fixture)
page.wait_for_function('window.ready === true')
lines = [
'Na początku @Dominik i @Dominik, do tego @Dora.',
'`@Dominik` [@Dominik](https://example.com/@Dora)',
'```sh',
'@Dominik',
'```',
*[f'Wypełnienie linii {i}' for i in range(1, 18)],
'<details>',
'<summary>Szczegóły</summary>',
'Wewnątrz @Dominik',
'</details>',
*[f'Treść linii {i}' for i in range(18, 38)],
'Na końcu @Dominik.',
]
content = '\n'.join(lines)
users = [{'nickname': 'Dominik', 'color': '#123456'}, {'nickname':'Dora', 'color':'#654321', 'email':'private@example.org'}]
page.evaluate('([text,users]) => window.runCase(text,users)', [content, users])
bar = page.locator('#mentioned-users')
assert bar.is_visible()
badges = page.locator('#mentioned-users-list .mentioned-users__badge')
preview_badges = page.locator('#mentioned-users-preview-list .mentioned-users__badge')
assert page.locator('#mentioned-users-preview').is_hidden()
assert badges.count() == 2
assert preview_badges.count() == 2
assert badges.all_text_contents() == ['@Dominik4', '@Dora1']
assert 'private' not in bar.inner_text()
button = badges.first
# The two code/link occurrences are intentionally skipped.
source_starts = [content.index('@Dominik'),content.index('@Dominik',content.index('@Dominik')+1),content.index('@Dominik',content.index('Wewnątrz')), content.rindex('@Dominik')]
for step, start in enumerate(source_starts, 1):
button.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == start, step
assert page.evaluate('document.querySelector("#editor").selectionEnd') == start + len('@Dominik')
assert page.locator('.markdown-mention.is-mention-target').count() == 1
assert button.locator('.mentioned-users__count').inner_text() == f'{step}/4'
assert page.locator('details.markdown-details').evaluate('element => element.open') is True
button.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[0]
assert button.locator('.mentioned-users__count').inner_text() == '1/4'
dora = badges.nth(1)
dora.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == content.index('@Dora')
assert dora.locator('.mentioned-users__count').inner_text() == '1/1'
page.evaluate('window.setView("preview")')
assert bar.is_hidden()
assert page.locator('#mentioned-users-preview').is_visible()
assert page.locator('#preview').is_visible()
original_selection = page.evaluate('document.querySelector("#editor").selectionStart')
preview_badges.first.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == original_selection
assert page.locator('.markdown-mention.is-mention-target').count() == 1
page.evaluate('window.setView("edit")')
assert bar.is_visible()
assert page.locator('#mentioned-users-preview').is_hidden()
button.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[2]
page.evaluate('window.setUsers([])')
assert bar.is_hidden()
assert page.locator('#mentioned-users-preview').is_hidden()
assert badges.count() == 0
assert preview_badges.count() == 0
assert not errors, errors
print('PASS: Chromium mentions list, access, cyclic navigation, source selection, details, view switching')
finally:
browser.close()
if __name__ == '__main__':
main()
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const autocomplete = readFileSync('static/js/mention-autocomplete.js', 'utf8')
.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;');
const autocompleteModule = await import('data:text/javascript;base64,' + Buffer.from(autocomplete).toString('base64'));
const navigation = readFileSync('static/js/mention-navigation.js', 'utf8')
.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;')
.replace(/^import \{ normalizeMentionUsers \} from "@rustpad\/mention-autocomplete";$/m,
`const normalizeMentionUsers = ${autocompleteModule.normalizeMentionUsers.toString()};`);
const { collectMentionOccurrences } = await import('data:text/javascript;base64,' + Buffer.from(navigation).toString('base64'));
const users = [
{nickname:'Dominik',color:'#1256aa',email:'private@example.org'},
{nickname:'dOMINIK',color:'#abc123'},
{nickname:'Dora',color:'#dd00dd'},
{nickname:'Ala+Dev',color:'#123abc'},
];
function matches(source, options) { return collectMentionOccurrences(source, users, options); }
function valid(source, options) { return matches(source, options).map(mention => source.slice(mention.start, mention.end)); }
assert.deepEqual(valid('Tu @Dominik, @Dora oraz @dominik.'), ['@Dominik', '@Dora', '@dominik']);
assert.deepEqual(valid('Przykład @DominiK @Ala+Dev, @Dominik'), ['@DominiK', '@Ala+Dev', '@Dominik']);
assert.deepEqual(valid('john@Dominik.org a@Dora @@Dominik @Dominika @Other'), []);
assert.deepEqual(valid('`@Dominik` i @Dora [@Dominik](https://example.org) oraz https://example.org/@Dora'), ['@Dora']);
assert.deepEqual(valid('[file=secret@Dominik,key] i [image=image@Dora] oraz @Dominik'), ['@Dominik']);
assert.deepEqual(valid('```sh\n@Dominik\n```\nPo @Dora\n~~~\n@Dora\n~~~\n@Dominik'), ['@Dora', '@Dominik']);
assert.deepEqual(valid(' ```js\n@Dominik\n ```\n@Dominik'), ['@Dominik']);
assert.deepEqual(valid('````rust\n@Dominik\n```\n@Dora\n````\n@Dora'), ['@Dora']);
assert.deepEqual(valid('@Dora', { markdown:false }), ['@Dora']);
assert.deepEqual(valid('```\n@Dominik\n```', { markdown:false }), ['@Dominik']);
const source = '🙂 @Dominik\nDrugi @Dora i @Dominik';
const items = matches(source);
assert.deepEqual(items.map(item=>item.start), [source.indexOf('@Dominik'),source.indexOf('@Dora'),source.lastIndexOf('@Dominik')]);
assert.deepEqual(items.map(item=>item.sourceLine), [1,2,2]);
assert.ok(items.every(item => !Object.hasOwn(item, 'email')));
assert.equal(collectMentionOccurrences('Hej @Dominik', []).length, 0);
console.log('PASS: mention navigation positions, permissions, Markdown exclusions, UTF-16, code fences');