paste files, images

This commit is contained in:
Mateusz Gruszczyński
2026-08-04 10:42:45 +02:00
parent 83e10129c3
commit 1a77ccd1bf
11 changed files with 686 additions and 41 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.29"
version = "0.2.30"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -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"
+15 -1
View File
@@ -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";
+3
View File
@@ -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",
+143
View File
@@ -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);
+142
View File
@@ -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;
}
+14 -3
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c]));
@@ -76,14 +77,24 @@ function inline(value) {
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</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(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
}
if (!file.mimeType.startsWith("image/")) return match;
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
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(`<span class="markdown-alias-image markdown-alias-image--${alignment}${sizeClass}" contenteditable="false" data-file-alias="image-container" data-file-name="${filename}" data-image-alias-source="${match}" data-image-align="${alias.align || ""}"${sized ? ` data-image-width="${alias.width}" data-image-height="${alias.height}"` : ""}${style}><img src="${safeUrl(file.url)}" alt="${alias.label}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}"></span>`);
});
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
+209 -5
View File
@@ -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 = `<div class="image-alias-align" role="group" aria-label="Image alignment">
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>Auto</button>
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>Left</button>
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>Center</button>
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>Right</button>
</div><div class="image-alias-size">
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="Image width"></label>
<span aria-hidden="true">×</span>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="Image height"></label>
<button type="button" data-image-size-apply>Set</button>
<button type="button" data-image-size-reset>Natural</button>
</div>`;
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) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).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) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).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);
+97 -30
View File
@@ -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;
}
+14
View File
@@ -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;
}
+47
View File
@@ -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;
}