This commit is contained in:
@@ -288,7 +288,7 @@ APP_HTML = r"""<!doctype html>
|
||||
<button type="button" data-md="link">Link</button>
|
||||
<button type="button" id="pageLinkBtn">Link to page</button>
|
||||
<button type="button" id="attachBtn">Insert image</button>
|
||||
<input id="attachInput" type="file" accept="image/*" />
|
||||
<input id="attachInput" type="file" accept="image/*,.heic,.heif" />
|
||||
</div>
|
||||
<div id="pageLinkPicker" class="link-picker" hidden>
|
||||
<select id="pageLinkSelect"></select>
|
||||
@@ -429,16 +429,21 @@ APP_HTML = r"""<!doctype html>
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.clone().json();
|
||||
} catch (_err) {
|
||||
data = { error: await res.text().catch(() => "") };
|
||||
}
|
||||
const data = await readApiResponse(res);
|
||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function readApiResponse(res) {
|
||||
try {
|
||||
return await res.clone().json();
|
||||
} catch (_err) {
|
||||
const text = await res.text().catch(() => "");
|
||||
const stripped = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
return { error: stripped || `${res.status} ${res.statusText || "Request failed"}` };
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const build = state.build || {};
|
||||
const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
|
||||
@@ -1015,21 +1020,97 @@ APP_HTML = r"""<!doctype html>
|
||||
pageLinkPicker.hidden = true;
|
||||
}
|
||||
|
||||
const MAX_UPLOAD_BYTES = 750 * 1024;
|
||||
const MAX_UPLOAD_DIMENSION = 1800;
|
||||
const MIN_UPLOAD_DIMENSION = 900;
|
||||
|
||||
function extensionOf(filename) {
|
||||
const match = /\.([a-z0-9]+)$/i.exec(filename || "");
|
||||
return match ? match[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function canCompressImage(file) {
|
||||
const ext = extensionOf(file.name);
|
||||
const compressibleExt = ["jpg", "jpeg", "png", "webp", "heic", "heif"];
|
||||
return (file.type.startsWith("image/") || compressibleExt.includes(ext)) && !["gif", "svg"].includes(ext);
|
||||
}
|
||||
|
||||
function loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("This browser could not read that image format."));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error("Could not prepare image for upload."));
|
||||
}, "image/jpeg", quality);
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareImageUpload(file) {
|
||||
const ext = extensionOf(file.name);
|
||||
if (!canCompressImage(file) || (file.size <= MAX_UPLOAD_BYTES && !["heic", "heif"].includes(ext))) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const img = await loadImage(file);
|
||||
let maxDimension = MAX_UPLOAD_DIMENSION;
|
||||
let quality = 0.82;
|
||||
const stem = (file.name || "image").replace(/\.[^.]+$/, "") || "image";
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const scale = Math.min(1, maxDimension / Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height));
|
||||
const width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale));
|
||||
const height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
const blob = await canvasToBlob(canvas, quality);
|
||||
if (blob.size <= MAX_UPLOAD_BYTES || maxDimension <= MIN_UPLOAD_DIMENSION) {
|
||||
return new File([blob], `${stem}.jpg`, { type: "image/jpeg", lastModified: Date.now() });
|
||||
}
|
||||
maxDimension = Math.max(MIN_UPLOAD_DIMENSION, Math.round(maxDimension * 0.82));
|
||||
quality = Math.max(0.62, quality - 0.05);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async function uploadAttachment() {
|
||||
const file = attachInput.files[0];
|
||||
if (!file) return;
|
||||
saveMessage.textContent = "Uploading image.";
|
||||
const body = new FormData();
|
||||
body.append("attachment", file);
|
||||
body.append("pagePath", currentEditorPath() || "index.org");
|
||||
saveMessage.textContent = "Preparing image.";
|
||||
try {
|
||||
const uploadFile = await prepareImageUpload(file);
|
||||
const body = new FormData();
|
||||
body.append("attachment", uploadFile, uploadFile.name);
|
||||
body.append("pagePath", currentEditorPath() || "index.org");
|
||||
saveMessage.textContent = uploadFile.size < file.size ? "Uploading optimized image." : "Uploading image.";
|
||||
const res = await fetch("/api/upload", { method: "POST", body });
|
||||
const data = await res.json();
|
||||
const data = await readApiResponse(res);
|
||||
if (!res.ok) throw new Error(data.error || "Upload failed");
|
||||
replaceSelection(`\n${data.insertText || data.markdown}\n`);
|
||||
saveMessage.textContent = "Image inserted.";
|
||||
} catch (err) {
|
||||
saveMessage.textContent = err.message;
|
||||
saveMessage.textContent = err.message || "Upload failed";
|
||||
} finally {
|
||||
attachInput.value = "";
|
||||
}
|
||||
@@ -3487,4 +3568,3 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_error(self, code: int, message: str | None = None, explain: str | None = None) -> None:
|
||||
if urlparse(self.path).path.startswith("/api/"):
|
||||
status = HTTPStatus(code)
|
||||
self.send_json({"error": message or status.phrase}, status)
|
||||
return
|
||||
super().send_error(code, message, explain)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/":
|
||||
|
||||
Reference in New Issue
Block a user