paste files, images
This commit is contained in:
+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);
|
||||
|
||||
Reference in New Issue
Block a user