Route mobile editing to standalone page
This commit is contained in:
1 parent
832de54177
commit
78eaaa8d09
3 files changed
+421
-4
No files matched your search
@@ -0,0 +1,391 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>编辑笔记 - EdgeEver</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #fff;
|
||||
padding: max(8px, env(safe-area-inset-top)) 12px 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px 16px max(24px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #020617;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.status.dirty,
|
||||
.status.saving {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input.title {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
input.tags {
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 72dvh;
|
||||
resize: none;
|
||||
font-size: 18px;
|
||||
line-height: 1.65;
|
||||
padding-top: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
display: none;
|
||||
border: 1px solid #fecdd3;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #9f1239;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.error-box.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<button id="back" type="button" aria-label="返回">‹</button>
|
||||
<div style="display: flex; min-width: 0; flex: 1; justify-content: flex-end; gap: 8px">
|
||||
<span class="status" id="status">加载中</span>
|
||||
<button class="primary" id="done" type="button">完成</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="error-box" id="error"></div>
|
||||
<label class="field">
|
||||
<input class="title" id="title" autocomplete="on" autocorrect="on" inputmode="text" placeholder="无标题笔记" />
|
||||
</label>
|
||||
<div class="meta">
|
||||
<input class="tags" id="tags" autocomplete="on" autocorrect="on" inputmode="text" placeholder="添加标签,用逗号分隔" />
|
||||
</div>
|
||||
<textarea id="body" autocomplete="on" autocorrect="on" inputmode="text" enterkeyhint="enter" autofocus placeholder="开始记录..."></textarea>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const AUTO_SAVE_DELAY_MS = 1200;
|
||||
const DRAFT_STORAGE_PREFIX = "edgeever-mobile-edit-draft:";
|
||||
const params = new URLSearchParams(location.search);
|
||||
const memoId = params.get("memoId");
|
||||
const returnTo = params.get("returnTo") || "/";
|
||||
const titleInput = document.getElementById("title");
|
||||
const tagsInput = document.getElementById("tags");
|
||||
const bodyInput = document.getElementById("body");
|
||||
const statusNode = document.getElementById("status");
|
||||
const errorNode = document.getElementById("error");
|
||||
const doneButton = document.getElementById("done");
|
||||
const backButton = document.getElementById("back");
|
||||
let memo = null;
|
||||
let dirty = false;
|
||||
let saving = false;
|
||||
let saveTimer = 0;
|
||||
let lastSavedSnapshot = "";
|
||||
|
||||
const draftKey = () => `${DRAFT_STORAGE_PREFIX}${memoId}`;
|
||||
|
||||
const setStatus = (label, kind = "") => {
|
||||
statusNode.textContent = label;
|
||||
statusNode.className = `status ${kind}`.trim();
|
||||
};
|
||||
|
||||
const showError = (message) => {
|
||||
errorNode.textContent = message;
|
||||
errorNode.classList.add("show");
|
||||
setStatus("保存失败", "error");
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
errorNode.textContent = "";
|
||||
errorNode.classList.remove("show");
|
||||
};
|
||||
|
||||
const docToMarkdown = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.content)) return "";
|
||||
return doc.content
|
||||
.map((node) => inlineText(node && node.content))
|
||||
.filter(Boolean)
|
||||
.join("\\n\\n");
|
||||
};
|
||||
|
||||
const inlineText = (content) => {
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((node) => {
|
||||
if (!node || typeof node !== "object") return "";
|
||||
if (typeof node.text === "string") return node.text;
|
||||
if (node.type === "hardBreak") return "\\n";
|
||||
return inlineText(node.content);
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const parseTags = (value) =>
|
||||
value
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const currentSnapshot = () =>
|
||||
JSON.stringify({
|
||||
title: titleInput.value,
|
||||
tagsText: tagsInput.value,
|
||||
body: bodyInput.value,
|
||||
});
|
||||
|
||||
const persistLocalDraft = () => {
|
||||
if (!memoId) return;
|
||||
localStorage.setItem(
|
||||
draftKey(),
|
||||
JSON.stringify({
|
||||
title: titleInput.value,
|
||||
tagsText: tagsInput.value,
|
||||
body: bodyInput.value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const readLocalDraft = () => {
|
||||
if (!memoId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey());
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const request = async (path, init) => {
|
||||
const response = await fetch(path, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init && init.headers ? init.headers : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body && body.error && body.error.message ? body.error.message : response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const saveNow = async ({ keepalive = false } = {}) => {
|
||||
if (!memo || saving) return false;
|
||||
const snapshot = currentSnapshot();
|
||||
if (!dirty && snapshot === lastSavedSnapshot) return true;
|
||||
|
||||
window.clearTimeout(saveTimer);
|
||||
saving = true;
|
||||
doneButton.disabled = true;
|
||||
backButton.disabled = true;
|
||||
setStatus("保存中", "saving");
|
||||
clearError();
|
||||
|
||||
try {
|
||||
const data = await request(`/api/v1/memos/${encodeURIComponent(memo.id)}`, {
|
||||
method: "PATCH",
|
||||
keepalive,
|
||||
body: JSON.stringify({
|
||||
expectedRevision: memo.revision,
|
||||
title: titleInput.value,
|
||||
contentMarkdown: bodyInput.value,
|
||||
tags: parseTags(tagsInput.value),
|
||||
}),
|
||||
});
|
||||
memo = data.memo;
|
||||
lastSavedSnapshot = currentSnapshot();
|
||||
dirty = false;
|
||||
localStorage.removeItem(draftKey());
|
||||
setStatus("已保存");
|
||||
return true;
|
||||
} catch (error) {
|
||||
persistLocalDraft();
|
||||
showError(error && error.message ? error.message : "保存失败,已保留本地草稿");
|
||||
return false;
|
||||
} finally {
|
||||
saving = false;
|
||||
doneButton.disabled = false;
|
||||
backButton.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSave = () => {
|
||||
dirty = true;
|
||||
persistLocalDraft();
|
||||
setStatus("未保存", "dirty");
|
||||
window.clearTimeout(saveTimer);
|
||||
saveTimer = window.setTimeout(() => void saveNow(), AUTO_SAVE_DELAY_MS);
|
||||
};
|
||||
|
||||
const leavePage = async () => {
|
||||
await saveNow();
|
||||
location.href = returnTo;
|
||||
};
|
||||
|
||||
const loadMemo = async () => {
|
||||
if (!memoId) {
|
||||
showError("缺少 memoId");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request(`/api/v1/memos/${encodeURIComponent(memoId)}`);
|
||||
memo = data.memo;
|
||||
titleInput.value = memo.title || "";
|
||||
tagsInput.value = Array.isArray(memo.tags) ? memo.tags.join(", ") : "";
|
||||
bodyInput.value = memo.contentMarkdown || docToMarkdown(memo.contentJson);
|
||||
|
||||
const draft = readLocalDraft();
|
||||
if (draft && Date.parse(draft.updatedAt || "") >= Date.parse(memo.updatedAt || "")) {
|
||||
titleInput.value = draft.title || "";
|
||||
tagsInput.value = draft.tagsText || "";
|
||||
bodyInput.value = draft.body || "";
|
||||
dirty = true;
|
||||
setStatus("本地草稿", "dirty");
|
||||
scheduleSave();
|
||||
} else {
|
||||
lastSavedSnapshot = currentSnapshot();
|
||||
setStatus("已保存");
|
||||
}
|
||||
|
||||
for (const delay of [0, 120, 500]) {
|
||||
window.setTimeout(() => bodyInput.focus(), delay);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error && error.message ? error.message : "加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
for (const element of [titleInput, tagsInput, bodyInput]) {
|
||||
element.addEventListener("input", scheduleSave);
|
||||
}
|
||||
|
||||
doneButton.addEventListener("click", () => void leavePage());
|
||||
backButton.addEventListener("click", () => void leavePage());
|
||||
window.addEventListener("pagehide", () => {
|
||||
if (dirty) {
|
||||
persistLocalDraft();
|
||||
void saveNow({ keepalive: true });
|
||||
}
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden" && dirty) {
|
||||
persistLocalDraft();
|
||||
void saveNow({ keepalive: true });
|
||||
}
|
||||
});
|
||||
|
||||
void loadMemo();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -467,6 +467,14 @@ type RichEditorPaneProps = EditorPaneProps & {
|
||||
onRequestMobileNativeEdit?: () => void;
|
||||
};
|
||||
|
||||
const openStandaloneMobileEditor = (memoId: string) => {
|
||||
const params = new URLSearchParams({
|
||||
memoId,
|
||||
returnTo: "/",
|
||||
});
|
||||
window.location.href = `/mobile-edit.html?${params.toString()}`;
|
||||
};
|
||||
|
||||
const MobileNativeEditorPane = ({
|
||||
memo,
|
||||
notebooks,
|
||||
@@ -945,6 +953,12 @@ export const EditorPane = (props: EditorPaneProps) => {
|
||||
(mobileDefaultEditRequested || mobileNativeEditMemoId === props.memo.id)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileViewport && mobileDefaultEditRequested && props.memo?.id) {
|
||||
openStandaloneMobileEditor(props.memo.id);
|
||||
}
|
||||
}, [isMobileViewport, mobileDefaultEditRequested, props.memo?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(MOBILE_EDITOR_QUERY);
|
||||
const updateMobileViewport = () => setIsMobileViewport(mediaQuery.matches);
|
||||
@@ -961,10 +975,9 @@ export const EditorPane = (props: EditorPaneProps) => {
|
||||
|
||||
if (mobileNativeEditingActive) {
|
||||
return (
|
||||
<MobileNativeEditorPane
|
||||
{...props}
|
||||
onExitMobileNativeEdit={() => setMobileNativeEditMemoId(null)}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 items-center justify-center bg-white text-sm font-medium text-slate-400">
|
||||
打开编辑器
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -975,6 +988,7 @@ export const EditorPane = (props: EditorPaneProps) => {
|
||||
onRequestMobileNativeEdit={() => {
|
||||
if (props.memo?.id && !readOnly) {
|
||||
setMobileNativeEditMemoId(props.memo.id);
|
||||
openStandaloneMobileEditor(props.memo.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -79,6 +79,14 @@ const isStandaloneApp = () =>
|
||||
window.matchMedia("(display-mode: fullscreen)").matches ||
|
||||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
|
||||
|
||||
const openStandaloneMobileEditor = (memoId: string) => {
|
||||
const params = new URLSearchParams({
|
||||
memoId,
|
||||
returnTo: "/",
|
||||
});
|
||||
window.location.href = `/mobile-edit.html?${params.toString()}`;
|
||||
};
|
||||
|
||||
const getVerticalScrollContainer = (target: EventTarget | null) => {
|
||||
let element = target instanceof HTMLElement ? target : null;
|
||||
|
||||
@@ -1168,6 +1176,10 @@ export const WorkspaceApp = ({
|
||||
setCreatedMemoEditId(data.memo.id);
|
||||
setSelectedMemoId(data.memo.id);
|
||||
setActivePane("editor");
|
||||
|
||||
if (!isDesktopViewport()) {
|
||||
openStandaloneMobileEditor(data.memo.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user