new funtions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-07-24 12:40:23 +02:00
parent 0ed852a249
commit 14f15e5d47
10 changed files with 301 additions and 71 deletions
Generated
+1 -1
View File
@@ -2433,7 +2433,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.0.11" version = "0.0.13"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.0.11" version = "0.0.13"
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"
+92 -39
View File
@@ -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 ## Development setup
@@ -8,46 +8,102 @@ Collaborative Markdown editor with standalone notes and workspaces.
./dev.sh ./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, - Real-time collaborative editing over WebSocket.
- nickname remembered in `localStorage`, - Nicknames stored in `localStorage`.
- change authors in history, - Change authors shown in history.
- line numbering enabled by default with a persistent toggle, - Line numbering enabled by default, with a persistent toggle.
- owner color next to each line, - Owner color displayed next to each line.
- upload images and files to `data/files/pads/<id>_<token>/` lub `data/files/notes/<id>_<token>/`, - Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
- automatic Markdown link insertion after upload, - Automatic Markdown link insertion after upload.
- Markdown i diagramy Mermaid, - Markdown and Mermaid diagram rendering.
- history with snippets, previews, and version restore. - 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 ## Data
- SQLite: `data/db/rustpad.db`, - SQLite database: `data/db/rustpad.db`
- files: `data/files/pads/<id>_<token>/` i `data/files/notes/<id>_<token>/`; the public URL has the form `/f/<token>/<nazwa>`. - Attachments: `data/files/pads/<id>_<token>/` and `data/files/notes/<id>_<token>/`
- Public attachment URL: `/f/<token>/<filename>`
In Docker, both directories are located under `/data`. In Docker, both directories are located under `/data`.
## Publishing a note as a page ## Publishing a note as a page
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` 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/<token>` 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` - SQLite: `sqlite:///data/db/rustpad.db?mode=rwc&journal_mode=WAL&busy_timeout=5000`
- PostgreSQL: `postgres://rustpad:rustpad@postgres:5432/rustpad` - PostgreSQL: `postgres://rustpad:rustpad@postgres:5432/rustpad`
- MySQL: `mysql://rustpad:rustpad@mysql:3306/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 ```bash
# PostgreSQL # 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 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 ## 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. 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.
## 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.
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 ## 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 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 ## Attachment storage
RustPad supports two interchangeable attachment backends selected in `.env`: RustPad supports two interchangeable attachment backends selected in `.env`:
- `STORAGE_DRIVER=local` stores files under `FILES_DIR` (default). - `STORAGE_DRIVER=local` stores files under `FILES_DIR` and is the default.
- `STORAGE_DRIVER=s3` uses any S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack or MinIO. - `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 ```sh
docker compose --profile s3 up -d --build 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.
+60 -9
View File
@@ -1083,11 +1083,11 @@ dialog::backdrop {
padding-right: 8px; padding-right: 8px;
} }
.hide-line-numbers .editor-shell { .hide-editor-line-numbers .editor-shell {
grid-template-columns: 0 minmax(0, 1fr); grid-template-columns: 0 minmax(0, 1fr);
} }
.hide-line-numbers .line-gutter { .hide-editor-line-numbers .line-gutter {
width: 0; width: 0;
padding: 0; padding: 0;
border: 0; border: 0;
@@ -1460,7 +1460,7 @@ dialog::backdrop {
white-space: nowrap; white-space: nowrap;
} }
.hide-line-numbers .owner-labels { .hide-editor-line-numbers .owner-labels {
left: 0; left: 0;
} }
@@ -2173,19 +2173,19 @@ dialog::backdrop {
/* Preview editing and source line numbers. */ /* Preview editing and source line numbers. */
.preview { .preview {
position: relative;
padding: 12px 24px 12px 62px; padding: 12px 24px 12px 62px;
line-height: 1.32; line-height: 1.32;
} }
.preview-source-line { .preview-source-line {
position: relative;
min-height: 1.32em; min-height: 1.32em;
} }
.preview-source-line::before { .preview-source-line::before {
content: attr(data-source-line); content: attr(data-source-line);
position: absolute; position: absolute;
right: calc(100% + 18px); left: 12px;
width: 32px; width: 32px;
color: #596270; color: #596270;
text-align: right; text-align: right;
@@ -2231,11 +2231,11 @@ dialog::backdrop {
line-height: 1.24; line-height: 1.24;
} }
.hide-line-numbers .preview { .hide-preview-line-numbers .preview {
padding-left: 24px; padding-left: 24px;
} }
.hide-line-numbers .preview-source-line::before { .hide-preview-line-numbers .preview-source-line::before {
display: none; display: none;
} }
@@ -2338,7 +2338,7 @@ dialog::backdrop {
line-height: 1.32 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 display: none
} }
@@ -3737,4 +3737,55 @@ dialog::backdrop {
.share-dialog-footer { .share-dialog-footer {
border-radius: 0 0 14px 14px; border-radius: 0 0 14px 14px;
} }
} }
/* 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; }
+16 -6
View File
@@ -11,10 +11,10 @@ export async function prepareImageFile(file){
const dialog=document.createElement("dialog"); const dialog=document.createElement("dialog");
dialog.className="image-editor-dialog"; dialog.className="image-editor-dialog";
dialog.innerHTML=`<form method="dialog" class="image-editor-panel"> dialog.innerHTML=`<form method="dialog" class="image-editor-panel">
<div class="image-editor-head"><div><h2>Adjust image</h2><p>Drag to crop, use zoom and choose output size.</p></div><button class="icon-button" value="cancel" aria-label="Close">×</button></div> <div class="image-editor-head"><div><h2>Adjust image</h2><p>Keep the whole image or choose a crop, then select output size.</p></div><button class="icon-button" value="cancel" aria-label="Close">×</button></div>
<div class="image-crop-stage"><canvas></canvas></div> <div class="image-crop-stage"><canvas></canvas></div>
<div class="image-editor-controls"> <div class="image-editor-controls">
<label>Crop<select data-aspect><option value="free">Free</option><option value="1">Square</option><option value="1.333333">4:3</option><option value="1.777778">16:9</option></select></label> <label>Crop<select data-aspect><option value="original" selected>Whole image</option><option value="free">Free</option><option value="1">Square</option><option value="1.333333">4:3</option><option value="1.777778">16:9</option></select></label>
<label>Zoom<input data-zoom type="range" min="1" max="3" value="1" step="0.01"></label> <label>Zoom<input data-zoom type="range" min="1" max="3" value="1" step="0.01"></label>
<label>Max size<select data-size><option value="320">320 px</option><option value="480">480 px</option><option value="640">640 px</option><option value="800">800 px</option><option value="1000">1000 px</option><option value="1200">1200 px</option><option value="1600" selected>1600 px</option><option value="2000">2000 px</option><option value="0">Original</option></select></label> <label>Max size<select data-size><option value="320">320 px</option><option value="480">480 px</option><option value="640">640 px</option><option value="800">800 px</option><option value="1000">1000 px</option><option value="1200">1200 px</option><option value="1600" selected>1600 px</option><option value="2000">2000 px</option><option value="0">Original</option></select></label>
</div> </div>
@@ -27,7 +27,7 @@ export async function prepareImageFile(file){
function cropBox(){ function cropBox(){
const rect=stage.getBoundingClientRect(); const rect=stage.getBoundingClientRect();
let width=Math.max(280,rect.width),height=Math.min(520,Math.max(260,rect.height)); 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; if(width/height>aspect)width=height*aspect;else height=width/aspect;
return {width:Math.round(width),height:Math.round(height)}; 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); 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`; 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); 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 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); 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); offsetX=clamp(offsetX,-maxX,maxX);offsetY=clamp(offsetY,-maxY,maxY);
@@ -50,8 +52,16 @@ export async function prepareImageFile(file){
const result=new Promise(resolve=>{ const result=new Promise(resolve=>{
dialog.addEventListener("close",()=>{URL.revokeObjectURL(url);dialog.remove();resolve(accepted);},{once:true}); dialog.addEventListener("close",()=>{URL.revokeObjectURL(url);dialog.remove();resolve(accepted);},{once:true});
dialog.querySelector("[data-apply]").addEventListener("click",async()=>{ 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"); const box=cropBox(),maxSize=Number(sizeSelect.value),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); 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 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 blob=await new Promise(r=>out.toBlob(r,mime,mime==="image/jpeg"?.88:undefined));
const ext=mime==="image/png"?"png":"jpg"; const ext=mime==="image/png"?"png":"jpg";
+120 -8
View File
@@ -54,6 +54,41 @@ function inline(value) {
return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); 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 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); 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"); 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) { 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 lines = String(source).split("\n");
const headings = collectHeadings(lines);
const headingByLine = new Map(headings.map(item => [item.index, 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++) {
@@ -92,10 +162,18 @@ export function renderMarkdown(source, lineOffset = 0) {
const closeList = () => { if (list) { html += `</${list}>`; list = null; } }; const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
const closeCode = () => { const closeCode = () => {
const body = escapeHtml(code.join("\n")); const body = escapeHtml(code.join("\n"));
html += language.toLowerCase() === "mermaid" const lang = normalizeLanguage(language);
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>` if (lang === "mermaid") {
: `<pre${attrs(codeStart, false, "", "", lineOffset)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`; html += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`;
code = []; language = ""; fence = ""; } else if (codeLineStart !== null) {
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join("\n");
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}><code${languageClass}>${numbered}</code></pre>`;
} else {
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
html += `<pre${attrs(codeStart, false, "", "", lineOffset)}><code${languageClass}>${body}</code></pre>`;
}
code = []; language = ""; codeLineStart = null; fence = "";
}; };
for (let index = 0; index < lines.length; index++) { for (let index = 0; index < lines.length; index++) {
@@ -104,7 +182,16 @@ export function renderMarkdown(source, lineOffset = 0) {
if (fenceMatch) { if (fenceMatch) {
closeList(); closeList();
if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode(); 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; inCode = !inCode;
continue; continue;
} }
@@ -149,6 +236,30 @@ export function renderMarkdown(source, lineOffset = 0) {
} }
} }
if (/^\s*\[TOC\]\s*$/i.test(line)) {
closeList();
if (headings.length) {
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents"><ol>`;
headings.forEach(item => html += `<li class="toc-level-${item.level}"><a href="#${escapeHtml(item.id)}">${inline(item.text)}</a></li>`);
html += `</ol></nav>`;
}
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 += `<aside class="markdown-alert markdown-alert--${type}" role="note">${renderMarkdown(body, lineOffset + index + 1)}</aside>`;
index = end;
continue;
}
}
const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); 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 task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/);
const ul = line.match(/^(\s*)[-*+]\s+(.+)$/); const ul = line.match(/^(\s*)[-*+]\s+(.+)$/);
@@ -157,9 +268,10 @@ export function renderMarkdown(source, lineOffset = 0) {
if (heading) { if (heading) {
closeList(); closeList();
const n = heading[1].length; 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]}}` : ""; const suffix = heading[3] ? ` {#${heading[3]}}` : "";
html += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}</h${n}>`; html += `<h${n} id="${escapeHtml(id)}"${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}</h${n}>`;
} else if (task) { } else if (task) {
if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; } if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; }
const checked = task[2].toLowerCase() === "x"; const checked = task[2].toLowerCase() === "x";
+7 -4
View File
@@ -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 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); 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=[]; 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"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
fontSize.value=localStorage.getItem("rustpad:font-size")||"14"; 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 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}`;} 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",'<p class="error">Failed to load Mermaid.</p>'));}} 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",'<p class="error">Failed to load Mermaid.</p>'));}}
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(){ function renderGutter(){
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount}); const lines=Array.from({length:lineCount});
@@ -59,7 +61,8 @@ function renderGutter(){
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:""; const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`; return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`;
}).join(""); }).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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});} function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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();});}); 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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}} 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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
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}); 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);}}); 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(()=>{});}}); roomDetails.addEventListener("toggle",()=>{if(roomDetails.open){clearUnread();chatInput.focus();if("Notification" in window&&Notification.permission==="default")Notification.requestPermission().catch(()=>{});}});
+1 -1
View File
@@ -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 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}`;} 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",'<p class="error">Failed to load Mermaid.</p>'));}} 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",'<p class="error">Failed to load Mermaid.</p>'));}}
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(){ function renderGutter(){
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount}); const lines=Array.from({length:lineCount});
+1 -1
View File
@@ -9,7 +9,7 @@ const token=location.pathname.split("/").filter(Boolean)[1];
const content=document.querySelector("#public-content"); 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);} 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",'<p class="error">Failed to load Mermaid.</p>'));}} 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",'<p class="error">Failed to load Mermaid.</p>'));}}
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){ function lockPublicContent(allowTaskUpdates){
content.querySelectorAll('[contenteditable]').forEach(node=>node.removeAttribute('contenteditable')); content.querySelectorAll('[contenteditable]').forEach(node=>node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node=>node.classList.remove('preview-editable')); content.querySelectorAll('.preview-editable').forEach(node=>node.classList.remove('preview-editable'));
+2 -1
View File
@@ -71,7 +71,8 @@
<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">Image/file</button><input id="file-input" type="file" hidden><label class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor lines</label><label
class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox"> Preview lines</label><label
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label> class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" <div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
aria-pressed="true">Markdown</button> aria-pressed="true">Markdown</button>