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(`${text}`); + + 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(`${alias.label}`); }); 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})` : `[${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; +}