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() ? (