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" data-md="link">Link</button>
|
||||||
<button type="button" id="pageLinkBtn">Link to page</button>
|
<button type="button" id="pageLinkBtn">Link to page</button>
|
||||||
<button type="button" id="attachBtn">Insert image</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>
|
||||||
<div id="pageLinkPicker" class="link-picker" hidden>
|
<div id="pageLinkPicker" class="link-picker" hidden>
|
||||||
<select id="pageLinkSelect"></select>
|
<select id="pageLinkSelect"></select>
|
||||||
@@ -429,16 +429,21 @@ APP_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
|
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
|
||||||
let data = null;
|
const data = await readApiResponse(res);
|
||||||
try {
|
|
||||||
data = await res.clone().json();
|
|
||||||
} catch (_err) {
|
|
||||||
data = { error: await res.text().catch(() => "") };
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||||
return data;
|
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() {
|
function renderStatus() {
|
||||||
const build = state.build || {};
|
const build = state.build || {};
|
||||||
const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
|
const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
|
||||||
@@ -1015,21 +1020,97 @@ APP_HTML = r"""<!doctype html>
|
|||||||
pageLinkPicker.hidden = true;
|
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() {
|
async function uploadAttachment() {
|
||||||
const file = attachInput.files[0];
|
const file = attachInput.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
saveMessage.textContent = "Uploading image.";
|
saveMessage.textContent = "Preparing image.";
|
||||||
const body = new FormData();
|
|
||||||
body.append("attachment", file);
|
|
||||||
body.append("pagePath", currentEditorPath() || "index.org");
|
|
||||||
try {
|
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 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");
|
if (!res.ok) throw new Error(data.error || "Upload failed");
|
||||||
replaceSelection(`\n${data.insertText || data.markdown}\n`);
|
replaceSelection(`\n${data.insertText || data.markdown}\n`);
|
||||||
saveMessage.textContent = "Image inserted.";
|
saveMessage.textContent = "Image inserted.";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
saveMessage.textContent = err.message;
|
saveMessage.textContent = err.message || "Upload failed";
|
||||||
} finally {
|
} finally {
|
||||||
attachInput.value = "";
|
attachInput.value = "";
|
||||||
}
|
}
|
||||||
@@ -3487,4 +3568,3 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
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:
|
def do_GET(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
if parsed.path == "/":
|
if parsed.path == "/":
|
||||||
|
|||||||
Reference in New Issue
Block a user