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