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