diff --git a/Cargo.lock b/Cargo.lock
index 2b55757..9271162 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
-version = "0.2.29"
+version = "0.2.30"
dependencies = [
"argon2",
"aws-config",
diff --git a/Cargo.toml b/Cargo.toml
index b608b53..b2a13ef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
-version = "0.2.29"
+version = "0.2.30"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
diff --git a/src/api/files.rs b/src/api/files.rs
index ccfc8c0..74534d4 100644
--- a/src/api/files.rs
+++ b/src/api/files.rs
@@ -628,7 +628,7 @@ fn sanitize_filename(value: &str) -> String {
#[cfg(test)]
mod tests {
- use super::{content_references_stored_file, is_safe_inline_image_mime};
+ use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime};
#[test]
fn only_raster_images_are_inline() {
@@ -639,6 +639,20 @@ mod tests {
assert!(!is_safe_inline_image_mime("application/xml"));
}
+ #[test]
+ fn extended_image_alias_is_still_attached() {
+ assert!(content_references_file(
+ "[image=photo.jpg,Photo,a=left,size=640x400]",
+ "photo.jpg",
+ "/f/token/photo.jpg",
+ ));
+ assert!(content_references_file(
+ "[file=report.pdf,Quarterly report]",
+ "report.pdf",
+ "/f/token/report.pdf",
+ ));
+ }
+
#[test]
fn attachment_references_survive_origin_changes() {
let stored = "/f/token/image.png";
diff --git a/src/assets.rs b/src/assets.rs
index 47a46b7..39de823 100644
--- a/src/assets.rs
+++ b/src/assets.rs
@@ -20,6 +20,7 @@ const MODULES: &[&str] = &[
"editor-format",
"emoji-data",
"emoji-picker",
+ "image-alias",
"image-upload",
"logger",
"line-links",
@@ -28,6 +29,8 @@ const MODULES: &[&str] = &[
"note-api",
"note-editor",
"note-files",
+ "preview-edit",
+ "render-queue",
"session",
"socket",
"toast",
diff --git a/static/css/styles.css b/static/css/styles.css
index 8c70da8..2f55b0f 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -2111,6 +2111,149 @@ dialog::backdrop {
max-height: calc(100vh - 210px);
}
+.markdown-alias-image {
+ position: relative;
+ display: block;
+ width: max-content;
+ max-width: 100%;
+ margin: .45em 0;
+ line-height: 0;
+}
+
+.markdown-alias-image--left {
+ margin-right: auto;
+ margin-left: 0;
+}
+
+.markdown-alias-image--center {
+ margin-right: auto;
+ margin-left: auto;
+}
+
+.markdown-alias-image--right {
+ margin-right: 0;
+ margin-left: auto;
+}
+
+.markdown-alias-image--sized {
+ width: min(var(--image-width), 100%);
+ aspect-ratio: var(--image-aspect);
+}
+
+.markdown-alias-image>img {
+ margin: 0;
+}
+
+.markdown-alias-image--sized>img {
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ object-fit: contain;
+}
+
+.preview .markdown-alias-image.is-selected {
+ border-radius: 8px;
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.image-alias-tools {
+ position: absolute;
+ z-index: 4;
+ top: 8px;
+ right: 8px;
+ left: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 6px;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ background: var(--surface-dialog);
+ box-shadow: 0 8px 24px var(--shadow-28);
+ line-height: 1.2;
+}
+
+.image-alias-align,
+.image-alias-size {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.image-alias-tools button,
+.image-alias-tools input {
+ min-height: 28px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--text-max);
+ font: inherit;
+}
+
+.image-alias-tools button {
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+.image-alias-tools button:hover,
+.image-alias-tools button.active {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
+
+.image-alias-size label {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ color: var(--muted);
+ font-size: .72rem;
+}
+
+.image-alias-size input {
+ width: 66px;
+ padding: 3px 5px;
+}
+
+.image-alias-resize {
+ position: absolute;
+ z-index: 5;
+ right: -7px;
+ bottom: -7px;
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ border: 2px solid var(--surface);
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: nwse-resize;
+ touch-action: none;
+}
+
+.markdown-alias-image.is-resizing {
+ user-select: none;
+}
+
+@media (max-width: 680px) {
+ .image-alias-tools {
+ top: 4px;
+ right: 4px;
+ left: 4px;
+ padding: 4px;
+ }
+
+ .image-alias-align,
+ .image-alias-size {
+ flex-wrap: wrap;
+ }
+
+ .image-alias-tools button {
+ padding-inline: 6px;
+ }
+}
+
.note-card-wrap {
position: relative;
border: 1px solid var(--border);
diff --git a/static/js/image-alias.js b/static/js/image-alias.js
new file mode 100644
index 0000000..f3ff00b
--- /dev/null
+++ b/static/js/image-alias.js
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
+ * Source-Available Code / Dual-Licensed.
+ *
+ * Free for non-commercial and evaluation use under terms of BSL/GPLv3.
+ * Commercial or production use requires a valid paid license.
+ * See LICENSE file in repository root for details.
+ */
+
+const IMAGE_ALIAS_SOURCE = String.raw`\[(image|img)=([^,\]\s]+)(?:,([^\]]*))?\]`;
+const ALIGNMENTS = new Set(["left", "center", "right"]);
+const MAX_IMAGE_DIMENSION = 10000;
+
+function cleanText(value, fallback = "") {
+ return String(value ?? fallback)
+ .replace(/\]/g, ")")
+ .replace(/[\r\n]+/g, " ")
+ .trim();
+}
+
+function validDimension(value) {
+ const number = Math.round(Number(value));
+ return Number.isFinite(number) && number >= 1 && number <= MAX_IMAGE_DIMENSION ? number : null;
+}
+
+function inlineCodeRanges(value) {
+ const ranges = [];
+ const pattern = /`[^`]+`/g;
+ for (const match of String(value || "").matchAll(pattern)) {
+ ranges.push([match.index, match.index + match[0].length]);
+ }
+ return ranges;
+}
+
+function isInsideRange(index, ranges) {
+ return ranges.some(([start, end]) => index >= start && index < end);
+}
+
+export function imageAliasPattern(flags = "gi") {
+ return new RegExp(IMAGE_ALIAS_SOURCE, flags);
+}
+
+export function parseImageAlias(value) {
+ const match = String(value || "").match(new RegExp(`^${IMAGE_ALIAS_SOURCE}$`, "i"));
+ if (!match) return null;
+
+ const [, kind, filename, tail = ""] = match;
+ const parts = tail.split(",");
+ let align = null;
+ let width = null;
+ let height = null;
+
+ while (parts.length) {
+ const part = parts.at(-1).trim();
+ const alignment = part.match(/^a=(left|center|right)$/i);
+ if (alignment) {
+ align = alignment[1].toLowerCase();
+ parts.pop();
+ continue;
+ }
+
+ const size = part.match(/^size=(\d{1,5})x(\d{1,5})$/i);
+ if (size) {
+ const nextWidth = validDimension(size[1]);
+ const nextHeight = validDimension(size[2]);
+ if (nextWidth && nextHeight) {
+ width = nextWidth;
+ height = nextHeight;
+ }
+ parts.pop();
+ continue;
+ }
+ break;
+ }
+
+ return {
+ kind: kind.toLowerCase(),
+ filename,
+ label: cleanText(parts.join(","), filename) || filename,
+ align,
+ width,
+ height,
+ raw: match[0],
+ };
+}
+
+export function buildImageAlias(options = {}) {
+ const filename = cleanText(options.filename, "image").replace(/[,\s]/g, "_") || "image";
+ const label = cleanText(options.label, filename) || filename;
+ const kind = String(options.kind || "image").toLowerCase() === "img" ? "img" : "image";
+ const align = ALIGNMENTS.has(String(options.align || "").toLowerCase())
+ ? String(options.align).toLowerCase()
+ : null;
+ const width = validDimension(options.width);
+ const height = validDimension(options.height);
+ const parts = [label];
+ if (align) parts.push(`a=${align}`);
+ if (width && height) parts.push(`size=${width}x${height}`);
+
+ return `[${kind}=${filename},${parts.join(",")}]`;
+}
+
+export function updateImageAliasInLine(line, aliasIndex, patch = {}) {
+ const source = String(line || "");
+ const targetIndex = Number(aliasIndex);
+ if (!Number.isInteger(targetIndex) || targetIndex < 0) return null;
+
+ const codeRanges = inlineCodeRanges(source);
+ let index = 0;
+ let changed = false;
+ const value = source.replace(imageAliasPattern(), (match, ...args) => {
+ const offset = args.at(-2);
+ if (isInsideRange(offset, codeRanges) || index++ !== targetIndex) return match;
+ const parsed = parseImageAlias(match);
+ if (!parsed) return match;
+ changed = true;
+ return buildImageAlias({ ...parsed, ...patch });
+ });
+
+ return changed ? value : null;
+}
+
+export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
+ const source = String(line || "");
+ const target = String(aliasSource || "");
+ const targetOccurrence = Number(occurrence);
+ if (!target || !Number.isInteger(targetOccurrence) || targetOccurrence < 0) return null;
+
+ const codeRanges = inlineCodeRanges(source);
+ let matchedOccurrence = 0;
+ let changed = false;
+ const value = source.replace(imageAliasPattern(), (match, ...args) => {
+ const offset = args.at(-2);
+ if (isInsideRange(offset, codeRanges) || match !== target || matchedOccurrence++ !== targetOccurrence) return match;
+ const parsed = parseImageAlias(match);
+ if (!parsed) return match;
+ changed = true;
+ return buildImageAlias({ ...parsed, ...patch });
+ });
+
+ return changed ? value : null;
+}
diff --git a/static/js/markdown.js b/static/js/markdown.js
index 68a8922..5931d9f 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -8,6 +8,7 @@
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
+import { parseImageAlias } from "@rustpad/image-alias";
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
@@ -76,14 +77,24 @@ function inline(value) {
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`${code}`));
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
+ const normalizedKind = kind.toLowerCase();
const file = markdownFiles.get(filename);
if (!file) return match;
- const text = String(label || filename).trim() || filename;
- if (kind.toLowerCase() === "file") {
+ if (normalizedKind === "file") {
+ const text = String(label || filename).trim() || filename;
return stash(`${text}`);
}
if (!file.mimeType.startsWith("image/")) return match;
- return stash(`
`);
+
+ const alias = parseImageAlias(match);
+ if (!alias) return match;
+ const alignment = alias.align || "left";
+ const sized = alias.width && alias.height;
+ const sizeClass = sized ? " markdown-alias-image--sized" : "";
+ const style = sized
+ ? ` style="--image-width:${alias.width}px;--image-height:${alias.height}px;--image-aspect:${alias.width} / ${alias.height}"`
+ : "";
+ return stash(`
`);
});
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
diff --git a/static/js/note-editor.js b/static/js/note-editor.js
index 391a59d..ad0ecbe 100644
--- a/static/js/note-editor.js
+++ b/static/js/note-editor.js
@@ -15,6 +15,9 @@ import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
+import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
+import { previewEditingHost } from "@rustpad/preview-edit";
+import { createRenderQueue } from "@rustpad/render-queue";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
@@ -427,6 +430,10 @@ export function startNoteEditor(adapter) {
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
const fileAlias = current.getAttribute("data-file-alias");
const fileName = current.getAttribute("data-file-name");
+ if (tag === "span" && fileAlias === "image-container") {
+ const alias = current.getAttribute("data-image-alias-source");
+ if (alias) return { open: "", close: "", atomic: alias };
+ }
if (tag === "a" && fileAlias === "file" && fileName) return { open: `[file=${fileName},`, close: "]", atomic: null };
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
if (tag === "img") {
@@ -586,6 +593,155 @@ export function startNoteEditor(adapter) {
return { start, end: start + lines[lineIndex].length, text: lines[lineIndex], lineIndex };
}
+ function imageFramesForLine(lineNumber) {
+ return [...preview.querySelectorAll(".markdown-alias-image")].filter(frame =>
+ Number(frame.closest("[data-source-line]")?.dataset.sourceLine) === lineNumber
+ );
+ }
+
+ function imageFramePosition(frame) {
+ const lineNumber = Number(frame.closest("[data-source-line]")?.dataset.sourceLine);
+ const aliasSource = frame.dataset.imageAliasSource || "";
+ if (!Number.isInteger(lineNumber) || lineNumber < 1 || !aliasSource) return null;
+ const frames = imageFramesForLine(lineNumber);
+ const frameIndex = frames.indexOf(frame);
+ if (frameIndex < 0) return null;
+ const sourceOccurrence = frames.slice(0, frameIndex)
+ .filter(item => item.dataset.imageAliasSource === aliasSource).length;
+ return { lineNumber, frameIndex, aliasSource, sourceOccurrence };
+ }
+
+ function selectedImageFrame() {
+ return preview.querySelector(".markdown-alias-image.is-selected");
+ }
+
+ function clearSelectedImageFrame() {
+ const active = selectedImageFrame();
+ if (!active) return;
+ active.classList.remove("is-selected");
+ active.querySelector(".image-alias-tools")?.remove();
+ active.querySelector(".image-alias-resize")?.remove();
+ }
+
+ function imageFrameDimensions(frame) {
+ const rect = frame.getBoundingClientRect();
+ return {
+ width: Math.max(1, Math.round(Number(frame.dataset.imageWidth) || rect.width)),
+ height: Math.max(1, Math.round(Number(frame.dataset.imageHeight) || rect.height)),
+ };
+ }
+
+ function selectImageFrame(frame) {
+ if (!frame || !canEditDocument()) return;
+ if (selectedImageFrame() === frame && frame.querySelector(".image-alias-tools")) return;
+ clearSelectedImageFrame();
+ frame.classList.add("is-selected");
+ const dimensions = imageFrameDimensions(frame);
+ const explicitAlignment = frame.dataset.imageAlign || "";
+ const tools = document.createElement("div");
+ tools.className = "image-alias-tools";
+ tools.setAttribute("role", "toolbar");
+ tools.setAttribute("aria-label", "Image layout");
+ tools.innerHTML = `
+
+
+
+
+
+
+ ×
+
+
+
+
`;
+ const resize = document.createElement("button");
+ resize.type = "button";
+ resize.className = "image-alias-resize";
+ resize.setAttribute("aria-label", "Resize image");
+ resize.title = "Drag to resize";
+ frame.append(tools, resize);
+ }
+
+ function updateImageFrameAlias(frame, patch) {
+ if (!canEditDocument()) return false;
+ const position = imageFramePosition(frame);
+ if (!position) return false;
+ const bounds = sourceLineBounds(position.lineNumber - 1);
+ if (!bounds) return false;
+ const nextLine = updateImageAliasInLineBySource(
+ bounds.text,
+ position.aliasSource,
+ position.sourceOccurrence,
+ patch,
+ );
+ if (nextLine == null || nextLine === bounds.text) return false;
+ editor.setRangeText(nextLine, bounds.start, bounds.end, "preserve");
+ editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText" }));
+ requestAnimationFrame(() => selectImageFrame(imageFramesForLine(position.lineNumber)[position.frameIndex]));
+ return true;
+ }
+
+ function applyImageSizeFromTools(frame) {
+ const tools = frame.querySelector(".image-alias-tools");
+ const width = Math.round(Number(tools?.querySelector("[data-image-width-input]")?.value));
+ const height = Math.round(Number(tools?.querySelector("[data-image-height-input]")?.value));
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1 || width > 10000 || height > 10000) {
+ toast("Image size must be between 1 and 10000 px.");
+ return;
+ }
+ updateImageFrameAlias(frame, { width, height });
+ }
+
+ function startImageResize(event, frame) {
+ if (!canEditDocument()) return;
+ const handle = event.target.closest(".image-alias-resize");
+ if (!handle) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const rect = frame.getBoundingClientRect();
+ const image = frame.querySelector("img");
+ const storedWidth = Number(frame.dataset.imageWidth);
+ const storedHeight = Number(frame.dataset.imageHeight);
+ const ratio = storedWidth > 0 && storedHeight > 0
+ ? storedWidth / storedHeight
+ : image?.naturalWidth > 0 && image?.naturalHeight > 0
+ ? image.naturalWidth / image.naturalHeight
+ : Math.max(.01, rect.width / Math.max(1, rect.height));
+ const parentWidth = frame.parentElement?.getBoundingClientRect().width || preview.clientWidth;
+ const maxWidth = Math.max(1, Math.min(10000, Math.round(parentWidth)));
+ const minWidth = Math.min(80, maxWidth);
+ const startX = event.clientX;
+ const startWidth = Math.max(minWidth, Math.round(rect.width));
+ let width = startWidth;
+ let height = Math.max(1, Math.round(width / ratio));
+
+ handle.setPointerCapture(event.pointerId);
+ frame.classList.add("is-resizing", "markdown-alias-image--sized");
+ const move = moveEvent => {
+ width = Math.max(minWidth, Math.min(maxWidth, Math.round(startWidth + moveEvent.clientX - startX)));
+ height = Math.max(1, Math.min(10000, Math.round(width / ratio)));
+ frame.dataset.imageWidth = String(width);
+ frame.dataset.imageHeight = String(height);
+ frame.style.setProperty("--image-width", `${width}px`);
+ frame.style.setProperty("--image-height", `${height}px`);
+ frame.style.setProperty("--image-aspect", `${width} / ${height}`);
+ const widthInput = frame.querySelector("[data-image-width-input]");
+ const heightInput = frame.querySelector("[data-image-height-input]");
+ if (widthInput) widthInput.value = String(width);
+ if (heightInput) heightInput.value = String(height);
+ };
+ const finish = () => {
+ frame.classList.remove("is-resizing");
+ handle.removeEventListener("pointermove", move);
+ handle.removeEventListener("pointerup", finish);
+ handle.removeEventListener("pointercancel", finish);
+ updateImageFrameAlias(frame, { width, height });
+ };
+ handle.addEventListener("pointermove", move);
+ handle.addEventListener("pointerup", finish);
+ handle.addEventListener("pointercancel", finish);
+ }
+
function tableCellBounds(line, cellIndex) {
const first = line.search(/\S|$/);
const trailingWhitespace = (line.match(/\s*$/) || [""])[0].length;
@@ -813,7 +969,8 @@ export function startNoteEditor(adapter) {
if (activeView() !== "split") return;
scrollPreviewToAnchor(editorScrollAnchor());
}
- function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
+ function renderNow() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
+ const render = createRenderQueue(renderNow);
function scheduleDocumentSave() {
clearTimeout(saveTimer);
if (!canEditDocument()) {
@@ -922,10 +1079,57 @@ export function startNoteEditor(adapter) {
editor, toast, getAccessToken: () => accessToken,
canDelete: () => Boolean(info?.can_delete_files),
canUpload: () => Boolean(info?.can_upload_files),
+ canEdit: canEditDocument,
endpoints: adapter.fileEndpoints,
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
});
refreshFilesForAliases = () => loadFiles();
+ preview.addEventListener("pointerdown", event => {
+ const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
+ if (frame && event.target.closest(".image-alias-resize")) startImageResize(event, frame);
+ });
+ preview.addEventListener("click", event => {
+ const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
+ if (!frame) {
+ clearSelectedImageFrame();
+ return;
+ }
+ event.stopImmediatePropagation();
+ if (!canEditDocument()) return;
+ if (!(event.target instanceof HTMLInputElement)) event.preventDefault();
+ selectImageFrame(frame);
+ const alignment = event.target.closest("[data-image-align]");
+ if (alignment) {
+ updateImageFrameAlias(frame, { align: alignment.dataset.imageAlign || null });
+ return;
+ }
+ if (event.target.closest("[data-image-size-apply]")) {
+ applyImageSizeFromTools(frame);
+ return;
+ }
+ if (event.target.closest("[data-image-size-reset]")) {
+ updateImageFrameAlias(frame, { width: null, height: null });
+ }
+ });
+ preview.addEventListener("keydown", event => {
+ const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
+ if (!frame) return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ clearSelectedImageFrame();
+ return;
+ }
+ if (event.key === "Enter" && event.target.matches("[data-image-width-input], [data-image-height-input]")) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ applyImageSizeFromTools(frame);
+ }
+ });
+ document.addEventListener("pointerdown", event => {
+ const active = selectedImageFrame();
+ if (active && !(event.target instanceof Node && active.contains(event.target))) clearSelectedImageFrame();
+ }, { capture: true });
function connect() {
socket?.stop();
socket = adapter.createSocket({
@@ -1406,17 +1610,17 @@ export function startNoteEditor(adapter) {
editor.dispatchEvent(new Event("input", { bubbles: true }));
});
preview.addEventListener("focusin", event => {
- const target = event.target.closest('.preview-editable[contenteditable="true"]');
+ const target = previewEditingHost(event.target);
if (!target) return;
target.dataset.originalHtml = target.innerHTML;
target.dataset.originalValue = markdownFromPreview(target);
});
preview.addEventListener("beforeinput", event => {
- if (!event.target.closest('.preview-editable[contenteditable="true"]')) return;
+ if (!previewEditingHost(event.target)) return;
if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault();
});
preview.addEventListener("keydown", event => {
- const target = event.target.closest('.preview-editable[contenteditable="true"]');
+ const target = previewEditingHost(event.target);
if (!target) return;
if (event.key === "Escape") {
event.preventDefault();
@@ -1439,7 +1643,7 @@ export function startNoteEditor(adapter) {
}
});
preview.addEventListener("blur", event => {
- const target = event.target.closest(".preview-editable");
+ const target = previewEditingHost(event.target);
if (!target) return;
if (cancelledPreviewEdits.has(target)) {
cancelledPreviewEdits.delete(target);
diff --git a/static/js/note-files.js b/static/js/note-files.js
index 361e7e0..ee2731c 100644
--- a/static/js/note-files.js
+++ b/static/js/note-files.js
@@ -1,7 +1,7 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
- *
+ *
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
@@ -36,6 +36,29 @@ function aliasCode(filename, label, mimeType) {
return `[${kind}=${filename},${safeLabel}]`;
}
+function clipboardFiles(event) {
+ const itemFiles = [...(event.clipboardData?.items || [])]
+ .filter(item => item.kind === "file")
+ .map(item => item.getAsFile())
+ .filter(Boolean);
+ if (itemFiles.length) return itemFiles;
+ return [...(event.clipboardData?.files || [])];
+}
+
+function pasteInsertionRange(editor, target) {
+ if (target === editor) return { start: editor.selectionStart, end: editor.selectionEnd };
+ const sourceLine = target instanceof Element ? target.closest("[data-source-line]") : null;
+ const lineIndex = Number(sourceLine?.dataset.sourceLine) - 1;
+ if (!Number.isInteger(lineIndex) || lineIndex < 0) {
+ return { start: editor.selectionStart, end: editor.selectionEnd };
+ }
+ const lines = editor.value.split("\n");
+ if (lineIndex >= lines.length) return { start: editor.selectionStart, end: editor.selectionEnd };
+ let end = 0;
+ for (let index = 0; index <= lineIndex; index++) end += lines[index].length + (index < lineIndex ? 1 : 0);
+ return { start: end, end };
+}
+
function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `` : `[${label}](${url})`;
}
@@ -47,11 +70,12 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
-export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => { } }) {
+export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
const footer = document.querySelector("#footer-files");
+ const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
async function loadFiles({ open = false } = {}) {
try {
@@ -80,32 +104,19 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
}
}
- document.querySelector("#upload-button").addEventListener("click", () => {
- if (!canUpload()) {
- toast("Log in with read-write access to upload files.");
- return;
- }
- input.click();
- });
- input.addEventListener("change", async event => {
- let file = event.target.files[0];
- if (!file) return;
- if (file.type.startsWith("image/")) {
- try {
- file = await prepareImageFile(file);
- } catch (error) {
- toast(error.message);
- input.value = "";
- return;
- }
- if (!file) { input.value = ""; return; }
- }
+ function insertText(text, range = null, inputType = "insertText") {
+ const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length));
+ const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length));
+ editor.setRangeText(text, start, end, "end");
+ editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data: text }));
+ return { start: start + text.length, end: start + text.length };
+ }
+ async function uploadFile(file, onUploaded) {
const uploadToast = createUploadToast(file.name);
let completed = false;
- const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
- const uploadFile = async () => {
+ const run = async () => {
uploadToast.start();
const form = new FormData();
form.append("access_token", getAccessToken() || "");
@@ -120,18 +131,75 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
if (completed) return;
completed = true;
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
- editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
- editor.dispatchEvent(new Event("input", { bubbles: true }));
+ await onUploaded(text);
uploadToast.success();
await loadFiles();
} catch (error) {
const retryable = !error.status || retryableStatuses.has(error.status);
- uploadToast.fail(error.message, { retryable, onRetry: uploadFile });
+ uploadToast.fail(error.message, { retryable, onRetry: run });
}
};
+ await run();
+ }
+
+ document.querySelector("#upload-button").addEventListener("click", () => {
+ if (!canUpload() || !canEdit()) {
+ toast("Log in with read-write access to upload files.");
+ return;
+ }
+ input.click();
+ });
+
+ input.addEventListener("change", async event => {
+ let file = event.target.files[0];
+ if (!file) return;
+ if (file.type.startsWith("image/")) {
+ try {
+ file = await prepareImageFile(file);
+ } catch (error) {
+ toast(error.message);
+ input.value = "";
+ return;
+ }
+ if (!file) { input.value = ""; return; }
+ }
+
+ const range = { start: editor.selectionStart, end: editor.selectionEnd };
input.value = "";
- await uploadFile();
+ await uploadFile(file, text => insertText(text, range));
+ });
+
+ document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
+ const files = clipboardFiles(event);
+ if (!files.length) return;
+ event.preventDefault();
+ if (!canUpload() || !canEdit()) {
+ toast("Log in with read-write access to paste files.");
+ return;
+ }
+
+ const initialRange = pasteInsertionRange(editor, event.target);
+ const state = { cursor: initialRange.start, replaceEnd: initialRange.end, inserted: false };
+ const insertPastedAlias = text => {
+ const value = editor.value;
+ if (!state.inserted) {
+ const prefix = state.cursor > 0 && value[state.cursor - 1] !== "\n" ? "\n" : "";
+ const suffix = state.replaceEnd < value.length && value[state.replaceEnd] !== "\n" ? "\n" : "";
+ insertText(`${prefix}${text}${suffix}`, { start: state.cursor, end: state.replaceEnd }, "insertFromPaste");
+ state.cursor += prefix.length + text.length;
+ state.replaceEnd = state.cursor;
+ state.inserted = true;
+ return;
+ }
+ const inserted = `\n${text}`;
+ insertText(inserted, { start: state.cursor, end: state.cursor }, "insertFromPaste");
+ state.cursor += inserted.length;
+ state.replaceEnd = state.cursor;
+ };
+
+ for (const file of files) await uploadFile(file, insertPastedAlias);
+ editor.setSelectionRange(state.cursor, state.cursor);
});
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
@@ -141,8 +209,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
- editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
- editor.dispatchEvent(new Event("input", { bubbles: true }));
+ insertText(text);
toast("Added to note");
return;
}
diff --git a/static/js/preview-edit.js b/static/js/preview-edit.js
new file mode 100644
index 0000000..cbac55d
--- /dev/null
+++ b/static/js/preview-edit.js
@@ -0,0 +1,14 @@
+/*
+ * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
+ * Source-Available Code / Dual-Licensed.
+ *
+ * Free for non-commercial and evaluation use under terms of BSL/GPLv3.
+ * Commercial or production use requires a valid paid license.
+ * See LICENSE file in repository root for details.
+ */
+
+const ACTIVE_PREVIEW_EDIT_SELECTOR = '.preview-editable[contenteditable="true"]';
+
+export function previewEditingHost(target) {
+ return target?.matches?.(ACTIVE_PREVIEW_EDIT_SELECTOR) ? target : null;
+}
diff --git a/static/js/render-queue.js b/static/js/render-queue.js
new file mode 100644
index 0000000..a5b63c9
--- /dev/null
+++ b/static/js/render-queue.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
+ * Source-Available Code / Dual-Licensed.
+ *
+ * Free for non-commercial and evaluation use under terms of BSL/GPLv3.
+ * Commercial or production use requires a valid paid license.
+ * See LICENSE file in repository root for details.
+ */
+
+export function createRenderQueue(renderNow, schedule = queueMicrotask) {
+ if (typeof renderNow !== "function") throw new TypeError("renderNow must be a function");
+ if (typeof schedule !== "function") throw new TypeError("schedule must be a function");
+
+ let rendering = false;
+ let pending = false;
+ let scheduled = false;
+
+ const schedulePendingRender = () => {
+ if (scheduled) return;
+ scheduled = true;
+ schedule(() => {
+ scheduled = false;
+ if (!pending) return;
+ pending = false;
+ render();
+ });
+ };
+
+ function render() {
+ if (rendering) {
+ pending = true;
+ schedulePendingRender();
+ return;
+ }
+
+ pending = false;
+ rendering = true;
+ try {
+ renderNow();
+ } finally {
+ rendering = false;
+ if (pending) schedulePendingRender();
+ }
+ }
+
+ return render;
+}