paste files, images
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.29"
|
||||
version = "0.2.30"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>${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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user