new funtions and fixes
This commit is contained in:
Generated
+1
-1
@@ -2433,7 +2433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.11"
|
||||
version = "0.0.13"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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/<id>_<token>/` lub `data/files/notes/<id>_<token>/`,
|
||||
- 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/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
|
||||
- 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/<id>_<token>/` i `data/files/notes/<id>_<token>/`; the public URL has the form `/f/<token>/<nazwa>`.
|
||||
- SQLite database: `data/db/rustpad.db`
|
||||
- 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`.
|
||||
|
||||
## 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`
|
||||
- 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.
|
||||
|
||||
+59
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -3738,3 +3738,54 @@ dialog::backdrop {
|
||||
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; }
|
||||
|
||||
@@ -11,10 +11,10 @@ export async function prepareImageFile(file){
|
||||
const dialog=document.createElement("dialog");
|
||||
dialog.className="image-editor-dialog";
|
||||
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-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>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>
|
||||
@@ -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";
|
||||
|
||||
+120
-8
@@ -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}>`; list = null; } };
|
||||
const closeCode = () => {
|
||||
const body = escapeHtml(code.join("\n"));
|
||||
html += language.toLowerCase() === "mermaid"
|
||||
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`
|
||||
: `<pre${attrs(codeStart, false, "", "", lineOffset)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
|
||||
code = []; language = ""; fence = "";
|
||||
const lang = normalizeLanguage(language);
|
||||
if (lang === "mermaid") {
|
||||
html += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`;
|
||||
} else if (codeLineStart !== null) {
|
||||
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join("\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++) {
|
||||
@@ -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 += `<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 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 += `<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) {
|
||||
if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; }
|
||||
const checked = task[2].toLowerCase() === "x";
|
||||
|
||||
+7
-4
@@ -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",'<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(){
|
||||
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]?`<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}`;
|
||||
}).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=`<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});
|
||||
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(()=>{});}});
|
||||
|
||||
+1
-1
@@ -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",'<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(){
|
||||
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
|
||||
const lines=Array.from({length:lineCount});
|
||||
|
||||
+1
-1
@@ -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",'<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){
|
||||
content.querySelectorAll('[contenteditable]').forEach(node=>node.removeAttribute('contenteditable'));
|
||||
content.querySelectorAll('.preview-editable').forEach(node=>node.classList.remove('preview-editable'));
|
||||
|
||||
+2
-1
@@ -71,7 +71,8 @@
|
||||
<option value="22">22</option>
|
||||
</select></label></div><button id="upload-button"
|
||||
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>
|
||||
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
|
||||
aria-pressed="true">Markdown</button>
|
||||
|
||||
Reference in New Issue
Block a user