diff --git a/Cargo.lock b/Cargo.lock index 848d56c..3b96c61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2433,7 +2433,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.11" +version = "0.0.13" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index e4dac3d..6c8d9cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.11" +version = "0.0.13" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index e28c595..f3fac16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# RustPad 0.0.1 +# RustPad -Collaborative Markdown editor with standalone notes and workspaces. +RustPad is a collaborative Markdown editor with standalone notes and workspaces. ## Development setup @@ -8,46 +8,102 @@ Collaborative Markdown editor with standalone notes and workspaces. ./dev.sh ``` -The script creates `data/db` and `data/files`, builds the project, and runs it with Cargo. When Cargo is unavailable, it uses `docker compose up --build`. +The script creates `data/db` and `data/files`, builds the project, and starts it with Cargo. If Cargo is unavailable, it runs `docker compose up --build` instead. -## Functions (Workspaces) +## Workspace features -- real-time collaborative editing over WebSocket, -- nickname remembered in `localStorage`, -- change authors in history, -- line numbering enabled by default with a persistent toggle, -- owner color next to each line, -- upload images and files to `data/files/pads/_/` lub `data/files/notes/_/`, -- automatic Markdown link insertion after upload, -- Markdown i diagramy Mermaid, -- history with snippets, previews, and version restore. +- Real-time collaborative editing over WebSocket. +- Nicknames stored in `localStorage`. +- Change authors shown in history. +- Line numbering enabled by default, with a persistent toggle. +- Owner color displayed next to each line. +- Image and file uploads to `data/files/pads/_/` or `data/files/notes/_/`. +- Automatic Markdown link insertion after upload. +- Markdown and Mermaid diagram rendering. +- History with snippets, previews, and version restore. +- Alert blocks: `success`, `info`, `warning`, and `danger`. +- Table of contents generated with `[TOC]`. +- Optional line numbers in fenced code blocks. + +## Fenced code blocks and language aliases + +RustPad recognizes common language names and aliases, including: + +- JavaScript: `js`, `javascript`, `jsx` +- TypeScript: `ts`, `typescript`, `tsx` +- Python: `py`, `python` +- PHP: `php` +- Rust: `rs`, `rust` +- Shell: `sh`, `shell`, `bash`, `zsh` +- C and C++: `c`, `h`, `cpp`, `c++`, `cxx`, `hpp` +- C#: `cs`, `c#`, `csharp` +- Java, Kotlin, Go, Swift, Dart, Scala +- HTML, XML, SVG, CSS, SCSS, Sass, Less +- JSON, YAML, TOML, INI, SQL, GraphQL +- Markdown, Dockerfile, Makefile, PowerShell +- Lua, Perl, R, MATLAB, Nginx, Apache, Diff, and plain text + +Standard code block: + +````markdown +```python +print("Hello") +``` +```` + +Code block with line numbers starting from line 1: + +````markdown +```python= +print("Hello") +``` +```` + +Code block with line numbers starting from a custom value: + +````markdown +```python=101 +print("Hello") +``` +```` + +The `=number` suffix is a RustPad extension and may not be supported by other Markdown renderers. ## Data -- SQLite: `data/db/rustpad.db`, -- files: `data/files/pads/_/` i `data/files/notes/_/`; the public URL has the form `/f//`. +- SQLite database: `data/db/rustpad.db` +- Attachments: `data/files/pads/_/` and `data/files/notes/_/` +- Public attachment URL: `/f//` In Docker, both directories are located under `/data`. ## Publishing a note as a page -Use the **Page** button in the editor. RustPad creates a permanent public `/s/` address, copies it to the clipboard, and opens it in a new tab. The page displays the current note content and renders Markdown, images, links, and Mermaid. Publishing a protected note requires the password, but the published link itself is public. +Use the **Page** button in the editor. RustPad creates a permanent public `/s/` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, links, and Mermaid diagrams. -## Limit uploadu +Publishing a protected note requires its password, but the generated public page itself is accessible without that password. -The maximum size of a single file is configured with `UPLOAD_MAX_SIZE_MB` w `.env`, np. `UPLOAD_MAX_SIZE_MB=50`. The default is 20 MB. After changing it, restart the project with `./dev.sh`. +## Upload limit -## Wybór bazy danych +Configure the maximum size of a single uploaded file with `UPLOAD_MAX_SIZE_MB` in `.env`, for example: -RustPad use db engine via `DATABASE_URL`: +```env +UPLOAD_MAX_SIZE_MB=50 +``` + +The default limit is 20 MB. Restart the project with `./dev.sh` after changing it. + +## Database selection + +RustPad selects the database engine through `DATABASE_URL`: - SQLite: `sqlite:///data/db/rustpad.db?mode=rwc&journal_mode=WAL&busy_timeout=5000` - PostgreSQL: `postgres://rustpad:rustpad@postgres:5432/rustpad` - MySQL: `mysql://rustpad:rustpad@mysql:3306/rustpad` -SQLite pozostaje domyślną bazą dla developmentu i małych instalacji. Tryb WAL pozwala czytać podczas zapisu, ale SQLite nadal wykonuje tylko jeden zapis naraz. `busy_timeout=5000` powoduje krótkie oczekiwanie zamiast natychmiastowego błędu `database is locked`. Przy wielu równoczesnych edytorach lub wielu instancjach aplikacji zalecany jest PostgreSQL albo MySQL. +SQLite remains the default for development and small installations. WAL mode allows reads during writes, but SQLite still performs only one write at a time. `busy_timeout=5000` waits briefly instead of immediately returning a `database is locked` error. PostgreSQL or MySQL is recommended for many concurrent editors or multiple application instances. -Opcjonalne db inDocker Compose: +Optional databases in Docker Compose: ```bash # PostgreSQL @@ -59,17 +115,13 @@ docker compose --profile mysql up -d mysql DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad docker compose up -d rustpad ``` -Migracje są rozdzielone w `migrations/sqlite`, `migrations/postgres` i `migrations/mysql`. Zapytania aplikacji znajdują się centralnie w `src/queries.rs`, a `src/database.rs` odpowiada za wybór sterownika i konfigurację połączenia. - +Migrations are stored in `migrations/sqlite`, `migrations/postgres`, and `migrations/mysql`. Runtime SQL statements are centralized in `src/queries.rs`, while `src/database.rs` selects the driver and configures the connection. ## Optional user accounts and password reset -Nicknames can still be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections. Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset e-mails. Reset links expire after 30 minutes and are single-use. - -## Database query layout - -All runtime SQL statements are centralized in `src/queries.rs`. Backend modules reference named constants, which keeps database-specific debugging and query review in one place. +Nicknames can be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections. +Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. Reset links expire after 30 minutes and can be used only once. ## Diagnostics and logging @@ -79,28 +131,29 @@ Server logs use `tracing`. Configure verbosity with `RUST_LOG`, for example: RUST_LOG=rustpad=debug,tower_http=info ``` -Important lifecycle, database, authentication, password-reset and WebSocket events are logged. Passwords, session tokens, reset tokens, SMTP credentials and authorization headers are never logged. +Important lifecycle, database, authentication, password-reset, and WebSocket events are logged. Passwords, session tokens, reset tokens, SMTP credentials, and authorization headers are never logged. -Browser diagnostics are configured separately from backend logs with `FRONTEND_LOG_LEVEL`. Supported values are `off`, `error`, `warn`, `info`, and `debug`; the default is `warn`. URL parameters cannot enable diagnostics. Use `debug` only in trusted development environments. Production should normally use `warn` or `error`. +Browser diagnostics are configured separately with `FRONTEND_LOG_LEVEL`. Supported values are `off`, `error`, `warn`, `info`, and `debug`; the default is `warn`. URL parameters cannot enable diagnostics. Use `debug` only in trusted development environments. Production should normally use `warn` or `error`. +## Registration and SMTP -### Rejestracja i SMTP +Set `REGISTRATION_ENABLED=true` to enable registration. After an account is created, the application sends an SMTP message containing the nickname and `PUBLIC_URL`. + +Set `ACCOUNT_CONFIRMATION_REQUIRED=true` to require users to click a confirmation link before signing in. This option is disabled by default and requires SMTP configuration. -`REGISTRATION_ENABLED=true` włącza rejestrację. Po utworzeniu konta aplikacja wysyła przez SMTP wiadomość z nickiem i adresem `PUBLIC_URL`. `ACCOUNT_CONFIRMATION_REQUIRED=true` wymaga dodatkowo kliknięcia linku potwierdzającego przed logowaniem; domyślnie opcja jest wyłączona i wymaga skonfigurowanego SMTP. ## Attachment storage RustPad supports two interchangeable attachment backends selected in `.env`: -- `STORAGE_DRIVER=local` stores files under `FILES_DIR` (default). -- `STORAGE_DRIVER=s3` uses any S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack or MinIO. +- `STORAGE_DRIVER=local` stores files under `FILES_DIR` and is the default. +- `STORAGE_DRIVER=s3` uses an S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack, or MinIO. -The public application URLs remain `/f/{token}/{filename}` for both backends. RustPad checks access and streams the object through the API, so no bucket needs to be public and existing database records do not need migration. +Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration. -For the optional Docker Garage service, set the S3 variables shown in `.env.example`, use strong unique credentials, and start: +For the optional Docker Garage service, configure the S3 variables shown in `.env.example`, use strong unique credentials, and run: ```sh docker compose --profile s3 up -d --build ``` -Garage is a separate Compose service and the existing `pgsql` and `mysql` profiles remain unchanged. The included single-node setup is intended for local/self-hosted development without redundancy; production Garage deployments should use an appropriately designed multi-node configuration. - +Garage runs as a separate Compose service. Existing PostgreSQL and MySQL profiles remain unchanged. The included single-node setup is intended for local or self-hosted development without redundancy. Production Garage deployments should use a properly designed multi-node configuration. diff --git a/static/css/styles.css b/static/css/styles.css index abe9899..bbb1666 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1083,11 +1083,11 @@ dialog::backdrop { padding-right: 8px; } -.hide-line-numbers .editor-shell { +.hide-editor-line-numbers .editor-shell { grid-template-columns: 0 minmax(0, 1fr); } -.hide-line-numbers .line-gutter { +.hide-editor-line-numbers .line-gutter { width: 0; padding: 0; border: 0; @@ -1460,7 +1460,7 @@ dialog::backdrop { white-space: nowrap; } -.hide-line-numbers .owner-labels { +.hide-editor-line-numbers .owner-labels { left: 0; } @@ -2173,19 +2173,19 @@ dialog::backdrop { /* Preview editing and source line numbers. */ .preview { + position: relative; padding: 12px 24px 12px 62px; line-height: 1.32; } .preview-source-line { - position: relative; min-height: 1.32em; } .preview-source-line::before { content: attr(data-source-line); position: absolute; - right: calc(100% + 18px); + left: 12px; width: 32px; color: #596270; text-align: right; @@ -2231,11 +2231,11 @@ dialog::backdrop { line-height: 1.24; } -.hide-line-numbers .preview { +.hide-preview-line-numbers .preview { padding-left: 24px; } -.hide-line-numbers .preview-source-line::before { +.hide-preview-line-numbers .preview-source-line::before { display: none; } @@ -2338,7 +2338,7 @@ dialog::backdrop { line-height: 1.32 } -.hide-line-numbers .markdown-body .task-list-item::before { +.hide-preview-line-numbers .markdown-body .task-list-item::before { display: none } @@ -3737,4 +3737,55 @@ dialog::backdrop { .share-dialog-footer { border-radius: 0 0 14px 14px; } -} \ No newline at end of file +} +/* Markdown alerts. */ +.markdown-body .markdown-alert { + margin: 1em 0; + padding: .8em 1em; + border: 1px solid; + border-left-width: 4px; + border-radius: 8px; +} +.markdown-body .markdown-alert > :first-child { margin-top: 0; } +.markdown-body .markdown-alert > :last-child { margin-bottom: 0; } +.markdown-body .markdown-alert--success { border-color: #2f855a; background: rgba(47,133,90,.14); } +.markdown-body .markdown-alert--info { border-color: #3182ce; background: rgba(49,130,206,.14); } +.markdown-body .markdown-alert--warning { border-color: #d69e2e; background: rgba(214,158,46,.14); } +.markdown-body .markdown-alert--danger { border-color: #c53030; background: rgba(197,48,48,.14); } + +/* Fenced code line numbers for every language: ```lang=, ```lang=101, ```= or ```=101. */ +.markdown-body pre.code-with-lines code { counter-reset: none; } +.markdown-body pre.code-with-lines .code-line { + display: block; + min-height: 1.35em; + padding-left: 4.6em; + position: relative; + white-space: pre; +} +.markdown-body pre.code-with-lines .code-line::before { + content: attr(data-line); + position: absolute; + left: 0; + width: 3.4em; + padding-right: .8em; + border-right: 1px solid var(--border); + color: var(--muted-2); + text-align: right; + user-select: none; +} + +/* Generated table of contents. */ +.markdown-body .markdown-toc { + margin: 1em 0; + padding: .8em 1em; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2); +} +.markdown-body .markdown-toc ol { margin: 0; padding-left: 1.4em; } +.markdown-body .markdown-toc li { margin: .25em 0; } +.markdown-body .markdown-toc .toc-level-2 { margin-left: 1em; } +.markdown-body .markdown-toc .toc-level-3 { margin-left: 2em; } +.markdown-body .markdown-toc .toc-level-4, +.markdown-body .markdown-toc .toc-level-5, +.markdown-body .markdown-toc .toc-level-6 { margin-left: 3em; } diff --git a/static/js/image-upload.js b/static/js/image-upload.js index a8c1bdd..f14c0ea 100644 --- a/static/js/image-upload.js +++ b/static/js/image-upload.js @@ -11,10 +11,10 @@ export async function prepareImageFile(file){ const dialog=document.createElement("dialog"); dialog.className="image-editor-dialog"; dialog.innerHTML=`
-

Adjust image

Drag to crop, use zoom and choose output size.

+

Adjust image

Keep the whole image or choose a crop, then select output size.

- +
@@ -27,7 +27,7 @@ export async function prepareImageFile(file){ function cropBox(){ const rect=stage.getBoundingClientRect(); let width=Math.max(280,rect.width),height=Math.min(520,Math.max(260,rect.height)); - const aspect=aspectSelect.value==="free"?width/height:Number(aspectSelect.value); + const aspect=aspectSelect.value==="original"?image.naturalWidth/image.naturalHeight:aspectSelect.value==="free"?width/height:Number(aspectSelect.value); if(width/height>aspect)width=height*aspect;else height=width/aspect; return {width:Math.round(width),height:Math.round(height)}; } @@ -35,7 +35,9 @@ export async function prepareImageFile(file){ const box=cropBox(),dpr=Math.min(devicePixelRatio||1,2); canvas.width=Math.round(box.width*dpr);canvas.height=Math.round(box.height*dpr);canvas.style.width=`${box.width}px`;canvas.style.height=`${box.height}px`; ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,box.width,box.height); - const base=Math.max(box.width/image.naturalWidth,box.height/image.naturalHeight),scale=base*Number(zoomInput.value); + const wholeImage=aspectSelect.value==="original"; + zoomInput.disabled=wholeImage; + const base=wholeImage?Math.min(box.width/image.naturalWidth,box.height/image.naturalHeight):Math.max(box.width/image.naturalWidth,box.height/image.naturalHeight),scale=base*(wholeImage?1:Number(zoomInput.value)); const drawW=image.naturalWidth*scale,drawH=image.naturalHeight*scale; const maxX=Math.max(0,(drawW-box.width)/2),maxY=Math.max(0,(drawH-box.height)/2); offsetX=clamp(offsetX,-maxX,maxX);offsetY=clamp(offsetY,-maxY,maxY); @@ -50,8 +52,16 @@ export async function prepareImageFile(file){ const result=new Promise(resolve=>{ dialog.addEventListener("close",()=>{URL.revokeObjectURL(url);dialog.remove();resolve(accepted);},{once:true}); dialog.querySelector("[data-apply]").addEventListener("click",async()=>{ - const box=cropBox(),maxSize=Number(sizeSelect.value),ratio=Math.min(1,maxSize?maxSize/Math.max(box.width,box.height):1),out=document.createElement("canvas"); - out.width=Math.max(1,Math.round(box.width*ratio));out.height=Math.max(1,Math.round(box.height*ratio));out.getContext("2d").drawImage(canvas,0,0,out.width,out.height); + const box=cropBox(),maxSize=Number(sizeSelect.value),out=document.createElement("canvas"); + if(aspectSelect.value==="original"){ + const ratio=Math.min(1,maxSize?maxSize/Math.max(image.naturalWidth,image.naturalHeight):1); + out.width=Math.max(1,Math.round(image.naturalWidth*ratio));out.height=Math.max(1,Math.round(image.naturalHeight*ratio)); + out.getContext("2d").drawImage(image,0,0,out.width,out.height); + }else{ + const ratio=Math.min(1,maxSize?maxSize/Math.max(box.width,box.height):1); + out.width=Math.max(1,Math.round(box.width*ratio));out.height=Math.max(1,Math.round(box.height*ratio)); + out.getContext("2d").drawImage(canvas,0,0,out.width,out.height); + } const mime=file.type==="image/png"?"image/png":"image/jpeg"; const blob=await new Promise(r=>out.toBlob(r,mime,mime==="image/jpeg"?.88:undefined)); const ext=mime==="image/png"?"png":"jpg"; diff --git a/static/js/markdown.js b/static/js/markdown.js index e4c759d..17652e8 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -54,6 +54,41 @@ function inline(value) { return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); } + +const languageAliases = { + js: "javascript", javascript: "javascript", jsx: "javascript", + ts: "typescript", typescript: "typescript", tsx: "typescript", + py: "python", python: "python", + rb: "ruby", ruby: "ruby", + rs: "rust", rust: "rust", + php: "php", + sh: "bash", shell: "bash", bash: "bash", zsh: "bash", + c: "c", h: "c", + cpp: "cpp", "c++": "cpp", cxx: "cpp", hpp: "cpp", + cs: "csharp", "c#": "csharp", csharp: "csharp", + java: "java", kotlin: "kotlin", kt: "kotlin", + go: "go", golang: "go", + swift: "swift", dart: "dart", scala: "scala", + html: "html", htm: "html", xml: "xml", svg: "xml", + css: "css", scss: "scss", sass: "scss", less: "less", + json: "json", jsonc: "json", yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini", + sql: "sql", graphql: "graphql", gql: "graphql", + md: "markdown", markdown: "markdown", + dockerfile: "dockerfile", docker: "dockerfile", + makefile: "makefile", make: "makefile", + powershell: "powershell", ps1: "powershell", + lua: "lua", perl: "perl", pl: "perl", r: "r", matlab: "matlab", + nginx: "nginx", apache: "apache", diff: "diff", patch: "diff", + text: "plaintext", txt: "plaintext", plaintext: "plaintext", none: "plaintext", + mermaid: "mermaid" +}; + +function normalizeLanguage(value) { + const language = String(value || "").trim().toLowerCase(); + if (!language) return ""; + return languageAliases[language] || language.replace(/[^a-z0-9_-]/g, ""); +} + const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`; const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line); @@ -70,9 +105,44 @@ function tableDelimiter(line) { return cells.map(cell => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left"); } +function headingSlug(value) { + return String(value) + .replace(/\{#[A-Za-z][\w:.-]*\}\s*$/, "") + .replace(/[`*_~^=<>]/g, "") + .replace(/:([a-z0-9_+-]+):/gi, "$1") + .toLowerCase().trim() + .replace(/[^a-z0-9\u00c0-\u024f\u1e00-\u1eff]+/g, "-") + .replace(/^-+|-+$/g, "") || "section"; +} + +function collectHeadings(lines) { + const used = new Map(); + const headings = []; + let fence = null; + lines.forEach((line, index) => { + const fenceMatch = line.match(/^\s*(```+|~~~+)/); + if (fenceMatch) { + if (!fence) fence = fenceMatch[1]; + else if (fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null; + return; + } + if (fence) return; + const match = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); + if (!match) return; + const base = match[3] || headingSlug(match[2]); + const count = used.get(base) || 0; + used.set(base, count + 1); + const id = count ? `${base}-${count + 1}` : base; + headings.push({level: match[1].length, text: match[2], id, index}); + }); + return headings; +} + export function renderMarkdown(source, lineOffset = 0) { - let html = "", inCode = false, fence = "", language = "", code = [], codeStart = 0, list = null; + let html = "", inCode = false, fence = "", language = "", codeLineStart = null, code = [], codeStart = 0, list = null; const lines = String(source).split("\n"); + const headings = collectHeadings(lines); + const headingByLine = new Map(headings.map(item => [item.index, item])); const footnotes = new Map(); for (let i = 0; i < lines.length; i++) { @@ -92,10 +162,18 @@ export function renderMarkdown(source, lineOffset = 0) { const closeList = () => { if (list) { html += ``; list = null; } }; const closeCode = () => { const body = escapeHtml(code.join("\n")); - html += language.toLowerCase() === "mermaid" - ? `
${body}
` - : `${body}`; - code = []; language = ""; fence = ""; + const lang = normalizeLanguage(language); + if (lang === "mermaid") { + html += `
${body}
`; + } else if (codeLineStart !== null) { + const numbered = body.split("\n").map((line, index) => `${line || " "}`).join("\n"); + const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; + html += `${numbered}`; + } else { + const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; + html += `${body}`; + } + code = []; language = ""; codeLineStart = null; fence = ""; }; for (let index = 0; index < lines.length; index++) { @@ -104,7 +182,16 @@ export function renderMarkdown(source, lineOffset = 0) { if (fenceMatch) { closeList(); if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode(); - else if (!inCode) { fence = fenceMatch[1]; language = fenceMatch[2] || ""; codeStart = index; } + else if (!inCode) { + fence = fenceMatch[1]; + const info = fenceMatch[2] || ""; + // Generic syntax for every fenced code block: + // ```rust=, ```python=101, ```= or ```=101. + const numbered = info.match(/^(.*?)=(\d*)$/); + language = numbered ? numbered[1] : info; + codeLineStart = numbered ? Number(numbered[2] || 1) : null; + codeStart = index; + } inCode = !inCode; continue; } @@ -149,6 +236,30 @@ export function renderMarkdown(source, lineOffset = 0) { } } + if (/^\s*\[TOC\]\s*$/i.test(line)) { + closeList(); + if (headings.length) { + html += ``; + } + continue; + } + + const alertStart = line.match(/^\s*:::(success|info|warning|danger)\s*$/i); + if (alertStart) { + closeList(); + let end = index + 1; + while (end < lines.length && !/^\s*:::\s*$/.test(lines[end])) end++; + if (end < lines.length) { + const type = alertStart[1].toLowerCase(); + const body = lines.slice(index + 1, end).join("\n"); + html += ``; + index = end; + continue; + } + } + const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); const task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/); const ul = line.match(/^(\s*)[-*+]\s+(.+)$/); @@ -157,9 +268,10 @@ export function renderMarkdown(source, lineOffset = 0) { if (heading) { closeList(); const n = heading[1].length; - const id = heading[3] ? ` id="${escapeHtml(heading[3])}"` : ""; + const resolved = headingByLine.get(index); + const id = resolved?.id || heading[3] || headingSlug(heading[2]); const suffix = heading[3] ? ` {#${heading[3]}}` : ""; - html += `${inline(heading[2])}`; + html += `${inline(heading[2])}`; } else if (task) { if (list !== "ul") { closeList(); html += `
    `; list = "ul"; } const checked = task[2].toLowerCase() === "x"; diff --git a/static/js/note.js b/static/js/note.js index 7dc33b8..f756b6a 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -20,7 +20,9 @@ let unreadChat=0; const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker"); const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("workspace",workspaceSlug,shareToken); let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; -const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; +const lineToggle=document.querySelector("#line-numbers-toggle"), previewLineToggle=document.querySelector("#preview-line-numbers-toggle"); +lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; +previewLineToggle.checked=localStorage.getItem("rustpad:preview-line-numbers")==="on"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; fontSize.value=localStorage.getItem("rustpad:font-size")||"14"; @@ -42,7 +44,7 @@ function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textConten function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;} async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} -async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>hljs.default.highlightElement(node));}catch{}} +async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>{const lines=node.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(node);return;}const language=[...node.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});node.classList.add("hljs");});}catch{}} function renderGutter(){ const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lines=Array.from({length:lineCount}); @@ -59,7 +61,8 @@ function renderGutter(){ const label=owner!==owners[i-1]?`${escapeHtml(ownerName(owner))}`:""; return `${label}`; }).join(""); - document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); + document.body.classList.toggle("hide-editor-line-numbers",!lineToggle.checked); + document.body.classList.toggle("hide-preview-line-numbers",!previewLineToggle.checked); } function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});} @@ -148,7 +151,7 @@ bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=valu identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});}); async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

    Note not found

    ${escapeHtml(e.message)}

    `;}} -document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); +document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});previewLineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:preview-line-numbers",previewLineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();return;}if(event.key==="ArrowUp"||event.key==="ArrowDown"){if(movePreviewCaret(target,event.key==="ArrowUp"?-1:1))event.preventDefault();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true}); publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates:publicTaskUpdates.checked})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); roomDetails.addEventListener("toggle",()=>{if(roomDetails.open){clearUnread();chatInput.focus();if("Notification" in window&&Notification.permission==="default")Notification.requestPermission().catch(()=>{});}}); diff --git a/static/js/pad.js b/static/js/pad.js index 803c257..2005688 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -41,7 +41,7 @@ function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textConten function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;} async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} -async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>hljs.default.highlightElement(node));}catch{}} +async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>{const lines=node.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(node);return;}const language=[...node.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});node.classList.add("hljs");});}catch{}} function renderGutter(){ const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lines=Array.from({length:lineCount}); diff --git a/static/js/public.js b/static/js/public.js index 0bbb79b..5366a57 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -9,7 +9,7 @@ const token=location.pathname.split("/").filter(Boolean)[1]; const content=document.querySelector("#public-content"); function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} async function renderMermaid(){const nodes=content.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} -async function renderCodeHighlight(){const blocks=content.querySelectorAll('pre code[class^="language-"]');if(!blocks.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");blocks.forEach(block=>hljs.default.highlightElement(block));}catch{}} +async function renderCodeHighlight(){const blocks=content.querySelectorAll('pre code[class^="language-"]');if(!blocks.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");blocks.forEach(block=>{const lines=block.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(block);return;}const language=[...block.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});block.classList.add("hljs");});}catch{}} function lockPublicContent(allowTaskUpdates){ content.querySelectorAll('[contenteditable]').forEach(node=>node.removeAttribute('contenteditable')); content.querySelectorAll('.preview-editable').forEach(node=>node.classList.remove('preview-editable')); diff --git a/static/note.html b/static/note.html index 0d7b345..af866ed 100644 --- a/static/note.html +++ b/static/note.html @@ -71,7 +71,8 @@