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
+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);