paste files, images
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.29"
|
version = "0.2.30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.29"
|
version = "0.2.30"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
+15
-1
@@ -628,7 +628,7 @@ fn sanitize_filename(value: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{content_references_stored_file, is_safe_inline_image_mime};
|
use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_raster_images_are_inline() {
|
fn only_raster_images_are_inline() {
|
||||||
@@ -639,6 +639,20 @@ mod tests {
|
|||||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_image_alias_is_still_attached() {
|
||||||
|
assert!(content_references_file(
|
||||||
|
"[image=photo.jpg,Photo,a=left,size=640x400]",
|
||||||
|
"photo.jpg",
|
||||||
|
"/f/token/photo.jpg",
|
||||||
|
));
|
||||||
|
assert!(content_references_file(
|
||||||
|
"[file=report.pdf,Quarterly report]",
|
||||||
|
"report.pdf",
|
||||||
|
"/f/token/report.pdf",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_references_survive_origin_changes() {
|
fn attachment_references_survive_origin_changes() {
|
||||||
let stored = "/f/token/image.png";
|
let stored = "/f/token/image.png";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const MODULES: &[&str] = &[
|
|||||||
"editor-format",
|
"editor-format",
|
||||||
"emoji-data",
|
"emoji-data",
|
||||||
"emoji-picker",
|
"emoji-picker",
|
||||||
|
"image-alias",
|
||||||
"image-upload",
|
"image-upload",
|
||||||
"logger",
|
"logger",
|
||||||
"line-links",
|
"line-links",
|
||||||
@@ -28,6 +29,8 @@ const MODULES: &[&str] = &[
|
|||||||
"note-api",
|
"note-api",
|
||||||
"note-editor",
|
"note-editor",
|
||||||
"note-files",
|
"note-files",
|
||||||
|
"preview-edit",
|
||||||
|
"render-queue",
|
||||||
"session",
|
"session",
|
||||||
"socket",
|
"socket",
|
||||||
"toast",
|
"toast",
|
||||||
|
|||||||
@@ -2111,6 +2111,149 @@ dialog::backdrop {
|
|||||||
max-height: calc(100vh - 210px);
|
max-height: calc(100vh - 210px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: .45em 0;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image--left {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image--center {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image--right {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image--sized {
|
||||||
|
width: min(var(--image-width), 100%);
|
||||||
|
aspect-ratio: var(--image-aspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image>img {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image--sized>img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview .markdown-alias-image.is-selected {
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-tools {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-dialog);
|
||||||
|
box-shadow: 0 8px 24px var(--shadow-28);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-align,
|
||||||
|
.image-alias-size {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-tools button,
|
||||||
|
.image-alias-tools input {
|
||||||
|
min-height: 28px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-max);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-tools button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-tools button:hover,
|
||||||
|
.image-alias-tools button.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-size label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-size input {
|
||||||
|
width: 66px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-resize {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
right: -7px;
|
||||||
|
bottom: -7px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid var(--surface);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: nwse-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alias-image.is-resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.image-alias-tools {
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
left: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-align,
|
||||||
|
.image-alias-size {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-alias-tools button {
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.note-card-wrap {
|
.note-card-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const IMAGE_ALIAS_SOURCE = String.raw`\[(image|img)=([^,\]\s]+)(?:,([^\]]*))?\]`;
|
||||||
|
const ALIGNMENTS = new Set(["left", "center", "right"]);
|
||||||
|
const MAX_IMAGE_DIMENSION = 10000;
|
||||||
|
|
||||||
|
function cleanText(value, fallback = "") {
|
||||||
|
return String(value ?? fallback)
|
||||||
|
.replace(/\]/g, ")")
|
||||||
|
.replace(/[\r\n]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDimension(value) {
|
||||||
|
const number = Math.round(Number(value));
|
||||||
|
return Number.isFinite(number) && number >= 1 && number <= MAX_IMAGE_DIMENSION ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineCodeRanges(value) {
|
||||||
|
const ranges = [];
|
||||||
|
const pattern = /`[^`]+`/g;
|
||||||
|
for (const match of String(value || "").matchAll(pattern)) {
|
||||||
|
ranges.push([match.index, match.index + match[0].length]);
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideRange(index, ranges) {
|
||||||
|
return ranges.some(([start, end]) => index >= start && index < end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageAliasPattern(flags = "gi") {
|
||||||
|
return new RegExp(IMAGE_ALIAS_SOURCE, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImageAlias(value) {
|
||||||
|
const match = String(value || "").match(new RegExp(`^${IMAGE_ALIAS_SOURCE}$`, "i"));
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, kind, filename, tail = ""] = match;
|
||||||
|
const parts = tail.split(",");
|
||||||
|
let align = null;
|
||||||
|
let width = null;
|
||||||
|
let height = null;
|
||||||
|
|
||||||
|
while (parts.length) {
|
||||||
|
const part = parts.at(-1).trim();
|
||||||
|
const alignment = part.match(/^a=(left|center|right)$/i);
|
||||||
|
if (alignment) {
|
||||||
|
align = alignment[1].toLowerCase();
|
||||||
|
parts.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = part.match(/^size=(\d{1,5})x(\d{1,5})$/i);
|
||||||
|
if (size) {
|
||||||
|
const nextWidth = validDimension(size[1]);
|
||||||
|
const nextHeight = validDimension(size[2]);
|
||||||
|
if (nextWidth && nextHeight) {
|
||||||
|
width = nextWidth;
|
||||||
|
height = nextHeight;
|
||||||
|
}
|
||||||
|
parts.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: kind.toLowerCase(),
|
||||||
|
filename,
|
||||||
|
label: cleanText(parts.join(","), filename) || filename,
|
||||||
|
align,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
raw: match[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImageAlias(options = {}) {
|
||||||
|
const filename = cleanText(options.filename, "image").replace(/[,\s]/g, "_") || "image";
|
||||||
|
const label = cleanText(options.label, filename) || filename;
|
||||||
|
const kind = String(options.kind || "image").toLowerCase() === "img" ? "img" : "image";
|
||||||
|
const align = ALIGNMENTS.has(String(options.align || "").toLowerCase())
|
||||||
|
? String(options.align).toLowerCase()
|
||||||
|
: null;
|
||||||
|
const width = validDimension(options.width);
|
||||||
|
const height = validDimension(options.height);
|
||||||
|
const parts = [label];
|
||||||
|
if (align) parts.push(`a=${align}`);
|
||||||
|
if (width && height) parts.push(`size=${width}x${height}`);
|
||||||
|
|
||||||
|
return `[${kind}=${filename},${parts.join(",")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateImageAliasInLine(line, aliasIndex, patch = {}) {
|
||||||
|
const source = String(line || "");
|
||||||
|
const targetIndex = Number(aliasIndex);
|
||||||
|
if (!Number.isInteger(targetIndex) || targetIndex < 0) return null;
|
||||||
|
|
||||||
|
const codeRanges = inlineCodeRanges(source);
|
||||||
|
let index = 0;
|
||||||
|
let changed = false;
|
||||||
|
const value = source.replace(imageAliasPattern(), (match, ...args) => {
|
||||||
|
const offset = args.at(-2);
|
||||||
|
if (isInsideRange(offset, codeRanges) || index++ !== targetIndex) return match;
|
||||||
|
const parsed = parseImageAlias(match);
|
||||||
|
if (!parsed) return match;
|
||||||
|
changed = true;
|
||||||
|
return buildImageAlias({ ...parsed, ...patch });
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
|
||||||
|
const source = String(line || "");
|
||||||
|
const target = String(aliasSource || "");
|
||||||
|
const targetOccurrence = Number(occurrence);
|
||||||
|
if (!target || !Number.isInteger(targetOccurrence) || targetOccurrence < 0) return null;
|
||||||
|
|
||||||
|
const codeRanges = inlineCodeRanges(source);
|
||||||
|
let matchedOccurrence = 0;
|
||||||
|
let changed = false;
|
||||||
|
const value = source.replace(imageAliasPattern(), (match, ...args) => {
|
||||||
|
const offset = args.at(-2);
|
||||||
|
if (isInsideRange(offset, codeRanges) || match !== target || matchedOccurrence++ !== targetOccurrence) return match;
|
||||||
|
const parsed = parseImageAlias(match);
|
||||||
|
if (!parsed) return match;
|
||||||
|
changed = true;
|
||||||
|
return buildImageAlias({ ...parsed, ...patch });
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? value : null;
|
||||||
|
}
|
||||||
+14
-3
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
|
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
|
||||||
|
import { parseImageAlias } from "@rustpad/image-alias";
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
@@ -76,14 +77,24 @@ function inline(value) {
|
|||||||
|
|
||||||
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
||||||
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
|
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
|
||||||
|
const normalizedKind = kind.toLowerCase();
|
||||||
const file = markdownFiles.get(filename);
|
const file = markdownFiles.get(filename);
|
||||||
if (!file) return match;
|
if (!file) return match;
|
||||||
const text = String(label || filename).trim() || filename;
|
if (normalizedKind === "file") {
|
||||||
if (kind.toLowerCase() === "file") {
|
const text = String(label || filename).trim() || filename;
|
||||||
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
|
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
|
||||||
}
|
}
|
||||||
if (!file.mimeType.startsWith("image/")) return match;
|
if (!file.mimeType.startsWith("image/")) return match;
|
||||||
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
|
|
||||||
|
const alias = parseImageAlias(match);
|
||||||
|
if (!alias) return match;
|
||||||
|
const alignment = alias.align || "left";
|
||||||
|
const sized = alias.width && alias.height;
|
||||||
|
const sizeClass = sized ? " markdown-alias-image--sized" : "";
|
||||||
|
const style = sized
|
||||||
|
? ` style="--image-width:${alias.width}px;--image-height:${alias.height}px;--image-aspect:${alias.width} / ${alias.height}"`
|
||||||
|
: "";
|
||||||
|
return stash(`<span class="markdown-alias-image markdown-alias-image--${alignment}${sizeClass}" contenteditable="false" data-file-alias="image-container" data-file-name="${filename}" data-image-alias-source="${match}" data-image-align="${alias.align || ""}"${sized ? ` data-image-width="${alias.width}" data-image-height="${alias.height}"` : ""}${style}><img src="${safeUrl(file.url)}" alt="${alias.label}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}"></span>`);
|
||||||
});
|
});
|
||||||
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
||||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||||
|
|||||||
+209
-5
@@ -15,6 +15,9 @@ import { copyText } from "@rustpad/clipboard";
|
|||||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||||
import { bindEmojiPicker } from "@rustpad/emoji-picker";
|
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 { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
||||||
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||||
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
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 };
|
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
|
||||||
const fileAlias = current.getAttribute("data-file-alias");
|
const fileAlias = current.getAttribute("data-file-alias");
|
||||||
const fileName = current.getAttribute("data-file-name");
|
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" && fileAlias === "file" && fileName) return { open: `[file=${fileName},`, close: "]", atomic: null };
|
||||||
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
|
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
|
||||||
if (tag === "img") {
|
if (tag === "img") {
|
||||||
@@ -586,6 +593,155 @@ export function startNoteEditor(adapter) {
|
|||||||
return { start, end: start + lines[lineIndex].length, text: lines[lineIndex], lineIndex };
|
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) {
|
function tableCellBounds(line, cellIndex) {
|
||||||
const first = line.search(/\S|$/);
|
const first = line.search(/\S|$/);
|
||||||
const trailingWhitespace = (line.match(/\s*$/) || [""])[0].length;
|
const trailingWhitespace = (line.match(/\s*$/) || [""])[0].length;
|
||||||
@@ -813,7 +969,8 @@ export function startNoteEditor(adapter) {
|
|||||||
if (activeView() !== "split") return;
|
if (activeView() !== "split") return;
|
||||||
scrollPreviewToAnchor(editorScrollAnchor());
|
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() {
|
function scheduleDocumentSave() {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (!canEditDocument()) {
|
if (!canEditDocument()) {
|
||||||
@@ -922,10 +1079,57 @@ export function startNoteEditor(adapter) {
|
|||||||
editor, toast, getAccessToken: () => accessToken,
|
editor, toast, getAccessToken: () => accessToken,
|
||||||
canDelete: () => Boolean(info?.can_delete_files),
|
canDelete: () => Boolean(info?.can_delete_files),
|
||||||
canUpload: () => Boolean(info?.can_upload_files),
|
canUpload: () => Boolean(info?.can_upload_files),
|
||||||
|
canEdit: canEditDocument,
|
||||||
endpoints: adapter.fileEndpoints,
|
endpoints: adapter.fileEndpoints,
|
||||||
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
|
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
|
||||||
});
|
});
|
||||||
refreshFilesForAliases = () => loadFiles();
|
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() {
|
function connect() {
|
||||||
socket?.stop();
|
socket?.stop();
|
||||||
socket = adapter.createSocket({
|
socket = adapter.createSocket({
|
||||||
@@ -1406,17 +1610,17 @@ export function startNoteEditor(adapter) {
|
|||||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
});
|
});
|
||||||
preview.addEventListener("focusin", event => {
|
preview.addEventListener("focusin", event => {
|
||||||
const target = event.target.closest('.preview-editable[contenteditable="true"]');
|
const target = previewEditingHost(event.target);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
target.dataset.originalHtml = target.innerHTML;
|
target.dataset.originalHtml = target.innerHTML;
|
||||||
target.dataset.originalValue = markdownFromPreview(target);
|
target.dataset.originalValue = markdownFromPreview(target);
|
||||||
});
|
});
|
||||||
preview.addEventListener("beforeinput", event => {
|
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();
|
if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault();
|
||||||
});
|
});
|
||||||
preview.addEventListener("keydown", event => {
|
preview.addEventListener("keydown", event => {
|
||||||
const target = event.target.closest('.preview-editable[contenteditable="true"]');
|
const target = previewEditingHost(event.target);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1439,7 +1643,7 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
preview.addEventListener("blur", event => {
|
preview.addEventListener("blur", event => {
|
||||||
const target = event.target.closest(".preview-editable");
|
const target = previewEditingHost(event.target);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
if (cancelledPreviewEdits.has(target)) {
|
if (cancelledPreviewEdits.has(target)) {
|
||||||
cancelledPreviewEdits.delete(target);
|
cancelledPreviewEdits.delete(target);
|
||||||
|
|||||||
+97
-30
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
* Source-Available Code / Dual-Licensed.
|
* Source-Available Code / Dual-Licensed.
|
||||||
*
|
*
|
||||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
* Commercial or production use requires a valid paid license.
|
* Commercial or production use requires a valid paid license.
|
||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
@@ -36,6 +36,29 @@ function aliasCode(filename, label, mimeType) {
|
|||||||
return `[${kind}=${filename},${safeLabel}]`;
|
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) {
|
function markdownCode(url, label, mimeType) {
|
||||||
return String(mimeType || "").startsWith("image/") ? `` : `[${label}](${url})`;
|
return String(mimeType || "").startsWith("image/") ? `` : `[${label}](${url})`;
|
||||||
}
|
}
|
||||||
@@ -47,11 +70,12 @@ function safeAttachmentUrl(value) {
|
|||||||
: safePublicUrl(raw, { allowMailto: false });
|
: 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 dialog = document.querySelector("#files-dialog");
|
||||||
const list = document.querySelector("#files-list");
|
const list = document.querySelector("#files-list");
|
||||||
const input = document.querySelector("#file-input");
|
const input = document.querySelector("#file-input");
|
||||||
const footer = document.querySelector("#footer-files");
|
const footer = document.querySelector("#footer-files");
|
||||||
|
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||||
|
|
||||||
async function loadFiles({ open = false } = {}) {
|
async function loadFiles({ open = false } = {}) {
|
||||||
try {
|
try {
|
||||||
@@ -80,32 +104,19 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#upload-button").addEventListener("click", () => {
|
function insertText(text, range = null, inputType = "insertText") {
|
||||||
if (!canUpload()) {
|
const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length));
|
||||||
toast("Log in with read-write access to upload files.");
|
const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length));
|
||||||
return;
|
editor.setRangeText(text, start, end, "end");
|
||||||
}
|
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data: text }));
|
||||||
input.click();
|
return { start: start + text.length, end: start + text.length };
|
||||||
});
|
}
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function uploadFile(file, onUploaded) {
|
||||||
const uploadToast = createUploadToast(file.name);
|
const uploadToast = createUploadToast(file.name);
|
||||||
let completed = false;
|
let completed = false;
|
||||||
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
||||||
|
|
||||||
const uploadFile = async () => {
|
const run = async () => {
|
||||||
uploadToast.start();
|
uploadToast.start();
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("access_token", getAccessToken() || "");
|
form.append("access_token", getAccessToken() || "");
|
||||||
@@ -120,18 +131,75 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
|
|||||||
if (completed) return;
|
if (completed) return;
|
||||||
completed = true;
|
completed = true;
|
||||||
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
|
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
|
||||||
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
|
await onUploaded(text);
|
||||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
uploadToast.success();
|
uploadToast.success();
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const retryable = !error.status || retryableStatuses.has(error.status);
|
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 = "";
|
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 }));
|
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]");
|
const addButton = event.target.closest("[data-add-file-to-note]");
|
||||||
if (addButton) {
|
if (addButton) {
|
||||||
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
|
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
|
||||||
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
|
insertText(text);
|
||||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
toast("Added to note");
|
toast("Added to note");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczynski @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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACTIVE_PREVIEW_EDIT_SELECTOR = '.preview-editable[contenteditable="true"]';
|
||||||
|
|
||||||
|
export function previewEditingHost(target) {
|
||||||
|
return target?.matches?.(ACTIVE_PREVIEW_EDIT_SELECTOR) ? target : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczynski @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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function createRenderQueue(renderNow, schedule = queueMicrotask) {
|
||||||
|
if (typeof renderNow !== "function") throw new TypeError("renderNow must be a function");
|
||||||
|
if (typeof schedule !== "function") throw new TypeError("schedule must be a function");
|
||||||
|
|
||||||
|
let rendering = false;
|
||||||
|
let pending = false;
|
||||||
|
let scheduled = false;
|
||||||
|
|
||||||
|
const schedulePendingRender = () => {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
schedule(() => {
|
||||||
|
scheduled = false;
|
||||||
|
if (!pending) return;
|
||||||
|
pending = false;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (rendering) {
|
||||||
|
pending = true;
|
||||||
|
schedulePendingRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = false;
|
||||||
|
rendering = true;
|
||||||
|
try {
|
||||||
|
renderNow();
|
||||||
|
} finally {
|
||||||
|
rendering = false;
|
||||||
|
if (pending) schedulePendingRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return render;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user