377 lines
17 KiB
JavaScript
377 lines
17 KiB
JavaScript
/*
|
||
* 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.
|
||
*/
|
||
|
||
import { api, uploadWithProgress } from "@rustpad/api";
|
||
import { copyText } from "@rustpad/clipboard";
|
||
import { prepareImageFile } from "@rustpad/image-upload";
|
||
import { askConfirm } from "@rustpad/modal";
|
||
import { safeAppUrl, safePublicUrl } from "@rustpad/security";
|
||
import { createUploadToast } from "@rustpad/toast";
|
||
|
||
function escapeHtml(value) {
|
||
return String(value).replace(/[&<>"']/g, character => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character]));
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
const bytes = Number(value) || 0;
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function formatDate(value) {
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
|
||
}
|
||
|
||
function isVideo(mimeType) {
|
||
return String(mimeType || "").startsWith("video/");
|
||
}
|
||
|
||
function isLikelyVideoFile(file) {
|
||
return isVideo(file?.type) || /\.(?:mp4|m4v|mov|webm|ogv)$/i.test(String(file?.name || ""));
|
||
}
|
||
|
||
function aliasCode(filename, label, mimeType, mode = "auto") {
|
||
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
|
||
let kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
|
||
if (isVideo(mimeType) && mode === "player") kind = "video";
|
||
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})`;
|
||
}
|
||
|
||
function safeAttachmentUrl(value) {
|
||
const raw = String(value || "").trim();
|
||
return raw.startsWith("/")
|
||
? safeAppUrl(raw)
|
||
: safePublicUrl(raw, { allowMailto: false });
|
||
}
|
||
|
||
function downloadAttachmentUrl(value) {
|
||
const safe = safeAttachmentUrl(value);
|
||
try {
|
||
const url = new URL(safe, location.origin);
|
||
if (/^\/f\/[^/]+\/[^/]+$/.test(url.pathname)) url.searchParams.set("download", "1");
|
||
return url.href;
|
||
} catch {
|
||
return safe;
|
||
}
|
||
}
|
||
|
||
function createVideoInsertDialog() {
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = "app-dialog video-insert-dialog";
|
||
dialog.innerHTML = `
|
||
<div class="video-insert-dialog__panel">
|
||
<div class="dialog-heading-row">
|
||
<div><p class="eyebrow">Video file</p><h2>How should it be added?</h2></div>
|
||
<button type="button" class="icon-button" data-video-choice="cancel" aria-label="Cancel">×</button>
|
||
</div>
|
||
<p class="dialog-copy" data-video-file-name></p>
|
||
<div class="video-choice-actions">
|
||
<button type="button" class="action-button action-button--primary" data-video-choice="player">
|
||
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
|
||
</button>
|
||
<button type="button" class="action-button action-button--secondary" data-video-choice="download">
|
||
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.append(dialog);
|
||
return dialog;
|
||
}
|
||
|
||
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, getDeleteRestrictionMessage = () => "", 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]);
|
||
let videoInsertDialog;
|
||
|
||
function chooseVideoInsertMode(filename) {
|
||
videoInsertDialog ||= createVideoInsertDialog();
|
||
videoInsertDialog.querySelector("[data-video-file-name]").textContent = filename;
|
||
if (!videoInsertDialog.open) videoInsertDialog.showModal();
|
||
|
||
return new Promise(resolve => {
|
||
let settled = false;
|
||
const finish = value => {
|
||
if (settled) return;
|
||
settled = true;
|
||
videoInsertDialog.removeEventListener("click", handleClick);
|
||
videoInsertDialog.removeEventListener("cancel", handleCancel);
|
||
if (videoInsertDialog.open) videoInsertDialog.close();
|
||
resolve(value);
|
||
};
|
||
const handleClick = event => {
|
||
const choice = event.target.closest("[data-video-choice]")?.dataset.videoChoice;
|
||
if (!choice) return;
|
||
finish(choice === "player" || choice === "download" ? choice : null);
|
||
};
|
||
const handleCancel = event => {
|
||
event.preventDefault();
|
||
finish(null);
|
||
};
|
||
videoInsertDialog.addEventListener("click", handleClick);
|
||
videoInsertDialog.addEventListener("cancel", handleCancel);
|
||
});
|
||
}
|
||
|
||
function fileActionButtons(file) {
|
||
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
|
||
const insertButtons = isVideo(file.mime_type)
|
||
? `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="player" ${attributes}>Add player</button>
|
||
<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>Add download</button>`
|
||
: `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="auto" ${attributes}>Add to note</button>`;
|
||
const codeButtons = isVideo(file.mime_type)
|
||
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
|
||
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" ${attributes}>Player code</button>
|
||
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" ${attributes}>Download code</button>`
|
||
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
|
||
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" ${attributes}>Alias</button>
|
||
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" ${attributes}>Markdown</button>`;
|
||
const deleteButton = canDelete()
|
||
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`
|
||
: "";
|
||
return `${codeButtons}${insertButtons}${deleteButton}`;
|
||
}
|
||
|
||
async function loadFiles({ open = false } = {}) {
|
||
try {
|
||
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||
footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
||
const restrictionMessage = getDeleteRestrictionMessage();
|
||
const restrictionNotice = restrictionMessage
|
||
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
|
||
: "";
|
||
const fileRows = files.length ? files.map(file => `
|
||
<div class="file-row" data-file-row="${file.id}">
|
||
<div class="file-row-main">
|
||
<div class="file-name">${escapeHtml(file.filename)}</div>
|
||
<div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div>
|
||
</div>
|
||
<div class="file-actions">${fileActionButtons(file)}</div>
|
||
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
|
||
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
||
list.innerHTML = restrictionNotice + fileRows;
|
||
onFilesChanged(files);
|
||
if (open && !dialog.open) dialog.showModal();
|
||
} catch (error) {
|
||
if (open) toast(error.message);
|
||
}
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
function insertVideoPlayer(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));
|
||
const prefix = start > 0 && editor.value[start - 1] !== "\n" ? "\n" : "";
|
||
const suffix = end < editor.value.length && editor.value[end] !== "\n" ? "\n" : "";
|
||
return insertText(`${prefix}${text}${suffix}`, { start, end }, inputType);
|
||
}
|
||
|
||
async function uploadFile(file, onUploaded, insertMode = "auto") {
|
||
const uploadToast = createUploadToast(file.name);
|
||
let completed = false;
|
||
|
||
const run = async () => {
|
||
uploadToast.start();
|
||
const form = new FormData();
|
||
form.append("access_token", getAccessToken() || "");
|
||
form.append("file", file);
|
||
try {
|
||
const result = await uploadWithProgress(endpoints.upload, {
|
||
method: "POST",
|
||
body: form,
|
||
headers: {},
|
||
uploadMaxSizeBytes: getUploadMaxSize(),
|
||
onProgress: progress => uploadToast.update(progress),
|
||
});
|
||
if (completed) return;
|
||
completed = true;
|
||
const text = aliasCode(result.name, file.name, result.mime_type || file.type, insertMode);
|
||
await onUploaded(text);
|
||
uploadToast.success();
|
||
await loadFiles();
|
||
} catch (error) {
|
||
const retryable = !error.status || retryableStatuses.has(error.status);
|
||
uploadToast.fail(error.message, { retryable, onRetry: run });
|
||
}
|
||
};
|
||
|
||
await run();
|
||
}
|
||
|
||
function requestUpload() {
|
||
if (!canUpload() || !canEdit()) {
|
||
toast("You need read-write access and upload permission to upload files.");
|
||
return;
|
||
}
|
||
input.click();
|
||
}
|
||
|
||
document.querySelector("#upload-button")?.addEventListener("click", requestUpload);
|
||
document.querySelector("#mobile-upload-button")?.addEventListener("click", requestUpload);
|
||
document.querySelector("#files-upload-button")?.addEventListener("click", requestUpload);
|
||
|
||
input.addEventListener("change", async event => {
|
||
let file = event.target.files[0];
|
||
if (!file) return;
|
||
input.value = "";
|
||
if (file.type.startsWith("image/")) {
|
||
try {
|
||
file = await prepareImageFile(file);
|
||
} catch (error) {
|
||
toast(error.message);
|
||
return;
|
||
}
|
||
if (!file) return;
|
||
}
|
||
|
||
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
|
||
if (!insertMode) return;
|
||
const range = { start: editor.selectionStart, end: editor.selectionEnd };
|
||
await uploadFile(file, text => insertMode === "player" ? insertVideoPlayer(text, range) : insertText(text, range), insertMode);
|
||
});
|
||
|
||
document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
|
||
const files = clipboardFiles(event);
|
||
if (!files.length) return;
|
||
event.preventDefault();
|
||
if (!canUpload() || !canEdit()) {
|
||
toast("You need read-write access and upload permission 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) {
|
||
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
|
||
if (insertMode) await uploadFile(file, insertPastedAlias, insertMode);
|
||
}
|
||
editor.setSelectionRange(state.cursor, state.cursor);
|
||
});
|
||
|
||
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
||
footer.addEventListener("click", () => loadFiles({ open: true }));
|
||
document.querySelector("#close-files").addEventListener("click", () => dialog.close());
|
||
list.addEventListener("click", async event => {
|
||
const addButton = event.target.closest("[data-add-file-to-note]");
|
||
if (addButton) {
|
||
const mode = addButton.dataset.insertMode;
|
||
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode);
|
||
if (mode === "player") insertVideoPlayer(text);
|
||
else insertText(text);
|
||
toast("Added to note");
|
||
return;
|
||
}
|
||
|
||
const showButton = event.target.closest("[data-show-file-code]");
|
||
if (showButton) {
|
||
const panel = showButton.closest(".file-row").querySelector(".file-code");
|
||
const output = panel.querySelector("textarea");
|
||
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
|
||
const absolute = new URL(safeUrl, location.origin).href;
|
||
let text = showButton.dataset.showFileCode === "link" && isVideo(showButton.dataset.mime)
|
||
? downloadAttachmentUrl(showButton.dataset.url)
|
||
: absolute;
|
||
if (showButton.dataset.showFileCode === "alias") {
|
||
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
|
||
} else if (showButton.dataset.showFileCode === "markdown") {
|
||
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
|
||
} else if (showButton.dataset.showFileCode === "player") {
|
||
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "player");
|
||
} else if (showButton.dataset.showFileCode === "download") {
|
||
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "download");
|
||
}
|
||
output.value = text;
|
||
panel.hidden = false;
|
||
output.focus();
|
||
output.select();
|
||
return;
|
||
}
|
||
|
||
const copyButton = event.target.closest("[data-copy-generated]");
|
||
if (copyButton) {
|
||
try {
|
||
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
|
||
toast("Copied");
|
||
} catch (error) {
|
||
toast(error.message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const deleteButton = event.target.closest("[data-delete-file]");
|
||
if (!deleteButton) return;
|
||
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
|
||
try {
|
||
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||
toast("File deleted");
|
||
await loadFiles();
|
||
} catch (error) {
|
||
toast(error.message);
|
||
}
|
||
});
|
||
|
||
return { loadFiles };
|
||
}
|