From 53290535ff50fac2cbf1a3f0daba95923d9aa806 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 29 Sep 2026 16:20:57 +0800 Subject: [PATCH] feat(web): share note size, line height, and palette Keep the native editor theme, store column width on this device, and publish account font size, line height, palette, and custom CSS to the editor, public shares, and WeChat paste. Highlight custom CSS with CodeMirror. --- apps/api/src/share-routes.test.mjs | 53 +++ apps/api/src/share-routes.ts | 73 ++- apps/web/package.json | 1 + apps/web/src/components/EditorPane.tsx | 116 ++--- .../web/src/components/EditorPhonePreview.tsx | 14 +- apps/web/src/components/EditorToolbar.tsx | 23 - apps/web/src/components/PublicSharePage.tsx | 16 +- apps/web/src/components/SettingsPane.tsx | 22 +- apps/web/src/components/WorkspaceApp.tsx | 15 +- .../editor/MarkdownSourceEditor.tsx | 2 +- .../settings/CustomEditorThemeDialog.tsx | 16 - .../settings/NoteProseCssEditor.tsx | 66 +++ .../settings/PreferenceCard.test.mjs | 32 +- .../components/settings/PreferenceCard.tsx | 227 ++++++--- apps/web/src/hooks/useNoteProse.ts | 65 +++ apps/web/src/hooks/useWorkspacePreferences.ts | 20 +- apps/web/src/lib/css-sandbox.ts | 74 +-- apps/web/src/lib/custom-editor-theme.ts | 2 +- apps/web/src/lib/dark-theme.test.mjs | 4 +- .../web/src/lib/editor-content-width.test.mjs | 21 + apps/web/src/lib/editor-content-width.ts | 48 ++ apps/web/src/lib/markdown-theme.test.mjs | 12 +- apps/web/src/lib/note-prose-local.ts | 60 +++ apps/web/src/lib/publish-layout.test.mjs | 34 +- apps/web/src/lib/wechat-copy.ts | 211 +++++---- .../web/src/styles/editor-typography.test.mjs | 9 +- apps/web/src/styles/globals.css | 21 +- apps/web/src/styles/note-prose.css | 132 ++++++ bun.lock | 1 + migrations/0056_user_note_prose.sql | 5 + packages/client/src/index.ts | 10 + packages/shared/src/i18n/en-US.ts | 36 +- packages/shared/src/i18n/ja.ts | 36 +- packages/shared/src/i18n/zh-CN.ts | 36 +- packages/shared/src/index.ts | 2 + packages/shared/src/note-prose-css.ts | 67 +++ packages/shared/src/note-prose.test.mjs | 97 ++++ packages/shared/src/note-prose.ts | 434 ++++++++++++++++++ packages/shared/src/schemas.ts | 29 ++ packages/shared/src/sharing.ts | 3 + 40 files changed, 1741 insertions(+), 404 deletions(-) create mode 100644 apps/web/src/components/settings/NoteProseCssEditor.tsx create mode 100644 apps/web/src/hooks/useNoteProse.ts create mode 100644 apps/web/src/lib/editor-content-width.test.mjs create mode 100644 apps/web/src/lib/editor-content-width.ts create mode 100644 apps/web/src/lib/note-prose-local.ts create mode 100644 apps/web/src/styles/note-prose.css create mode 100644 migrations/0056_user_note_prose.sql create mode 100644 packages/shared/src/note-prose-css.ts create mode 100644 packages/shared/src/note-prose.test.mjs create mode 100644 packages/shared/src/note-prose.ts diff --git a/apps/api/src/share-routes.test.mjs b/apps/api/src/share-routes.test.mjs index 71bedfc2..fa6af76f 100644 --- a/apps/api/src/share-routes.test.mjs +++ b/apps/api/src/share-routes.test.mjs @@ -123,6 +123,59 @@ describe("public memo shares", () => { sqlite.close(); }); + test("publishes account prose with the public share", async () => { + const { sqlite, environment } = createDatabaseEnvironment(); + sqlite.query("INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)") + .run("usr_author", "author", "hash"); + sqlite.query("UPDATE memo_shares SET created_by = ? WHERE id = ?") + .run("usr_author", "share_source"); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("auth", { kind: "user", actorType: "user", actorId: "usr_author", username: "author", displayName: null, scopes: [], workspaceId: "ws_member", role: "owner" }); + await next(); + }); + registerMemoShareRoutes(app); + registerPublicShareRoutes(app); + + const before = await app.request(`/api/public/shares/${sourceToken}`, {}, environment); + expect((await before.json()).share.prose).toEqual({ + fontSize: 16, + lineHeight: 1.65, + palette: "native", + customCss: "", + customColors: null, + }); + + const saved = await app.request("/api/v1/me/note-prose", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + fontSize: 18, + lineHeight: 2, + palette: "clay", + customCss: "p { color: red; font-size: 40px; line-height: 3; background: url(https://evil.test/a.png); }", + }), + }, environment); + expect(saved.status).toBe(200); + const body = await saved.json(); + expect(body.fontSize).toBe(18); + expect(body.lineHeight).toBe(2); + expect(body.palette).toBe("clay"); + expect(body.customCss).toContain("color: red"); + expect(body.customCss).not.toContain("font-size"); + expect(body.customCss).not.toContain("line-height"); + expect(body.customCss).not.toContain("url("); + + const published = await (await app.request(`/api/public/shares/${sourceToken}`, {}, environment)).json(); + expect(published.share.prose).toMatchObject({ + fontSize: 18, + lineHeight: 2, + palette: "clay", + }); + expect(published.share.prose.customCss).not.toContain("font-size"); + sqlite.close(); + }); + test("saves a signed-in user's published note font", async () => { const { sqlite, environment } = createDatabaseEnvironment(); sqlite.query("INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)") diff --git a/apps/api/src/share-routes.ts b/apps/api/src/share-routes.ts index de9edf6a..ac439431 100644 --- a/apps/api/src/share-routes.ts +++ b/apps/api/src/share-routes.ts @@ -1,4 +1,4 @@ -import { collectMemoLinkIds, isPdfAttachment, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, parsePublishedNoteBodyFont, PublicShareUnlockSchema, resolveMemoContentDoc, resolvePlayableMediaMimeType, type MemoShare, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; +import { accountNoteProseFromRow, collectMemoLinkIds, isPdfAttachment, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, resolvePlayableMediaMimeType, sanitizeNoteProseCss, type AccountNoteProse, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; import { zValidator } from "@hono/zod-validator"; import type { Hono } from "hono"; import { getCookie, setCookie } from "hono/cookie"; @@ -41,7 +41,7 @@ type PublicMemoShareRow = { updated_at: string; password_hash: string | null; note_body_font: string | null; -}; +} & NoteProseAccountRow; type ShareGateRow = { workspace_id: string; password_hash: string | null; @@ -129,6 +129,23 @@ const loadShareGate = async (c: AppContext, token: string) => const selectMemoShareSql = `SELECT memo_id, token, created_at, updated_at, password_hash FROM memo_shares WHERE memo_id = ? AND workspace_id = ?`; +const NOTE_PROSE_COLUMNS = `note_prose_font_size, note_prose_line_height, note_prose_palette, note_prose_custom_css, note_prose_custom_colors`; + +const presentAccountNoteProse = (row: NoteProseAccountRow | null): AccountNoteProse => { + const account = accountNoteProseFromRow(row); + return { + ...account, + customCss: account.customCss === null ? null : sanitizeNoteProseCss(account.customCss), + }; +}; + +const loadAccountNoteProse = async (c: AppContext, actorId: string) => { + const row = await c.env.storage.db.prepare( + `SELECT ${NOTE_PROSE_COLUMNS} FROM users WHERE id = ?` + ).bind(actorId).first(); + return row ? presentAccountNoteProse(row) : null; +}; + export const registerPublicShareRoutes = (app: Hono) => { app.get("/api/public/shares/:token", async (c) => { const token = normalizeShareToken(c.req.param("token")); @@ -136,7 +153,8 @@ export const registerPublicShareRoutes = (app: Hono) => { const row = await c.env.storage.db.prepare( `SELECT ms.workspace_id, m.title, mc.content_json, mc.content_markdown, m.tags_json, m.updated_at, ms.password_hash, - u.note_body_font + u.note_body_font, u.note_prose_font_size, u.note_prose_line_height, u.note_prose_palette, + u.note_prose_custom_css, u.note_prose_custom_colors FROM memo_shares ms INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id INNER JOIN memo_contents mc ON mc.memo_id = m.id @@ -174,6 +192,7 @@ export const registerPublicShareRoutes = (app: Hono) => { updatedAt: row.updated_at, memoShareTokens, bodyFont: parsePublishedNoteBodyFont(row.note_body_font), + prose: publicNoteProseFromAccount(presentAccountNoteProse(row)), }; c.header("Cache-Control", "private, no-store"); c.header("X-Robots-Tag", "noindex, nofollow, noarchive"); @@ -289,6 +308,54 @@ export const registerMemoShareRoutes = (app: Hono) => { return c.json({ bodyFont }); }); + app.get("/api/v1/me/note-prose", async (c) => { + const denied = requireUser(c); + if (denied) return denied; + const actorId = c.get("auth").actorId; + if (!actorId) return apiError(c, "note_prose_unavailable", "This session cannot read note prose settings", 403); + const prose = await loadAccountNoteProse(c, actorId); + if (!prose) return notFound(c, "User not found"); + return c.json(prose); + }); + + app.put("/api/v1/me/note-prose", zValidator("json", NoteProseUpdateSchema), async (c) => { + const denied = requireUser(c); + if (denied) return denied; + const actorId = c.get("auth").actorId; + if (!actorId) return apiError(c, "note_prose_unavailable", "This session cannot update note prose settings", 403); + const patch = c.req.valid("json"); + const assignments: string[] = []; + const values: Array = []; + if (patch.fontSize !== undefined) { + assignments.push("note_prose_font_size = ?"); + values.push(patch.fontSize); + } + if (patch.lineHeight !== undefined) { + assignments.push("note_prose_line_height = ?"); + values.push(patch.lineHeight === null ? null : String(patch.lineHeight)); + } + if (patch.palette !== undefined) { + assignments.push("note_prose_palette = ?"); + values.push(patch.palette); + } + if (patch.customCss !== undefined) { + assignments.push("note_prose_custom_css = ?"); + values.push(patch.customCss === null ? null : sanitizeNoteProseCss(patch.customCss)); + } + if (patch.customColors !== undefined) { + assignments.push("note_prose_custom_colors = ?"); + values.push(patch.customColors === null ? null : JSON.stringify(patch.customColors)); + } + assignments.push("updated_at = ?"); + values.push(isoNow(), actorId); + await c.env.storage.db.prepare( + `UPDATE users SET ${assignments.join(", ")} WHERE id = ?` + ).bind(...values).run(); + const prose = await loadAccountNoteProse(c, actorId); + if (!prose) return notFound(c, "User not found"); + return c.json(prose); + }); + app.get("/api/v1/memos/:id/share", async (c) => { const denied = requireUser(c); if (denied) return denied; diff --git a/apps/web/package.json b/apps/web/package.json index b76001db..051096aa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "dependencies": { "@antv/infographic": "0.2.20", "@antv/x6": "3.1.8", + "@codemirror/lang-css": "6.3.1", "@codemirror/lang-markdown": "^6.5.2", "@edgeever/client": "workspace:*", "@floating-ui/react-dom": "^2.1.8", diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 7b036576..597ced59 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -89,7 +89,7 @@ import { type NoteLinkSuggestionLabels, } from "./editor/NoteLinkSuggestion"; import { WeChatIcon } from "./WeChatIcon"; -import { isNamedEditorTheme, useEditorTheme, useMarkdownTheme } from "./ThemeProvider"; +import { useMarkdownTheme } from "./ThemeProvider"; const MarkdownSourceEditor = lazy(() => import("./editor/MarkdownSourceEditor").then((module) => ({ default: module.MarkdownSourceEditor, @@ -114,12 +114,13 @@ import { api } from "@/lib/api"; import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceDownloadUrl, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { contentReferencesStagedResourceUrl, findMatchingMemoResource, repairMemoStagedResourceUrls, repairTiptapStagedResourceUrls } from "@/lib/staged-resource-repair"; import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; -import { EDITOR_CONTENT_MAX_WIDTH, EDITOR_CONTENT_MAX_WIDTH_COLLAPSED } from "@/lib/workspace-ui"; +import { editorContentColumnMaxWidth, type EditorContentWidth } from "@/lib/editor-content-width"; import { countMemoCharacters, createEdgeEverDocumentExtensions, docToMarkdown, MEMO_CONTENT_STYLE, + noteProseCssVariables, markdownToDoc, normalizeImageGalleries, PLUGIN_EMBED_NODE_TYPE, @@ -128,6 +129,7 @@ import { isPdfAttachment, resolveMemoContentDoc, type Notebook, + type ResolvedNoteProse, type MemoDetail, type MemoSummary, type MemoEditSession, @@ -161,11 +163,9 @@ import { writeDesktopReadingProtectionPreference, writeEditorOutlineCollapsedPreference, writeEditorPhonePreviewPreference, - type EditorContentAlignment, type MemoDocumentActionRequest, type ShortcutSettings, } from "@/lib/app-helpers"; -import { isPaperEditorTheme, publishEditorCssVars, resolvePaperEditorTheme } from "@/lib/publish-layout"; import { ThemeBlock } from "./ThemeBlock"; import { EditorPhonePreview, PhonePreviewGlyph } from "./EditorPhonePreview"; import { @@ -291,7 +291,8 @@ type EditorPaneProps = { repository: EdgeEverRepository; desktopFocusMode: boolean; onToggleDesktopFocusMode: () => void; - editorContentAlignment: EditorContentAlignment; + editorContentWidth: EditorContentWidth; + noteProse: ResolvedNoteProse; mobileDefaultEditMemoId: string | null; pendingInsertFiles?: { memoId: string; files: File[] } | null; onPendingInsertFilesConsumed?: () => void; @@ -369,7 +370,8 @@ const RichEditorPane = ({ repository, desktopFocusMode, onToggleDesktopFocusMode, - editorContentAlignment, + editorContentWidth, + noteProse, mobileDefaultEditMemoId, pendingInsertFiles = null, onPendingInsertFilesConsumed, @@ -412,7 +414,6 @@ const RichEditorPane = ({ onRequestMobileNativeEdit, }: RichEditorPaneProps) => { const { t, i18n } = useTranslation(); - const { customEditorTheme, editorTheme } = useEditorTheme(); const { markdownTheme } = useMarkdownTheme(); const queryClient = useQueryClient(); const resourceInsertionLimit = useMemo(createFileBatchQueue, []); @@ -484,6 +485,9 @@ const RichEditorPane = ({ const [isMobileViewport, setIsMobileViewport] = useState(() => typeof window === "undefined" ? false : window.matchMedia(MOBILE_EDITOR_QUERY).matches ); + const [isDesktopColumn, setIsDesktopColumn] = useState(() => + typeof window === "undefined" ? false : window.matchMedia("(min-width: 1024px)").matches + ); const [isMobileEditing, setIsMobileEditing] = useState(false); const [desktopReadingProtection, setDesktopReadingProtection] = useState(readDesktopReadingProtectionPreference); const [mobilePlainText, setMobilePlainText] = useState(""); @@ -820,6 +824,14 @@ const RichEditorPane = ({ return () => mediaQuery.removeEventListener("change", updateMobileViewport); }, []); + useEffect(() => { + const mediaQuery = window.matchMedia("(min-width: 1024px)"); + const updateDesktopColumn = () => setIsDesktopColumn(mediaQuery.matches); + updateDesktopColumn(); + mediaQuery.addEventListener("change", updateDesktopColumn); + return () => mediaQuery.removeEventListener("change", updateDesktopColumn); + }, []); + useEffect(() => { setIsMobileEditing(false); setMobileToolbarOpen(false); @@ -3545,13 +3557,9 @@ const RichEditorPane = ({ }; const editorColumnMatchesArticle = !useMarkdownSourceEditor; - const editorColumnStyle: CSSProperties | undefined = editorColumnMatchesArticle && !desktopFocusMode && editorContentAlignment === "center" - ? { - maxWidth: editorOutlineCollapsed - ? EDITOR_CONTENT_MAX_WIDTH_COLLAPSED - : EDITOR_CONTENT_MAX_WIDTH, - } - : undefined; + const contentColumnMode = desktopFocusMode ? "focus" : editorOutlineCollapsed ? "collapsed" : "reading"; + const contentColumnMaxWidth = editorContentColumnMaxWidth(editorContentWidth, contentColumnMode); + const focusTitleMaxWidth = editorContentColumnMaxWidth(editorContentWidth, "focus"); const savedQuietly = saveState !== "saving" && saveState !== "error" && saveState !== "conflict" @@ -3617,16 +3625,18 @@ const RichEditorPane = ({ className={cn( "flex min-w-0 flex-1", desktopFocusMode && "mx-auto w-full max-w-[1400px]", - editorColumnMatchesArticle && editorContentAlignment === "center" && "justify-center", )} style={editorColumnMatchesArticle && editorScrollbarGutter > 0 ? { paddingRight: editorScrollbarGutter } : undefined} >
0 ? { paddingRight: titleStatusClearancePx } : undefined} + style={{ + ...(isDesktopColumn && desktopFocusMode ? { maxWidth: focusTitleMaxWidth } : {}), + ...(titleStatusClearancePx > 0 ? { paddingRight: titleStatusClearancePx } : {}), + }} >
- {!isNamedEditorTheme(editorTheme) && customEditorTheme.customCss && ( + {noteProse.customCss.trim() ? (