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.
This commit is contained in:
tianma-if committed 2026-09-29 16:20:57 +08:00
1 parent c7c0fd86d6
commit 53290535ff
40 files changed
+1741 -404

No files matched your search

+53
View File
@@ -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 (?, ?, ?)")
+70 -3
View File
@@ -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<NoteProseAccountRow>();
return row ? presentAccountNoteProse(row) : null;
};
export const registerPublicShareRoutes = (app: Hono<AppEnv>) => {
app.get("/api/public/shares/:token", async (c) => {
const token = normalizeShareToken(c.req.param("token"));
@@ -136,7 +153,8 @@ export const registerPublicShareRoutes = (app: Hono<AppEnv>) => {
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<AppEnv>) => {
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<AppEnv>) => {
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<string | number | null> = [];
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;
+1
View File
@@ -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",
+46 -70
View File
@@ -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}
>
<div
className={cn(
"min-w-0 w-full",
desktopFocusMode && "mx-auto max-w-[960px]",
isDesktopColumn && desktopFocusMode && "mx-auto",
)}
style={titleStatusClearancePx > 0 ? { paddingRight: titleStatusClearancePx } : undefined}
style={{
...(isDesktopColumn && desktopFocusMode ? { maxWidth: focusTitleMaxWidth } : {}),
...(titleStatusClearancePx > 0 ? { paddingRight: titleStatusClearancePx } : {}),
}}
>
<div
ref={setHeaderTitleSlot}
@@ -4055,42 +4065,11 @@ const RichEditorPane = ({
<div
ref={setEditorScrollContainerRef}
data-editor-theme={isNamedEditorTheme(editorTheme) ? editorTheme : "custom"}
data-paper-theme={isPaperEditorTheme(editorTheme) ? "true" : undefined}
data-publish-surface={isMobileViewport ? "phone" : "desktop"}
data-editor-theme="default"
data-note-palette={noteProse.palette}
style={{
...(isPaperEditorTheme(editorTheme)
? publishEditorCssVars(
editorTheme,
resolvePaperEditorTheme(editorTheme)?.palette ?? "emerald",
isMobileViewport ? "phone" : "desktop",
)
: {
"--editor-body-font-size": `${MEMO_CONTENT_STYLE.body.fontSize}px`,
"--editor-body-line-height": String(MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize),
"--editor-paragraph-spacing": `${MEMO_CONTENT_STYLE.body.paragraphSpacing}px`,
}),
...noteProseCssVariables(noteProse),
"--memo-content-divider-spacing": `${MEMO_CONTENT_STYLE.divider.marginVertical}px`,
...(!isNamedEditorTheme(editorTheme)
? {
"--editor-theme-light-bg": customEditorTheme.light.background,
"--editor-theme-light-text": customEditorTheme.light.text,
"--editor-theme-light-muted": customEditorTheme.light.muted,
"--editor-theme-light-heading": customEditorTheme.light.heading,
"--editor-theme-light-accent": customEditorTheme.light.accent,
"--editor-theme-light-soft": customEditorTheme.light.soft,
"--editor-theme-light-code-bg": customEditorTheme.light.codeBackground,
"--editor-theme-light-border": customEditorTheme.light.border,
"--editor-theme-dark-bg": customEditorTheme.dark.background,
"--editor-theme-dark-text": customEditorTheme.dark.text,
"--editor-theme-dark-muted": customEditorTheme.dark.muted,
"--editor-theme-dark-heading": customEditorTheme.dark.heading,
"--editor-theme-dark-accent": customEditorTheme.dark.accent,
"--editor-theme-dark-soft": customEditorTheme.dark.soft,
"--editor-theme-dark-code-bg": customEditorTheme.dark.codeBackground,
"--editor-theme-dark-border": customEditorTheme.dark.border,
}
: {}),
} as CSSProperties}
className={cn(
"edgeever-editor relative min-h-0 flex-1 bg-transparent",
@@ -4103,13 +4082,13 @@ const RichEditorPane = ({
: "overflow-y-auto lg:[scrollbar-gutter:stable_both-edges]"
)}
>
{!isNamedEditorTheme(editorTheme) && customEditorTheme.customCss && (
{noteProse.customCss.trim() ? (
<style
data-theme-custom-css
data-original-css={customEditorTheme.customCss}
dangerouslySetInnerHTML={{ __html: sanitizeAndScopeCss(customEditorTheme.customCss) }}
data-original-css={noteProse.customCss}
dangerouslySetInnerHTML={{ __html: sanitizeAndScopeCss(noteProse.customCss) }}
/>
)}
) : null}
<div
onClickCapture={
!useMobilePlainTextEditor && !useMarkdownSourceEditor
@@ -4123,28 +4102,18 @@ const RichEditorPane = ({
: cn("min-h-full items-start py-2", MEMO_EDITOR_READING_GUTTER_CLASS_NAME),
desktopFocusMode
? "mx-auto w-full max-w-[1400px] justify-center"
: editorContentAlignment === "center"
: isDesktopColumn
? "w-full justify-center"
: "w-full justify-between"
: "w-full"
)}
>
<div
className={cn(
"min-w-0 flex-1 transition-[max-width] duration-200",
useMarkdownSourceEditor && "flex h-full min-h-0 flex-col",
desktopFocusMode
? "max-w-[960px]"
: "max-w-none"
isDesktopColumn && "mx-auto",
)}
style={
!desktopFocusMode && !useMarkdownSourceEditor && editorContentAlignment === "center"
? {
maxWidth: editorOutlineCollapsed
? EDITOR_CONTENT_MAX_WIDTH_COLLAPSED
: EDITOR_CONTENT_MAX_WIDTH,
}
: undefined
}
style={isDesktopColumn ? { maxWidth: contentColumnMaxWidth } : undefined}
>
{useMobilePlainTextEditor ? (
<>
@@ -4164,7 +4133,13 @@ const RichEditorPane = ({
aria-label={t("editor.noteBodyAria")}
className="block min-h-[60dvh] w-full resize-none border border-slate-200 bg-card px-4 py-3 pr-32 text-base leading-7 text-slate-950 outline-none placeholder:text-slate-400 sm:px-7"
placeholder={t("editor.placeholder")}
style={{ WebkitUserSelect: "text", userSelect: "text", caretColor: "auto" }}
style={{
WebkitUserSelect: "text",
userSelect: "text",
caretColor: "auto",
fontSize: `${noteProse.fontSize}px`,
lineHeight: String(noteProse.lineHeight),
}}
/>
<div className="absolute right-3 top-3 flex gap-2">
<button
@@ -4241,6 +4216,7 @@ const RichEditorPane = ({
editor={editor}
title={getEditableMemoTitle(memo?.title)}
scrollContainer={editorScrollContainer}
noteProse={noteProse}
/>
)}
{!isMobileViewport && !useMobilePlainTextEditor && !useMarkdownSourceEditor && !phonePreviewOpen && (
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useEditorTheme, useMermaidTheme } from "@/components/ThemeProvider";
import type { ResolvedNoteProse } from "@edgeever/shared";
import { useMermaidTheme } from "@/components/ThemeProvider";
import { embedMermaidForPreview } from "@/lib/phone-preview-mermaid";
import { Switch } from "@/components/ui/switch";
import { preparePublishArticle } from "@/lib/wechat-copy";
@@ -23,6 +24,7 @@ export const PhonePreviewGlyph = ({ className }: { className?: string }) => (
type EditorPhonePreviewProps = {
editor: Editor | null;
title?: string;
noteProse: ResolvedNoteProse;
scrollContainer?: HTMLDivElement | null;
className?: string;
};
@@ -61,10 +63,10 @@ const alignElement = (scroller: HTMLElement, target: HTMLElement, viewportTop: n
scroller.scrollTop += delta;
};
export const EditorPhonePreview = ({ editor, title, scrollContainer, className }: EditorPhonePreviewProps) => {
export const EditorPhonePreview = ({ editor, title, noteProse, scrollContainer, className }: EditorPhonePreviewProps) => {
const { t } = useTranslation();
const { editorTheme } = useEditorTheme();
const { mermaidTheme } = useMermaidTheme();
const proseKey = `${noteProse.palette}:${noteProse.fontSize}:${noteProse.lineHeight}:${noteProse.customCss}`;
const previewSyncGeneration = useRef(0);
const [markup, setMarkup] = useState({ html: "", style: "" });
const [follow, setFollow] = useState(readEditorPhonePreviewFollowPreference);
@@ -80,7 +82,7 @@ export const EditorPhonePreview = ({ editor, title, scrollContainer, className }
const sync = () => {
if (editor.isDestroyed) return;
const generation = ++previewSyncGeneration.current;
const root = preparePublishArticle(editor.getHTML(), editor.view.dom, editorTheme);
const root = preparePublishArticle(editor.getHTML(), editor.view.dom);
setMarkup({
html: root.innerHTML,
style: root.getAttribute("style") ?? "",
@@ -101,7 +103,7 @@ export const EditorPhonePreview = ({ editor, title, scrollContainer, className }
editor.off("update", sync);
editor.off("create", sync);
};
}, [editor, editorTheme, mermaidTheme]);
}, [editor, mermaidTheme, noteProse]);
useEffect(() => {
previewRef.current?.setAttribute("style", markup.style);
@@ -210,7 +212,7 @@ export const EditorPhonePreview = ({ editor, title, scrollContainer, className }
{title?.trim() ? <p className="edgeever-phone-shell-title">{title.trim()}</p> : null}
{markup.html ? (
<div
key={editorTheme}
key={proseKey}
className="edgeever-phone-article"
dangerouslySetInnerHTML={{ __html: markup.html }}
/>
-23
View File
@@ -35,7 +35,6 @@ import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -52,10 +51,7 @@ import { CODE_BLOCK_LANGUAGES, getCodeBlockLanguageValue } from "@/lib/code-bloc
import { EditorTableMenu } from "@/components/EditorTableMenu";
import { wrapIndentedParagraphInList } from "@/lib/editor-shortcuts";
import {
EDITOR_THEME_NAMES,
MARKDOWN_THEME_PREFERENCES,
localizeStoredCustomThemeName,
useEditorTheme,
useMarkdownTheme,
} from "@/components/ThemeProvider";
@@ -199,8 +195,6 @@ export const EditorToolbar = ({
}) => {
const { t } = useTranslation();
const { markdownThemePreference, setMarkdownTheme } = useMarkdownTheme();
const { editorTheme, setEditorTheme, customEditorThemes } = useEditorTheme();
const namedEditorThemes = EDITOR_THEME_NAMES.filter((theme) => theme !== "custom");
const controlsRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(readEditorToolbarExpandedPreference);
const [hasOverflow, setHasOverflow] = useState(false);
@@ -449,23 +443,6 @@ export const EditorToolbar = ({
</>
) : (
<>
<DropdownMenuLabel className="text-xs text-slate-500">{t("editorToolbar.editorTheme")}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={editorTheme} onValueChange={(value) => setEditorTheme(value)}>
{namedEditorThemes.map((theme) => (
<DropdownMenuRadioItem key={theme} value={theme} className="text-xs leading-5">
{t(`settings.editorThemes.${theme}`)}
</DropdownMenuRadioItem>
))}
{customEditorThemes.map((theme) => (
<DropdownMenuRadioItem key={theme.id} value={theme.id} className="text-xs leading-5">
{localizeStoredCustomThemeName(theme.name, {
defaultName: t("settings.customEditorTheme.defaultName"),
newName: (index) => t("settings.customEditorTheme.newName", { n: index }),
})}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-slate-500">{t("editorToolbar.blockStyle")}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={blockValue} onValueChange={(value) => setBlock(value)}>
<DropdownMenuRadioItem value="paragraph" className="text-xs leading-5" disabled={disabled}>
+14 -2
View File
@@ -4,7 +4,7 @@ import Image from "@tiptap/extension-image";
import { EditorContent, useEditor } from "@tiptap/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Clock3, FileText, LoaderCircle, LockKeyhole, ShieldCheck } from "lucide-react";
import { lazy, Suspense, useEffect, useMemo, useState, type FormEvent } from "react";
import { lazy, Suspense, useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { ApiRequestError, api } from "@/lib/api";
@@ -13,11 +13,14 @@ import { Input } from "@/components/ui/input";
import { EdgeEverCodeBlock, codeBlockLowlight } from "@/lib/code-block";
import { withEnvironmentTitlePrefix } from "@/lib/environment-title";
import { resolvePublicShareBody } from "@/lib/public-share-body";
import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
import {
parseImageWidth,
getImageReferrerPolicy,
createEdgeEverDocumentExtensions,
noteProseCssVariables,
parsePublishedNoteBodyFont,
resolveNoteProse,
type PublicMemoShare,
} from "@edgeever/shared";
import { applyEditorBodyFontPreference } from "@/lib/editor-body-font";
@@ -256,6 +259,7 @@ export const PublicSharePage = () => {
);
}
const prose = resolveNoteProse(share.prose);
return (
<main className="edgeever-public-share min-h-[100dvh] bg-slate-50 px-4 py-6 sm:px-8 sm:py-10">
<article className="mx-auto max-w-4xl overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
@@ -280,7 +284,15 @@ export const PublicSharePage = () => {
</div>
) : null}
</header>
<div className="edgeever-editor px-1 py-4 sm:px-4 sm:py-7" data-editor-theme="default">
<div
className="edgeever-editor px-1 py-4 sm:px-4 sm:py-7"
data-editor-theme="default"
data-note-palette={prose.palette}
style={noteProseCssVariables(prose) as CSSProperties}
>
{prose.customCss ? (
<style dangerouslySetInnerHTML={{ __html: sanitizeAndScopeCss(prose.customCss) }} />
) : null}
<SharedDocument
locale={(i18n.resolvedLanguage || i18n.language || "zh-CN").startsWith("en") ? "en-US" : "zh-CN"}
share={share}
+15 -7
View File
@@ -19,7 +19,9 @@ import * as m from "motion/react-m";
import { SystemInfoDialog } from "@/components/SystemInfoDialog";
import { Button } from "@/components/ui/button";
import type { EditorContentAlignment, ShortcutSettings } from "@/lib/app-helpers";
import type { ShortcutSettings } from "@/lib/app-helpers";
import type { EditorContentWidth } from "@/lib/editor-content-width";
import type { NoteProsePatch, ResolvedNoteProse } from "@edgeever/shared";
import { BETA_BADGE_CLASSNAME, WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { cn } from "@/lib/utils";
import { AccountInfoCard } from "./settings/AccountInfoCard";
@@ -49,8 +51,10 @@ interface SettingsPaneProps {
onImageCompressionChange: (enabled: boolean) => void;
shortcutSettings: ShortcutSettings;
onShortcutSettingsChange: (settings: ShortcutSettings) => void;
editorContentAlignment: EditorContentAlignment;
onEditorContentAlignmentChange: (alignment: EditorContentAlignment) => void;
editorContentWidth: EditorContentWidth;
onEditorContentWidthChange: (width: EditorContentWidth) => void;
noteProse: ResolvedNoteProse;
onNoteProseChange: (patch: NoteProsePatch) => void;
onLogout: () => void;
isLoggingOut: boolean;
authRequired: boolean;
@@ -85,8 +89,10 @@ export const SettingsPane = ({
onImageCompressionChange,
shortcutSettings,
onShortcutSettingsChange,
editorContentAlignment,
onEditorContentAlignmentChange,
editorContentWidth,
onEditorContentWidthChange,
noteProse,
onNoteProseChange,
onLogout,
isLoggingOut,
authRequired,
@@ -188,8 +194,10 @@ export const SettingsPane = ({
<PreferenceCard
imageCompressionEnabled={imageCompressionEnabled}
onImageCompressionChange={onImageCompressionChange}
editorContentAlignment={editorContentAlignment}
onEditorContentAlignmentChange={onEditorContentAlignmentChange}
editorContentWidth={editorContentWidth}
onEditorContentWidthChange={onEditorContentWidthChange}
noteProse={noteProse}
onNoteProseChange={onNoteProseChange}
/>
<div className="hidden overflow-hidden rounded-xl border border-slate-200 bg-card lg:block">
<FeedbackLink />
+10 -5
View File
@@ -117,6 +117,7 @@ import { useWorkspaceSyncLifecycle } from "@/hooks/useWorkspaceSyncLifecycle";
import { paneEnterMotion } from "@/lib/motion";
import { WorkspaceMotionProvider } from "./WorkspaceMotionProvider";
import { useWorkspaceRoute } from "@/hooks/useWorkspaceRoute";
import { useNoteProse } from "@/hooks/useNoteProse";
import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
@@ -508,19 +509,20 @@ export const WorkspaceApp = ({
const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false);
const {
desktopFocusMode,
editorContentAlignment,
editorContentWidth,
imageCompressionEnabled,
memoListWidth,
notebookSidebarCollapsed,
resetMemoListWidth,
setDesktopFocusMode,
setEditorContentAlignment,
setEditorContentWidth,
setImageCompressionEnabled,
setMemoListWidth,
setNotebookSidebarCollapsed,
setShortcutSettings,
shortcutSettings,
} = useWorkspacePreferences();
const { noteProse, updateNoteProse } = useNoteProse();
const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "execution-center" | "evernote-migration">(() =>
isInitialSettingsRoute
? "settings"
@@ -3426,8 +3428,10 @@ export const WorkspaceApp = ({
onImageCompressionChange={setImageCompressionEnabled}
shortcutSettings={shortcutSettings}
onShortcutSettingsChange={setShortcutSettings}
editorContentAlignment={editorContentAlignment}
onEditorContentAlignmentChange={setEditorContentAlignment}
editorContentWidth={editorContentWidth}
onEditorContentWidthChange={setEditorContentWidth}
noteProse={noteProse}
onNoteProseChange={updateNoteProse}
onLogout={onLogout}
isLoggingOut={isLoggingOut}
authRequired={authRequired}
@@ -3580,7 +3584,8 @@ export const WorkspaceApp = ({
onOpenCompanionNote={handleOpenPluginNote}
desktopFocusMode={desktopFocusModeActive}
onToggleDesktopFocusMode={toggleDesktopFocusMode}
editorContentAlignment={editorContentAlignment}
editorContentWidth={editorContentWidth}
noteProse={noteProse}
mobileDefaultEditMemoId={createdMemoEditId}
isTrashView={memoView === "trash"}
notebooks={notebooks}
@@ -77,7 +77,7 @@ const baseEditorTheme = EditorView.theme({
"&": {
height: "100%",
width: "100%",
fontSize: "14px",
fontSize: "var(--editor-body-font-size, 16px)",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
},
@@ -11,7 +11,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
import {
CustomEditorThemeFileError,
DEFAULT_CUSTOM_LIGHT_COLORS,
@@ -217,18 +216,6 @@ export const CustomEditorThemeDialog = ({
) : null}
{importError ? <p className="rounded-md bg-red-50 px-2.5 py-2 text-xs leading-4 text-red-700" role="alert">{importError}</p> : null}
{/* Custom CSS Textarea */}
<label className="grid gap-1 text-xs font-semibold text-slate-700">
{t("settings.customEditorTheme.customCss", "Custom CSS (高级自定义样式表)")}
<textarea
value={draft.customCss || ""}
onChange={(event) => setDraft((current) => ({ ...current, customCss: event.target.value }))}
placeholder="e.g. h1 { font-style: italic; } blockquote { border-radius: 6px; }"
className="min-h-[72px] w-full rounded-md border border-slate-200 bg-card p-2 font-mono text-xs focus:border-slate-900 focus:outline-none"
maxLength={2000}
/>
</label>
{/* Preview Panel */}
<div
className="edgeever-editor rounded-lg border transition-all"
@@ -244,9 +231,6 @@ export const CustomEditorThemeDialog = ({
padding: "0.75rem",
}}
>
{draft.customCss && (
<style dangerouslySetInnerHTML={{ __html: sanitizeAndScopeCss(draft.customCss) }} />
)}
<div className="text-sm font-semibold mb-0.5" style={{ color: activeColors.heading }}>
{t("settings.customEditorTheme.previewTitle")}
</div>
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { css } from "@codemirror/lang-css";
import { githubDark, githubLightInit } from "@uiw/codemirror-themes-all";
const cssLanguage = css();
const lightTheme = githubLightInit();
const cssEditorChrome = EditorView.theme({
"&": {
fontSize: "12px",
},
".cm-scroller": {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
lineHeight: "1.25rem",
},
".cm-content, .cm-gutter": {
minHeight: "192px",
},
".cm-content": {
padding: "8px 12px",
},
"&.cm-focused": {
outline: "none",
},
});
export const NoteProseCssEditor = ({
value,
onChange,
placeholder,
ariaLabel,
dark,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
ariaLabel: string;
dark: boolean;
}) => {
const extensions = useMemo(() => [
cssLanguage,
cssEditorChrome,
EditorView.contentAttributes.of({
"aria-label": ariaLabel,
spellcheck: "false",
}),
], [ariaLabel]);
return (
<CodeMirror
value={value}
height="192px"
theme={dark ? githubDark : lightTheme}
extensions={extensions}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLineGutter: false,
}}
placeholder={placeholder}
aria-label={ariaLabel}
onChange={onChange}
className="overflow-hidden rounded-md border border-slate-200 focus-within:ring-2 focus-within:ring-slate-300"
/>
);
};
@@ -36,13 +36,18 @@ describe("editor body font preference", () => {
});
});
describe("editor content alignment preference", () => {
test("is configured from settings instead of the per-note toolbar", () => {
describe("editor content width preference", () => {
test("is a device-local reading setting instead of a per-note toolbar control", () => {
const preferenceCard = readFileSync(new URL("./PreferenceCard.tsx", import.meta.url), "utf8");
const editorPane = readFileSync(new URL("../EditorPane.tsx", import.meta.url), "utf8");
expect(preferenceCard).toContain('t("settings.editorContentAlignmentTitle")');
expect(preferenceCard).toContain('onEditorContentAlignmentChange(value as EditorContentAlignment)');
expect(preferenceCard).toContain('t("settings.editorContentWidthTitle")');
expect(preferenceCard).toContain('t("settings.editorContentWidthDescription")');
expect(preferenceCard).toContain("onEditorContentWidthChange(value as EditorContentWidth)");
expect(preferenceCard).toContain('hidden min-h-16 flex-col');
expect(preferenceCard).toContain("lg:flex");
expect(editorPane).toContain('window.matchMedia("(min-width: 1024px)")');
expect(editorPane).toContain("editorContentColumnMaxWidth");
expect(editorPane).not.toContain("onToggleEditorContentAlignment");
});
});
@@ -59,16 +64,27 @@ describe("appearance preference", () => {
});
});
describe("paper editor themes", () => {
test("keeps theme switching in the editor toolbar instead of settings", () => {
describe("reading typography settings", () => {
test("offers size, line height, palette, and CSS without an editor theme menu", () => {
const preferenceCard = readFileSync(new URL("./PreferenceCard.tsx", import.meta.url), "utf8");
const editorToolbar = readFileSync(new URL("../EditorToolbar.tsx", import.meta.url), "utf8");
expect(preferenceCard).toContain('t("settings.editorBodyFontSizeTitle")');
expect(preferenceCard).toContain('t("settings.editorBodyFontSizeDescription")');
expect(preferenceCard).toContain('t("settings.editorBodyLineHeightTitle")');
expect(preferenceCard).toContain('t("settings.editorBodyLineHeightDescription")');
expect(preferenceCard).toContain('t("settings.editorBodyPaletteTitle")');
expect(preferenceCard).toContain('t("settings.editorBodyCssTitle")');
expect(preferenceCard).toContain('t("settings.editorBodyCssDescription")');
expect(preferenceCard).toContain("NoteProseCssEditor");
expect(readFileSync(new URL("./NoteProseCssEditor.tsx", import.meta.url), "utf8")).toContain("@codemirror/lang-css");
expect(preferenceCard).toContain('t("common.save")');
expect(preferenceCard).not.toContain('t("settings.publishLayoutTitle")');
expect(preferenceCard).not.toContain('t("settings.editorThemeTitle")');
expect(preferenceCard).not.toContain('t("settings.markdownThemeTitle")');
expect(preferenceCard).toContain('t("settings.customEditorTheme.settingsTitle")');
expect(editorToolbar).toContain('t(`settings.editorThemes.${theme}`)');
expect(editorToolbar).not.toContain('t("editorToolbar.editorTheme")');
expect(editorToolbar).not.toContain("useEditorTheme");
expect(editorToolbar).toContain('t("editorToolbar.blockStyle")');
expect(editorToolbar).toContain("markdownThemePreference");
});
});
@@ -1,7 +1,9 @@
import { AlignHorizontalJustifyCenter, AppWindow, BookOpenText, ChartNoAxesCombined, Image, Keyboard, Languages, MousePointerClick, Palette, Sparkles, SunMoon } from "lucide-react";
import { AlignHorizontalJustifyCenter, AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, MousePointerClick, Palette, Sparkles, SunMoon, Type } from "lucide-react";
import { useState, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { EditorContentAlignment } from "@/lib/app-helpers";
import type { EditorContentWidth } from "@/lib/editor-content-width";
import type { NoteProsePatch, NoteProsePaletteChoice, ResolvedNoteProse } from "@edgeever/shared";
import { MAX_NOTE_PROSE_CSS_BYTES, NOTE_PROSE_FONT_SIZES, NOTE_PROSE_PALETTE_CHOICES } from "@edgeever/shared";
import {
EDITOR_LINK_OPEN_MODE_CHANGED_EVENT,
getStoredEditorLinkOpenMode,
@@ -19,6 +21,7 @@ import {
writeAiSpaceShortcutPreference,
} from "@/lib/ai-space-shortcut-preference";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -41,6 +44,7 @@ import {
import { applyUiFontPreference, readUiFontPreference, writeUiFontPreference } from "@/lib/ui-font";
import { syncPublishedNoteBodyFont } from "@/lib/published-note-body-font";
import { CustomEditorThemeDialog } from "./CustomEditorThemeDialog";
import { NoteProseCssEditor } from "./NoteProseCssEditor";
const PreferenceSection = ({ title, children }: { title: string; children: ReactNode }) => (
<section className="grid gap-2">
@@ -57,7 +61,6 @@ import {
useMermaidTheme,
DEFAULT_CUSTOM_LIGHT_COLORS,
DEFAULT_CUSTOM_DARK_COLORS,
localizeStoredCustomThemeName,
type CustomEditorTheme,
type ThemePreference,
} from "../ThemeProvider";
@@ -178,18 +181,28 @@ const FontChoiceFields = ({
);
};
const NOTE_PROSE_LINE_HEIGHT_OPTIONS = [
{ value: "1.5", labelKey: "settings.editorBodyLineHeights.compact" },
{ value: "1.65", labelKey: "settings.editorBodyLineHeights.standard" },
{ value: "2", labelKey: "settings.editorBodyLineHeights.relaxed" },
] as const;
interface PreferenceCardProps {
imageCompressionEnabled: boolean;
onImageCompressionChange: (enabled: boolean) => void;
editorContentAlignment: EditorContentAlignment;
onEditorContentAlignmentChange: (alignment: EditorContentAlignment) => void;
editorContentWidth: EditorContentWidth;
onEditorContentWidthChange: (width: EditorContentWidth) => void;
noteProse: ResolvedNoteProse;
onNoteProseChange: (patch: NoteProsePatch) => void;
}
export const PreferenceCard = ({
imageCompressionEnabled,
onImageCompressionChange,
editorContentAlignment,
onEditorContentAlignmentChange,
editorContentWidth,
onEditorContentWidthChange,
noteProse,
onNoteProseChange,
}: PreferenceCardProps) => {
const { t } = useTranslation();
const {
@@ -198,9 +211,11 @@ export const PreferenceCard = ({
setCustomEditorThemes,
setEditorTheme,
} = useEditorTheme();
const { preference: appearancePreference, setPreference: setAppearancePreference } = useAppearanceTheme();
const { preference: appearancePreference, resolvedTheme, setPreference: setAppearancePreference } = useAppearanceTheme();
const { mermaidThemePreference, setMermaidTheme } = useMermaidTheme();
const [customThemeDialogOpen, setCustomThemeDialogOpen] = useState(false);
const [cssDialogOpen, setCssDialogOpen] = useState(false);
const [cssDraft, setCssDraft] = useState(noteProse.customCss);
const [editingTheme, setEditingTheme] = useState<CustomEditorTheme | null>(null);
const [activeLocalePreference, setActiveLocalePreference] = useState<AppLocalePreference>(() => getAppLocalePreference());
const [isMobile, setIsMobile] = useState(false);
@@ -272,39 +287,41 @@ export const PreferenceCard = ({
};
}, []);
const activeCustom = customEditorThemes.find((t) => t.id === editorTheme);
const customThemeLabel = (name: string) =>
localizeStoredCustomThemeName(name, {
defaultName: t("settings.customEditorTheme.defaultName"),
newName: (index) => t("settings.customEditorTheme.newName", { n: index }),
});
const cssDraftBytes = new TextEncoder().encode(cssDraft).byteLength;
const handleEditClick = () => {
const target = activeCustom ?? customEditorThemes[0];
if (target) {
setEditingTheme({ ...target, name: customThemeLabel(target.name) });
} else {
const newTheme: CustomEditorTheme = {
id: `custom-${Date.now()}`,
name: t("settings.customEditorTheme.newName", { n: customEditorThemes.length + 1 }),
light: DEFAULT_CUSTOM_LIGHT_COLORS,
dark: DEFAULT_CUSTOM_DARK_COLORS,
};
setEditingTheme(newTheme);
}
const colors = noteProse.customColors;
setEditingTheme({
id: "account-custom",
name: t("settings.customEditorTheme.defaultName"),
light: colors?.light ?? DEFAULT_CUSTOM_LIGHT_COLORS,
dark: colors?.dark ?? DEFAULT_CUSTOM_DARK_COLORS,
customCss: noteProse.customCss,
});
setCustomThemeDialogOpen(true);
};
const handleSaveTheme = (saved: CustomEditorTheme) => {
const exists = customEditorThemes.some((t) => t.id === saved.id);
let nextThemes: CustomEditorTheme[];
if (exists) {
nextThemes = customEditorThemes.map((t) => (t.id === saved.id ? saved : t));
} else {
nextThemes = [...customEditorThemes, saved];
}
const exists = customEditorThemes.some((theme) => theme.id === saved.id);
const nextThemes = exists
? customEditorThemes.map((theme) => (theme.id === saved.id ? saved : theme))
: [...customEditorThemes, saved];
setCustomEditorThemes(nextThemes);
setEditorTheme(saved.id);
onNoteProseChange({
palette: "custom",
customColors: { light: saved.light, dark: saved.dark },
...(saved.customCss?.trim() ? { customCss: saved.customCss } : {}),
});
};
const openCssDialog = () => {
setCssDraft(noteProse.customCss);
setCssDialogOpen(true);
};
const saveCssDraft = () => {
onNoteProseChange({ customCss: cssDraft });
setCssDialogOpen(false);
};
const handleDeleteTheme = (idToDelete: string) => {
@@ -407,20 +424,21 @@ export const PreferenceCard = ({
<div className="flex min-w-0 items-center gap-3">
<AlignHorizontalJustifyCenter className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorContentAlignmentTitle")}</div>
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorContentWidthTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.editorContentWidthDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={editorContentAlignment}
onValueChange={(value) => onEditorContentAlignmentChange(value as EditorContentAlignment)}
value={editorContentWidth}
onValueChange={(value) => onEditorContentWidthChange(value as EditorContentWidth)}
>
<SelectTrigger aria-label={t("settings.editorContentAlignmentTitle")} className="h-9 bg-card">
<SelectTrigger aria-label={t("settings.editorContentWidthTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start">{t("settings.editorContentAlignments.start")}</SelectItem>
<SelectItem value="center">{t("settings.editorContentAlignments.center")}</SelectItem>
<SelectItem value="standard">{t("settings.editorContentWidths.standard")}</SelectItem>
<SelectItem value="wide">{t("settings.editorContentWidths.wide")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -440,23 +458,99 @@ export const PreferenceCard = ({
/>
</div>
{!isMobile && (
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Palette className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.customEditorTheme.settingsTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row">
<Button variant="outline" className="h-9 shrink-0 px-3 text-xs" onClick={handleEditClick}>
{activeCustom || customEditorThemes.length > 0
? t("settings.customEditorTheme.edit")
: t("settings.customEditorTheme.create")}
</Button>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Type className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontSizeTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.editorBodyFontSizeDescription")}</p>
</div>
</div>
)}
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.fontSize)}
onValueChange={(value) => onNoteProseChange({ fontSize: Number(value) as ResolvedNoteProse["fontSize"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyFontSizeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_FONT_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<AlignVerticalSpaceAround className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyLineHeightTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.editorBodyLineHeightDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.lineHeight)}
onValueChange={(value) => onNoteProseChange({ lineHeight: Number(value) as ResolvedNoteProse["lineHeight"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyLineHeightTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_LINE_HEIGHT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{t(option.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Palette className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyPaletteTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-80">
<Select
value={noteProse.palette}
onValueChange={(value) => onNoteProseChange({ palette: value as NoteProsePaletteChoice })}
>
<SelectTrigger aria-label={t("settings.editorBodyPaletteTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_PALETTE_CHOICES.map((paletteId) => (
<SelectItem key={paletteId} value={paletteId}>{t(`settings.editorBodyPalettes.${paletteId}`)}</SelectItem>
))}
</SelectContent>
</Select>
{noteProse.palette === "custom" && !isMobile ? (
<Button variant="outline" className="h-9 shrink-0 px-3 text-xs" onClick={handleEditClick}>
{t("settings.customEditorTheme.edit")}
</Button>
) : null}
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Code2 className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyCssTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.editorBodyCssDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-auto">
<Button variant="outline" className="h-9 px-3 text-xs" onClick={openCssDialog}>
{t("settings.editorBodyCssEdit")}
</Button>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
@@ -568,6 +662,29 @@ export const PreferenceCard = ({
isDefaultTheme={editingTheme.id === "custom-default"}
/>
)}
<Dialog open={cssDialogOpen} onOpenChange={setCssDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("settings.editorBodyCssTitle")}</DialogTitle>
<DialogDescription>{t("settings.editorBodyCssDescription")}</DialogDescription>
</DialogHeader>
<NoteProseCssEditor
value={cssDraft}
dark={resolvedTheme === "dark"}
ariaLabel={t("settings.editorBodyCssTitle")}
placeholder={t("settings.editorBodyCssPlaceholder")}
onChange={setCssDraft}
/>
<p className={cssDraftBytes > MAX_NOTE_PROSE_CSS_BYTES ? "text-xs text-rose-600" : "text-xs text-slate-500"}>
{cssDraftBytes} / {MAX_NOTE_PROSE_CSS_BYTES}
</p>
<DialogFooter>
<Button type="button" onClick={saveCssDraft} disabled={cssDraftBytes > MAX_NOTE_PROSE_CSS_BYTES}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
+65
View File
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
noteProseMigrationPatch,
resolveNoteProse,
type AccountNoteProse,
type NoteProsePatch,
type ResolvedNoteProse,
} from "@edgeever/shared";
import { api } from "@/lib/api";
import { readLocalNoteProseMigration } from "@/lib/note-prose-local";
const EMPTY_ACCOUNT: AccountNoteProse = {
fontSize: null,
lineHeight: null,
palette: null,
customCss: null,
customColors: null,
};
export const useNoteProse = () => {
const [account, setAccount] = useState<AccountNoteProse>(EMPTY_ACCOUNT);
const [ready, setReady] = useState(false);
const revision = useRef(0);
useEffect(() => {
const request = ++revision.current;
let cancelled = false;
void (async () => {
try {
const loaded = await api.getNoteProse();
if (cancelled || request !== revision.current) return;
const patch = noteProseMigrationPatch(loaded, readLocalNoteProseMigration());
const next = Object.keys(patch).length > 0 ? await api.updateNoteProse(patch) : loaded;
if (!cancelled && request === revision.current) setAccount(next);
} catch {
if (!cancelled && request === revision.current) setAccount(EMPTY_ACCOUNT);
} finally {
if (!cancelled && request === revision.current) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, []);
const updateNoteProse = useCallback(async (patch: NoteProsePatch) => {
const request = ++revision.current;
setAccount((current) => ({
...current,
...patch,
}));
try {
const saved = await api.updateNoteProse(patch);
if (request === revision.current) setAccount(saved);
} catch {
// Keep the optimistic value. The next successful load replaces it.
}
}, []);
return {
noteProse: resolveNoteProse(account),
noteProseReady: ready,
updateNoteProse,
};
};
+11 -9
View File
@@ -3,26 +3,28 @@ import {
DEFAULT_MEMO_LIST_WIDTH_PX,
clampMemoListWidth,
readDesktopFocusModePreference,
readEditorContentAlignmentPreference,
readImageCompressionPreference,
readMemoListWidthPreference,
readNotebookSidebarCollapsedPreference,
readShortcutSettingsPreference,
writeDesktopFocusModePreference,
writeEditorContentAlignmentPreference,
writeImageCompressionPreference,
writeMemoListWidthPreference,
writeNotebookSidebarCollapsedPreference,
writeShortcutSettingsPreference,
type ShortcutSettings,
type EditorContentAlignment,
} from "@/lib/app-helpers";
import {
readEditorContentWidthPreference,
writeEditorContentWidthPreference,
type EditorContentWidth,
} from "@/lib/editor-content-width";
export const useWorkspacePreferences = () => {
const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference);
const [desktopFocusMode, setDesktopFocusModeState] = useState(readDesktopFocusModePreference);
const [notebookSidebarCollapsed, setNotebookSidebarCollapsedState] = useState(readNotebookSidebarCollapsedPreference);
const [editorContentAlignment, setEditorContentAlignmentState] = useState(readEditorContentAlignmentPreference);
const [editorContentWidth, setEditorContentWidthState] = useState(readEditorContentWidthPreference);
const [shortcutSettings, setShortcutSettings] = useState<ShortcutSettings>(readShortcutSettingsPreference);
const [memoListWidth, setMemoListWidthState] = useState(readMemoListWidthPreference);
@@ -39,9 +41,9 @@ export const useWorkspacePreferences = () => {
writeNotebookSidebarCollapsedPreference(collapsed);
}, []);
const setEditorContentAlignment = useCallback((alignment: EditorContentAlignment) => {
setEditorContentAlignmentState(alignment);
writeEditorContentAlignmentPreference(alignment);
const setEditorContentWidth = useCallback((width: EditorContentWidth) => {
setEditorContentWidthState(width);
writeEditorContentWidthPreference(width);
}, []);
const setMemoListWidth = useCallback((width: number) => {
@@ -56,13 +58,13 @@ export const useWorkspacePreferences = () => {
return {
desktopFocusMode,
editorContentAlignment,
editorContentWidth,
imageCompressionEnabled,
memoListWidth,
notebookSidebarCollapsed,
resetMemoListWidth,
setDesktopFocusMode,
setEditorContentAlignment,
setEditorContentWidth,
setNotebookSidebarCollapsed,
setImageCompressionEnabled,
setMemoListWidth,
+2 -72
View File
@@ -2,79 +2,9 @@
* 安全的 CSS 过滤器与作用域限制器
*/
const ALLOWED_CSS_PROPERTIES = new Set([
"font-family",
"font-size",
"font-style",
"font-weight",
"line-height",
"letter-spacing",
"color",
"background",
"background-color",
"border",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-color",
"border-width",
"border-style",
"border-radius",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"text-align",
"text-decoration",
"text-transform",
"text-indent",
"word-break",
"word-wrap",
"white-space",
"list-style",
"list-style-type",
]);
import { sanitizeNoteProseDeclarationBlock } from "@edgeever/shared";
/**
* 过滤单行 CSS 规则,仅保留安全的排版属性,阻断 url() 和定位等危险内容
*/
const sanitizeRulesBlock = (block: string): string => {
return block
.split(";")
.map((rule) => {
const parts = rule.split(":");
if (parts.length < 2) return "";
const property = parts[0].trim().toLowerCase();
const value = parts.slice(1).join(":").trim();
// 仅允许白名单属性
if (!ALLOWED_CSS_PROPERTIES.has(property)) {
return "";
}
// 深度拦截潜在危险值 (如 url, expression, javascript)
if (
/url\s*\(/i.test(value) ||
/expression/i.test(value) ||
/javascript\s*:/i.test(value) ||
/behavior/i.test(value) ||
/-moz-binding/i.test(value)
) {
return "";
}
return `${property}: ${value};`;
})
.filter(Boolean)
.join(" ");
};
const sanitizeRulesBlock = (block: string): string => sanitizeNoteProseDeclarationBlock(block);
/**
* 对用户的 CSS 进行安全过滤,并将其作用域限定在当前编辑器的 ProseMirror 区域
+1 -1
View File
@@ -66,7 +66,7 @@ export const localizeStoredCustomThemeName = (
export const CUSTOM_EDITOR_THEME_FILE_SCHEMA = "edgeever.editor-theme";
export const CUSTOM_EDITOR_THEME_FILE_VERSION = 1;
export const MAX_CUSTOM_EDITOR_THEME_FILE_BYTES = 64 * 1024;
export const MAX_CUSTOM_EDITOR_THEME_CSS_LENGTH = 2000;
export const MAX_CUSTOM_EDITOR_THEME_CSS_LENGTH = 8 * 1024;
const LEGACY_COLOR_FIELDS = ["background", "text", "muted", "heading", "accent", "soft", "border"] as const;
const COLOR_FIELDS = [...LEGACY_COLOR_FIELDS, "codeBackground"] as const;
+3 -1
View File
@@ -116,7 +116,9 @@ describe("dark theme contracts", () => {
const editorPane = readFileSync(new URL("../components/EditorPane.tsx", import.meta.url), "utf8");
const editorThemeCss = readFileSync(new URL("../styles/editor-themes/base.css", import.meta.url), "utf8");
expect(editorPane).toContain("useEditorTheme()");
expect(editorPane).toContain('data-editor-theme="default"');
expect(editorPane).toContain("noteProseCssVariables(noteProse)");
expect(editorPane).not.toContain("useEditorTheme()");
expect(editorPane).not.toContain("resolvedTheme");
expect(editorThemeCss).toContain(':root.dark .edgeever-editor[data-editor-theme="custom"]:not([data-editor-theme="default"])');
expect(editorThemeCss).toContain("--editor-theme-dark-bg");
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { editorContentColumnMaxWidth, editorContentWidthFromStored } from "./editor-content-width.ts";
describe("editor content width", () => {
test("keeps an explicit width and treats only the old left alignment as wider", () => {
expect(editorContentWidthFromStored(null, null)).toBe("standard");
expect(editorContentWidthFromStored(null, "center")).toBe("standard");
expect(editorContentWidthFromStored(undefined, "start")).toBe("wide");
expect(editorContentWidthFromStored("wide", "center")).toBe("wide");
expect(editorContentWidthFromStored("standard", "start")).toBe("standard");
});
test("uses the two centered column widths", () => {
expect(editorContentColumnMaxWidth("standard", "reading")).toBe("880px");
expect(editorContentColumnMaxWidth("standard", "collapsed")).toBe("1200px");
expect(editorContentColumnMaxWidth("standard", "focus")).toBe("960px");
expect(editorContentColumnMaxWidth("wide", "reading")).toBe("1040px");
expect(editorContentColumnMaxWidth("wide", "collapsed")).toBe("1280px");
expect(editorContentColumnMaxWidth("wide", "focus")).toBe("1120px");
});
});
+48
View File
@@ -0,0 +1,48 @@
export type EditorContentWidth = "standard" | "wide";
export const EDITOR_CONTENT_WIDTH_STORAGE_KEY = "edgeever.editorContentWidth";
export const EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY = "edgeever.editorContentAlignment";
const EDITOR_CONTENT_COLUMN_WIDTHS = {
standard: { reading: "880px", collapsed: "1200px", focus: "960px" },
wide: { reading: "1040px", collapsed: "1280px", focus: "1120px" },
} as const;
export type EditorContentColumnMode = keyof (typeof EDITOR_CONTENT_COLUMN_WIDTHS)["standard"];
export const editorContentWidthFromStored = (
width: string | null | undefined,
legacyAlignment: string | null | undefined,
): EditorContentWidth => {
if (width === "standard" || width === "wide") return width;
return legacyAlignment === "start" ? "wide" : "standard";
};
export const editorContentColumnMaxWidth = (
width: EditorContentWidth,
mode: EditorContentColumnMode,
) => EDITOR_CONTENT_COLUMN_WIDTHS[width][mode];
const readStorage = (key: string) => {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
};
export const readEditorContentWidthPreference = (): EditorContentWidth =>
editorContentWidthFromStored(
readStorage(EDITOR_CONTENT_WIDTH_STORAGE_KEY),
readStorage(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY),
);
export const writeEditorContentWidthPreference = (width: EditorContentWidth) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(EDITOR_CONTENT_WIDTH_STORAGE_KEY, width);
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
+7 -5
View File
@@ -69,12 +69,14 @@ describe("markdown theme contracts", () => {
expect(toolbar).not.toContain('t("editorToolbar.markdownSource")');
});
test("the rich-text toolbar can switch editor themes without leaving the note", () => {
test("the rich-text toolbar keeps block styles and no longer switches editor themes", () => {
const toolbar = readFileSync(new URL("../components/EditorToolbar.tsx", import.meta.url), "utf8");
expect(toolbar).toContain("useEditorTheme");
expect(toolbar).toContain("setEditorTheme");
expect(toolbar).toContain('t("editorToolbar.editorTheme")');
expect(toolbar).toContain("namedEditorThemes");
expect(toolbar).not.toContain("useEditorTheme");
expect(toolbar).not.toContain("setEditorTheme");
expect(toolbar).not.toContain('t("editorToolbar.editorTheme")');
expect(toolbar).not.toContain("namedEditorThemes");
expect(toolbar).toContain('t("editorToolbar.blockStyle")');
expect(toolbar).toContain("EDITOR_HEADING_LEVELS");
});
test("phone preview is toggled from the note header instead of the format toolbar", () => {
+60
View File
@@ -0,0 +1,60 @@
import {
paletteForLegacyEditorTheme,
parseNoteProseCustomColors,
type NoteProseCustomColors,
type NoteProsePaletteChoice,
} from "@edgeever/shared";
const EDITOR_THEME_STORAGE_KEY = "edgeever.editor-theme";
const CUSTOM_EDITOR_THEMES_STORAGE_KEY = "edgeever.custom-editor-themes";
export type LocalNoteProseMigration = {
palette: NoteProsePaletteChoice | null;
customCss: string | null;
customColors: NoteProseCustomColors | null;
};
const readStorage = (key: string) => {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
};
export const readLocalNoteProseMigration = (): LocalNoteProseMigration => {
const theme = readStorage(EDITOR_THEME_STORAGE_KEY);
const palette = paletteForLegacyEditorTheme(theme);
let customCss: string | null = null;
let customColors: NoteProseCustomColors | null = null;
const rawThemes = readStorage(CUSTOM_EDITOR_THEMES_STORAGE_KEY);
if (rawThemes) {
try {
const parsed = JSON.parse(rawThemes) as unknown;
if (Array.isArray(parsed)) {
const selected = parsed.find((item) =>
typeof item === "object" && item !== null && (item as { id?: unknown }).id === theme);
const cssOwner = [selected, ...parsed].find((item) =>
typeof item === "object"
&& item !== null
&& typeof (item as { customCss?: unknown }).customCss === "string"
&& (item as { customCss: string }).customCss.trim());
if (cssOwner) customCss = (cssOwner as { customCss: string }).customCss;
if (palette === "custom") {
const colorOwner = selected ?? parsed[0];
if (typeof colorOwner === "object" && colorOwner !== null) {
customColors = parseNoteProseCustomColors({
light: (colorOwner as { light?: unknown }).light,
dark: (colorOwner as { dark?: unknown }).dark,
});
}
}
}
} catch {
customCss = null;
customColors = null;
}
}
return { palette, customCss, customColors };
};
+19 -15
View File
@@ -159,15 +159,18 @@ describe("publish layout CSS", () => {
});
describe("copy pipeline wiring", () => {
test("wechat copy follows paper editor themes and keeps compact copy for other themes", () => {
test("wechat copy uses account prose and no longer applies paper layouts", () => {
const source = readFileSync(new URL("./wechat-copy.ts", import.meta.url), "utf8");
const preferenceCard = readFileSync(new URL("../components/settings/PreferenceCard.tsx", import.meta.url), "utf8");
expect(source).toContain("resolvePaperEditorTheme");
expect(source).toContain("applyPublishLayout");
expect(source).toContain('"phone"');
expect(source).toContain("MEMO_CONTENT_STYLE");
expect(source).not.toContain("resolvePaperEditorTheme");
expect(source).not.toContain("applyPublishLayout");
expect(source).toContain("noteProseCodeFontSize");
expect(source).toContain("NOTE_PROSE_HEADING_SCALE");
expect(source).toContain("NOTE_PROSE_PARAGRAPH_SPACING_PX");
expect(source).toContain("[data-note-palette]");
expect(source).toContain("preparePublishArticle");
expect(source).not.toContain("editorThemeOverride");
expect(source).toContain("ProseMirror-separator");
expect(source).toContain("findOriginalImage");
expect(source).toContain("svgToWeChatImage");
@@ -179,7 +182,7 @@ describe("copy pipeline wiring", () => {
expect(preferenceCard).not.toContain('t("settings.editorThemeTitle")');
});
test("drives the rich editor rhythm from paper editor themes", () => {
test("keeps paper layout styles available without applying them in the editor", () => {
const editorPane = readFileSync(new URL("../components/EditorPane.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../styles/publish-layout.css", import.meta.url), "utf8");
const vars = publishEditorCssVars("letter", "dawn");
@@ -197,10 +200,10 @@ describe("copy pipeline wiring", () => {
expect(vars["--publish-ornament-brush"]).toContain("url(\"data:image/svg+xml");
expect(css).toContain("--editor-theme-accent: var(--publish-accent)");
expect(css).toContain("--brand-green: var(--publish-accent)");
expect(editorPane).toContain("isPaperEditorTheme(editorTheme)");
expect(editorPane).toContain('data-paper-theme={isPaperEditorTheme(editorTheme) ? "true" : undefined}');
expect(editorPane).toContain("publishEditorCssVars");
expect(editorPane).toContain('data-publish-surface={isMobileViewport ? "phone" : "desktop"}');
expect(editorPane).toContain('data-editor-theme="default"');
expect(editorPane).not.toContain("isPaperEditorTheme");
expect(editorPane).not.toContain("publishEditorCssVars");
expect(editorPane).not.toContain("data-paper-theme");
expect(css).toContain("counter-increment: publish-h2");
expect(css).toContain("[data-paper-theme]");
expect(css).toContain('[data-editor-theme="letter"]');
@@ -209,14 +212,15 @@ describe("copy pipeline wiring", () => {
expect(css).toContain("line-height: var(--editor-body-line-height)");
});
test("phone preview markup applies the paper layout instead of raw editor chrome", () => {
test("phone preview markup follows account prose instead of a paper layout", () => {
const source = readFileSync(new URL("./wechat-copy.ts", import.meta.url), "utf8");
const preview = readFileSync(new URL("../components/EditorPhonePreview.tsx", import.meta.url), "utf8");
expect(source).toContain("export const preparePublishArticle");
expect(source).toContain('applyPublishLayout(root, paperTheme.layout, paperTheme.palette, "phone")');
expect(source).toContain("editorThemeOverride");
expect(preview).toContain("preparePublishArticle(editor.getHTML(), editor.view.dom, editorTheme)");
expect(source).not.toContain("applyPublishLayout");
expect(source).toContain("applyLegacyWeChatStyles");
expect(preview).toContain("preparePublishArticle(editor.getHTML(), editor.view.dom)");
expect(preview).toContain("previewRef.current?.setAttribute(\"style\", markup.style)");
expect(preview).toContain("key={editorTheme}");
expect(preview).toContain("noteProse");
expect(preview).not.toContain("key={editorTheme}");
});
});
+120 -91
View File
@@ -1,103 +1,114 @@
import type { Editor } from "@tiptap/react";
import { flattenDetailsForLinearHtml, MEMO_CONTENT_STYLE } from "@edgeever/shared";
import {
DEFAULT_NOTE_PROSE_FONT_SIZE,
DEFAULT_NOTE_PROSE_LINE_HEIGHT,
flattenDetailsForLinearHtml,
NOTE_PROSE_HEADING_SCALE,
NOTE_PROSE_PARAGRAPH_SPACING_PX,
noteProseCodeFontSize,
parseNoteProseLineHeight,
type NoteProsePaletteColors,
} from "@edgeever/shared";
import { marked } from "marked";
import { MERMAID_THEME_PALETTES } from "@/components/ThemeProvider";
import { copyHtmlToClipboard } from "@/lib/clipboard";
import { parseCustomCssToStyles } from "@/lib/css-sandbox";
import { applyPublishLayout, resolvePaperEditorTheme } from "@/lib/publish-layout";
const BODY_LINE_HEIGHT = MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize;
const BODY_FONT_SIZE = `${MEMO_CONTENT_STYLE.body.fontSize}px`;
const PARAGRAPH_SPACING = `${MEMO_CONTENT_STYLE.body.paragraphSpacing}px`;
const PARAGRAPH_SPACING = `${NOTE_PROSE_PARAGRAPH_SPACING_PX}px`;
const WECHAT_STYLES: Record<string, string> = {
p: `margin: 0 0 ${PARAGRAPH_SPACING}; padding: 0; line-height: ${BODY_LINE_HEIGHT}; font-size: ${BODY_FONT_SIZE}; color: #333;`,
h1: "margin: 1.2em 0 0.6em; font-size: 24px; line-height: 1.35; font-weight: 700; color: #1f2937;",
h2: "margin: 1.1em 0 0.55em; font-size: 21px; line-height: 1.4; font-weight: 700; color: #1f2937;",
h3: "margin: 1em 0 0.5em; font-size: 18px; line-height: 1.45; font-weight: 700; color: #1f2937;",
h4: "margin: 0.95em 0 0.45em; font-size: 16px; line-height: 1.5; font-weight: 700; color: #1f2937;",
h5: "margin: 0.9em 0 0.4em; font-size: 15px; line-height: 1.5; font-weight: 700; color: #1f2937;",
h6: "margin: 0.85em 0 0.35em; font-size: 14px; line-height: 1.5; font-weight: 700; color: #1f2937;",
blockquote: `margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid #10b981; background: #f0fdf4; color: #4b5563; line-height: ${BODY_LINE_HEIGHT};`,
ul: `margin: 0 0 1em; padding-left: 1.6em; line-height: ${BODY_LINE_HEIGHT};`,
ol: `margin: 0 0 1em; padding-left: 1.6em; line-height: ${BODY_LINE_HEIGHT};`,
li: `margin: 0.25em 0; line-height: ${BODY_LINE_HEIGHT};`,
a: "color: #059669; text-decoration: underline;",
strong: "font-weight: 700;",
em: "font-style: italic;",
del: "text-decoration: line-through;",
code: "padding: 0.15em 0.35em; border-radius: 3px; background: #f3f4f6; color: #be123c; font-family: Menlo, Consolas, monospace; font-size: 0.9em;",
pre: "margin: 1em 0; padding: 12px 14px; overflow: hidden; border-radius: 6px; background: #f6f8fa; color: #24292f; line-height: 1.6; text-align: left;",
hr: "margin: 1.5em 0; border: 0; border-top: 1px solid #e5e7eb;",
table: "width: 100%; margin: 1em 0; border-collapse: collapse; font-size: 14px; line-height: 1.6;",
th: "padding: 8px; border: 1px solid #d1d5db; background: #f3f4f6; font-weight: 700; text-align: left;",
td: "padding: 8px; border: 1px solid #d1d5db; text-align: left;",
img: "display: block; max-width: 100%; height: auto; margin: 1em auto;",
const HEADING_LAYOUT = {
h1: { margin: "1.2em 0 0.6em", lineHeight: 1.35 },
h2: { margin: "1.1em 0 0.55em", lineHeight: 1.4 },
h3: { margin: "1em 0 0.5em", lineHeight: 1.45 },
h4: { margin: "0.95em 0 0.45em", lineHeight: 1.5 },
h5: { margin: "0.9em 0 0.4em", lineHeight: 1.5 },
h6: { margin: "0.85em 0 0.35em", lineHeight: 1.5 },
} as const;
type WeChatMetrics = {
fontSize: number;
lineHeight: number;
};
const headingStyle = (tag: keyof typeof HEADING_LAYOUT, metrics: WeChatMetrics, color: string) => {
const layout = HEADING_LAYOUT[tag];
const size = Math.round(metrics.fontSize * NOTE_PROSE_HEADING_SCALE[tag]);
return `margin: ${layout.margin}; font-size: ${size}px; line-height: ${layout.lineHeight}; font-weight: 700; color: ${color};`;
};
const applyLegacyWeChatStyles = (
root: HTMLElement,
customColors?: { bg: string; text: string; accent: string; soft: string; codeBackground: string; border: string } | null,
metrics: WeChatMetrics,
colors: NoteProsePaletteColors | null,
) => {
const textColor = customColors ? customColors.text : "#333";
const bgColors = customColors ? customColors.bg : "#ffffff";
const accent = customColors ? customColors.accent : "#059669";
const soft = customColors ? customColors.soft : "#f0fdfa";
const codeBackground = customColors ? customColors.codeBackground : "#f6f8fa";
const border = customColors ? customColors.border : "#e5e7eb";
const fontSize = `${metrics.fontSize}px`;
const lineHeight = String(metrics.lineHeight);
const codeSize = `${noteProseCodeFontSize(metrics.fontSize)}px`;
const text = colors?.text ?? "#333";
const headingColor = colors?.text ?? "#1f2937";
const background = colors?.background ?? "#ffffff";
const accent = colors?.accent ?? "#059669";
const quoteBorder = colors?.accent ?? "#10b981";
const quoteBackground = colors?.surface ?? "#f0fdf4";
const quoteColor = colors?.muted ?? "#4b5563";
const link = colors?.link ?? "#059669";
const inlineCodeBackground = colors?.codeBackground ?? "#f3f4f6";
const inlineCodeColor = colors?.codeText ?? "#be123c";
const preBackground = colors?.codeBackground ?? "#f6f8fa";
const preColor = colors?.codeText ?? "#24292f";
const ruleColor = colors?.divider ?? "#e5e7eb";
const cellBorder = colors?.divider ?? "#d1d5db";
const headBackground = colors?.surface ?? "#f3f4f6";
const cellColor = colors ? ` color: ${text};` : "";
const styles: Record<string, string> = {
p: `margin: 0 0 ${PARAGRAPH_SPACING}; padding: 0; line-height: ${lineHeight}; font-size: ${fontSize}; color: ${text};`,
h1: headingStyle("h1", metrics, headingColor),
h2: headingStyle("h2", metrics, headingColor),
h3: headingStyle("h3", metrics, headingColor),
h4: headingStyle("h4", metrics, headingColor),
h5: headingStyle("h5", metrics, headingColor),
h6: headingStyle("h6", metrics, headingColor),
blockquote: `margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid ${quoteBorder}; background: ${quoteBackground}; color: ${quoteColor}; line-height: ${lineHeight};`,
ul: `margin: 0 0 1em; padding-left: 1.6em; line-height: ${lineHeight};`,
ol: `margin: 0 0 1em; padding-left: 1.6em; line-height: ${lineHeight};`,
li: `margin: 0.25em 0; line-height: ${lineHeight};`,
a: `color: ${link}; text-decoration: underline;`,
strong: "font-weight: 700;",
em: "font-style: italic;",
del: "text-decoration: line-through;",
code: `padding: 0.15em 0.35em; border-radius: 3px; background: ${inlineCodeBackground}; color: ${inlineCodeColor}; font-family: Menlo, Consolas, monospace; font-size: ${codeSize};`,
pre: `margin: 1em 0; padding: 12px 14px; overflow: hidden; border-radius: 6px; background: ${preBackground}; color: ${preColor}; line-height: 1.6; text-align: left;`,
hr: `margin: 1.5em 0; border: 0; border-top: 1px solid ${ruleColor};`,
table: `width: 100%; margin: 1em 0; border-collapse: collapse; font-size: ${fontSize}; line-height: 1.6;`,
th: `padding: 8px; border: 1px solid ${cellBorder}; background: ${headBackground}; font-weight: 700; text-align: left;${cellColor}`,
td: `padding: 8px; border: 1px solid ${cellBorder}; text-align: left;${cellColor}`,
img: "display: block; max-width: 100%; height: auto; margin: 1em auto;",
};
root.style.cssText = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: ${BODY_FONT_SIZE}; line-height: ${BODY_LINE_HEIGHT}; color: ${textColor}; background-color: ${bgColors}; word-break: break-word;`;
root.style.cssText = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: ${fontSize}; line-height: ${lineHeight}; color: ${text}; background-color: ${background}; word-break: break-word;`;
root.querySelectorAll<HTMLElement>("*").forEach((element) => {
const tagName = element.tagName.toLowerCase();
let style = WECHAT_STYLES[tagName] || "";
const isMergeDivider =
tagName === "hr" && element.matches("[data-edgeever-merge-divider], .edgeever-merge-divider");
if (isMergeDivider) {
style = `margin: 1.75em 0; border: 0; border-top: 2px solid ${accent};`;
} else if (customColors) {
if (tagName === "p") {
style = `margin: 0 0 ${PARAGRAPH_SPACING}; padding: 0; line-height: ${BODY_LINE_HEIGHT}; font-size: ${BODY_FONT_SIZE}; color: ${textColor};`;
} else if (tagName === "h1" || tagName === "h2" || tagName === "h3" || tagName === "h4" || tagName === "h5" || tagName === "h6") {
style = style.replace("color: #1f2937;", `color: ${textColor};`);
} else if (tagName === "blockquote") {
style = `margin: 1em 0; padding: 0.6em 1em; border-left: 4px solid ${accent}; background: ${soft}; color: ${textColor}; line-height: ${BODY_LINE_HEIGHT};`;
} else if (tagName === "a") {
style = `color: ${accent}; text-decoration: underline;`;
} else if (tagName === "code") {
style = `padding: 0.15em 0.35em; border-radius: 3px; background: ${codeBackground}; color: ${textColor}; font-family: Menlo, Consolas, monospace; font-size: 0.9em;`;
} else if (tagName === "pre") {
style = `margin: 1em 0; padding: 12px 14px; overflow: hidden; border-radius: 6px; background: ${codeBackground}; color: ${textColor}; line-height: 1.6; text-align: left;`;
} else if (tagName === "hr") {
style = `margin: 1.5em 0; border: 0; border-top: 1px solid ${border};`;
} else if (tagName === "th") {
style = `padding: 8px; border: 1px solid ${border}; background: ${soft}; font-weight: 700; text-align: left; color: ${textColor};`;
} else if (tagName === "td") {
style = `padding: 8px; border: 1px solid ${border}; text-align: left; color: ${textColor};`;
}
}
const style = isMergeDivider
? `margin: 1.75em 0; border: 0; border-top: 2px solid ${accent};`
: styles[tagName] || "";
if (style) element.style.cssText = `${style}${element.style.cssText}`;
});
root.querySelectorAll<HTMLElement>("pre code").forEach((element) => {
element.style.cssText = "padding: 0; background: transparent; color: inherit; font-family: Menlo, Consolas, monospace; font-size: 13px; white-space: pre-wrap;";
element.style.cssText = `padding: 0; background: transparent; color: inherit; font-family: Menlo, Consolas, monospace; font-size: ${codeSize}; white-space: pre-wrap;`;
});
};
const applyInlineStyles = (
root: HTMLElement,
editorTheme?: string,
customColors?: { bg: string; text: string; accent: string; soft: string; codeBackground: string; border: string } | null,
metrics: WeChatMetrics,
colors: NoteProsePaletteColors | null,
customCss?: string,
) => {
const paperTheme = resolvePaperEditorTheme(editorTheme);
if (paperTheme) {
applyPublishLayout(root, paperTheme.layout, paperTheme.palette, "phone");
} else {
applyLegacyWeChatStyles(root, customColors);
}
applyLegacyWeChatStyles(root, metrics, colors);
const customStyles = customCss ? parseCustomCssToStyles(customCss) : null;
if (!customStyles) return;
@@ -452,26 +463,45 @@ const convertImageGalleriesForWeChat = (root: HTMLElement) => {
});
};
const readCssNumber = (value: string | undefined, fallback: number) => {
const parsed = Number.parseFloat(value ?? "");
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const readPaletteColor = (style: CSSStyleDeclaration, name: string) => style.getPropertyValue(name).trim();
export const readEditorCopyContext = (from?: HTMLElement | null) => {
const closestContainer = from?.closest<HTMLElement>("[data-editor-theme]")
?? document.querySelector<HTMLElement>("[data-editor-theme]");
const editorTheme = closestContainer?.dataset.editorTheme;
let customColors: { bg: string; text: string; accent: string; soft: string; codeBackground: string; border: string } | null = null;
if (closestContainer && editorTheme === "custom") {
const colors = getComputedStyle(closestContainer);
customColors = {
bg: colors.getPropertyValue("--editor-theme-bg") || "#ffffff",
text: colors.getPropertyValue("--editor-theme-text") || "#1f2937",
accent: colors.getPropertyValue("--editor-theme-accent") || "#059669",
soft: colors.getPropertyValue("--editor-theme-soft") || "#ecfdf5",
codeBackground: colors.getPropertyValue("--editor-theme-code-bg") || "#e0ece9",
border: colors.getPropertyValue("--editor-theme-border") || "#a7f3d0",
};
const container = from?.closest<HTMLElement>("[data-note-palette]")
?? from?.closest<HTMLElement>("[data-editor-theme]")
?? document.querySelector<HTMLElement>("[data-note-palette], [data-editor-theme]");
const style = container ? getComputedStyle(container) : null;
const fontSize = readCssNumber(style?.getPropertyValue("--editor-body-font-size"), DEFAULT_NOTE_PROSE_FONT_SIZE);
const lineHeight = parseNoteProseLineHeight(style?.getPropertyValue("--editor-body-line-height").trim())
?? DEFAULT_NOTE_PROSE_LINE_HEIGHT;
const palette = container?.dataset.notePalette;
let colors: NoteProsePaletteColors | null = null;
if (style && palette && palette !== "native") {
const accent = readPaletteColor(style, "--note-palette-accent");
const text = readPaletteColor(style, "--note-palette-text");
if (accent && text) {
colors = {
accent,
text,
muted: readPaletteColor(style, "--note-palette-muted") || text,
surface: readPaletteColor(style, "--note-palette-surface") || "#f8fafc",
divider: readPaletteColor(style, "--note-palette-divider") || "#e5e7eb",
link: readPaletteColor(style, "--note-palette-link") || accent,
codeBackground: readPaletteColor(style, "--note-palette-code-bg") || "#f6f8fa",
codeText: readPaletteColor(style, "--note-palette-code-text") || text,
background: readPaletteColor(style, "--note-palette-bg") || "#ffffff",
};
}
}
const customStyleTag = closestContainer?.querySelector<HTMLStyleElement>("style[data-theme-custom-css]");
const customStyleTag = container?.querySelector<HTMLStyleElement>("style[data-theme-custom-css]");
return {
editorTheme,
customColors,
fontSize,
lineHeight,
colors,
customCss: customStyleTag?.dataset.originalCss || "",
};
};
@@ -479,15 +509,14 @@ export const readEditorCopyContext = (from?: HTMLElement | null) => {
export const preparePublishArticle = (
html: string,
from?: HTMLElement | null,
editorThemeOverride?: string,
) => {
const root = document.createElement("div");
root.innerHTML = html;
const context = readEditorCopyContext(from);
applyInlineStyles(
root,
editorThemeOverride ?? context.editorTheme,
context.customColors,
{ fontSize: context.fontSize, lineHeight: context.lineHeight },
context.colors,
context.customCss,
);
flattenDetailsForLinearHtml(root);
@@ -34,12 +34,15 @@ describe("editor typography contract", () => {
expect(markdownRules).toMatch(/font-feature-settings\s*:\s*["']chws["']\s*1/);
});
test("keeps compact rhythm unless a paper editor theme is selected", () => {
test("keeps the native editor and applies account prose instead of paper themes", () => {
const editorPane = readStyle("../components/EditorPane.tsx");
const publishLayout = readStyle("./publish-layout.css");
expect(editorPane).toContain("isPaperEditorTheme(editorTheme)");
expect(editorPane).toContain("MEMO_CONTENT_STYLE.body.lineHeight");
expect(editorPane).toContain('data-editor-theme="default"');
expect(editorPane).toContain("data-note-palette={noteProse.palette}");
expect(editorPane).toContain("noteProseCssVariables(noteProse)");
expect(editorPane).not.toContain("isPaperEditorTheme");
expect(editorPane).toContain("MEMO_CONTENT_STYLE.divider");
expect(publishLayout).toContain('[data-editor-theme="letter"]');
expect(publishLayout).toContain("[data-paper-theme]");
expect(publishLayout).not.toContain("[data-publish-layout]");
+13 -8
View File
@@ -3,6 +3,7 @@
@import "highlight.js/styles/github.css";
@import "./editor-themes/index.css";
@import "./publish-layout.css";
@import "./note-prose.css";
@import "./code-highlight-dark.css";
@source "../../node_modules/streamdown/dist";
@@ -239,6 +240,10 @@ html.dark[data-edgeever-environment="demo"] body::after {
box-shadow: 0 2px 8px rgb(0 0 0 / 28%);
}
.ProseMirror .tiptap-mathematics-render {
font-size: var(--editor-code-font-size, 0.875em);
}
.ProseMirror .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 1rem 0;
@@ -347,7 +352,7 @@ html.dark[data-edgeever-environment="demo"] body::after {
--dark-surface-rgb: 255 255 255;
--editor-body-font-size: 1rem;
--edgeever-system-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", Inter, ui-sans-serif, system-ui, sans-serif;
--editor-body-line-height: 1.625;
--editor-body-line-height: 1.65;
--editor-paragraph-spacing: 0.35rem;
--memo-content-divider-color: #1a1d21;
--memo-content-divider-spacing: 1.5rem;
@@ -1275,7 +1280,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror h1 {
font-size: 1.5rem;
font-size: 1.5em;
font-weight: 650;
line-height: 1.28;
letter-spacing: -0.02em;
@@ -1284,7 +1289,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror h2 {
font-size: 1.25rem;
font-size: 1.25em;
font-weight: 600;
line-height: 1.32;
letter-spacing: -0.015em;
@@ -1293,7 +1298,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror h3 {
font-size: 1.125rem;
font-size: 1.125em;
font-weight: 600;
line-height: 1.38;
letter-spacing: -0.01em;
@@ -1302,7 +1307,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror h4 {
font-size: 1.0625rem;
font-size: 1.0625em;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.006em;
@@ -1311,7 +1316,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror h5 {
font-size: 1rem;
font-size: 1em;
font-weight: 600;
line-height: 1.42;
margin: 0.95rem 0 0.4rem;
@@ -1319,7 +1324,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror h6 {
font-size: 0.9375rem;
font-size: 0.9375em;
font-weight: 600;
line-height: 1.45;
margin: 0.85rem 0 0.35rem;
@@ -1660,7 +1665,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
border-radius: 4px;
background: #f3f5f7;
padding: 0.12rem 0.38rem;
font-size: 0.875em;
font-size: var(--editor-code-font-size, 0.875em);
font-weight: 550;
color: #3d4450;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+132
View File
@@ -0,0 +1,132 @@
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h1,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h2,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h3,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h4,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h5,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h6 {
color: var(--note-palette-text);
}
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror a:not(.edgeever-attachment-link) {
color: var(--note-palette-link);
}
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror blockquote {
background: var(--note-palette-surface);
color: var(--note-palette-muted);
}
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror hr,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror td,
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror th {
border-color: var(--note-palette-divider);
}
.edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror code {
background: var(--note-palette-code-bg);
color: var(--note-palette-code-text);
border-color: var(--note-palette-divider);
}
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h1,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h2,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h3,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h4,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h5,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror h6 {
color: hsl(var(--foreground));
background: transparent;
}
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror blockquote {
color: hsl(var(--foreground));
background: rgb(255 255 255 / 0.05);
}
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror a:not(.edgeever-attachment-link),
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror code {
color: var(--note-palette-accent);
}
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror code {
border-color: rgb(255 255 255 / 0.12);
background: rgb(255 255 255 / 0.06);
}
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror hr,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror td,
:root.dark .edgeever-editor[data-note-palette]:not([data-note-palette="native"]):not([data-note-palette="custom"]) .ProseMirror th {
border-color: rgb(var(--slate-200-rgb) / 0.7);
}
.edgeever-editor[data-note-palette="custom"] .ProseMirror {
color: var(--note-palette-text);
background: var(--note-palette-bg);
}
.edgeever-editor[data-note-palette="custom"] .ProseMirror h1,
.edgeever-editor[data-note-palette="custom"] .ProseMirror h2,
.edgeever-editor[data-note-palette="custom"] .ProseMirror h3,
.edgeever-editor[data-note-palette="custom"] .ProseMirror h4,
.edgeever-editor[data-note-palette="custom"] .ProseMirror h5,
.edgeever-editor[data-note-palette="custom"] .ProseMirror h6 {
color: var(--note-palette-heading, var(--note-palette-text));
}
.edgeever-editor[data-note-palette="custom"] .ProseMirror a:not(.edgeever-attachment-link) {
color: var(--note-palette-link);
}
.edgeever-editor[data-note-palette="custom"] .ProseMirror blockquote {
background: var(--note-palette-surface);
color: var(--note-palette-muted);
}
.edgeever-editor[data-note-palette="custom"] .ProseMirror hr,
.edgeever-editor[data-note-palette="custom"] .ProseMirror td,
.edgeever-editor[data-note-palette="custom"] .ProseMirror th {
border-color: var(--note-palette-divider);
}
.edgeever-editor[data-note-palette="custom"] .ProseMirror code {
background: var(--note-palette-code-bg);
color: var(--note-palette-code-text);
border-color: var(--note-palette-divider);
}
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror {
color: var(--note-palette-dark-text);
background: var(--note-palette-dark-bg);
}
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror h1,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror h2,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror h3,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror h4,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror h5,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror h6 {
color: var(--note-palette-dark-heading, var(--note-palette-dark-text));
}
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror a:not(.edgeever-attachment-link) {
color: var(--note-palette-dark-link);
}
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror blockquote {
background: var(--note-palette-dark-surface);
color: var(--note-palette-dark-muted);
}
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror hr,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror td,
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror th {
border-color: var(--note-palette-dark-divider);
}
:root.dark .edgeever-editor[data-note-palette="custom"] .ProseMirror code {
background: var(--note-palette-dark-code-bg);
color: var(--note-palette-dark-code-text);
border-color: var(--note-palette-dark-divider);
}
+1
View File
@@ -221,6 +221,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",
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN note_prose_font_size INTEGER;
ALTER TABLE users ADD COLUMN note_prose_line_height TEXT;
ALTER TABLE users ADD COLUMN note_prose_palette TEXT;
ALTER TABLE users ADD COLUMN note_prose_custom_css TEXT;
ALTER TABLE users ADD COLUMN note_prose_custom_colors TEXT;
+10
View File
@@ -47,6 +47,8 @@ import type {
ResourceListItem,
ResourceStorageSummary,
ObjectStorageSettings,
AccountNoteProse,
NoteProsePatch,
PublicMemoShare,
TagSummary,
TiptapDoc,
@@ -813,6 +815,14 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify({ bodyFont }),
}),
getNoteProse: () => request<AccountNoteProse>("/api/v1/me/note-prose"),
updateNoteProse: (patch: NoteProsePatch) =>
request<AccountNoteProse>("/api/v1/me/note-prose", {
method: "PUT",
body: JSON.stringify(patch),
}),
unlockPublicMemoShare: (token: string, password: string) =>
request<{ ok: true }>(`/api/public/shares/${encodeURIComponent(token)}/unlock`, {
method: "POST",
+35 -1
View File
@@ -767,6 +767,40 @@ export const enUS = {
center: "Optimize readability (centered)",
start: "Align left",
},
editorContentWidthTitle: "Text width",
editorContentWidthDescription: "Changes the editor width on this device only.",
editorContentWidths: {
standard: "Standard",
wide: "Wider",
},
editorBodyFontSizeTitle: "Text size",
editorBodyFontSizeDescription: "Used by the editor and public shares.",
editorBodyLineHeightTitle: "Line height",
editorBodyLineHeightDescription: "Used by the editor and public shares.",
editorBodyLineHeights: {
compact: "Compact 1.5",
standard: "Standard 1.65",
relaxed: "Relaxed 2.0",
},
editorBodyPaletteTitle: "Text colors",
editorBodyPalettes: {
native: "Native",
clay: "Red",
orange: "Orange",
dawn: "Gold",
emerald: "Green",
teal: "Teal",
azure: "Blue",
violet: "Purple",
plum: "Wine",
pink: "Pink",
slate: "Gray",
custom: "Custom",
},
editorBodyCssTitle: "Custom CSS",
editorBodyCssDescription: "Used by the editor and public shares. Size and line height stay on the controls above.",
editorBodyCssEdit: "Edit styles",
editorBodyCssPlaceholder: "p { text-indent: 2em; } h1 { letter-spacing: 0.04em; }",
editorBodyFontTitle: "Note text font",
editorBodyFonts: {
system: "System",
@@ -803,7 +837,7 @@ export const enUS = {
},
customEditorTheme: {
title: "Custom editor theme",
description: "Adjust editor colors. This setting is saved in the current browser.",
description: "Adjust the note colors. They follow this account and are used by the editor and public shares.",
settingsTitle: "Custom theme",
defaultName: "Custom theme",
newName: "New theme {{n}}",
+35 -1
View File
@@ -767,6 +767,40 @@ export const ja = {
center: "読みやすさを優先(中央)",
start: "左揃え",
},
editorContentWidthTitle: "本文の幅",
editorContentWidthDescription: "この端末の編集領域だけを変えます。",
editorContentWidths: {
standard: "標準",
wide: "広め",
},
editorBodyFontSizeTitle: "本文の文字サイズ",
editorBodyFontSizeDescription: "エディタと公開共有の両方に使います。",
editorBodyLineHeightTitle: "行間",
editorBodyLineHeightDescription: "エディタと公開共有の両方に使います。",
editorBodyLineHeights: {
compact: "詰め 1.5",
standard: "標準 1.65",
relaxed: "ゆったり 2.0",
},
editorBodyPaletteTitle: "本文の配色",
editorBodyPalettes: {
native: "標準",
clay: "赤",
orange: "オレンジ",
dawn: "金",
emerald: "緑",
teal: "青緑",
azure: "青",
violet: "紫",
plum: "ワイン",
pink: "ピンク",
slate: "グレー",
custom: "カスタム",
},
editorBodyCssTitle: "カスタム CSS",
editorBodyCssDescription: "エディタと公開共有の両方に使います。文字サイズと行間はここでは指定しません。",
editorBodyCssEdit: "スタイルを編集",
editorBodyCssPlaceholder: "p { text-indent: 2em; } h1 { letter-spacing: 0.04em; }",
editorBodyFontTitle: "ノート本文のフォント",
editorBodyFonts: {
system: "システム",
@@ -803,7 +837,7 @@ export const ja = {
},
customEditorTheme: {
title: "カスタムエディタテーマ",
description: "エディタの色を調整します。この設定は現在のブラウザに保存されます。",
description: "本文の色を調整します。このアカウントに保存され、エディタと公開共有で使われます。",
settingsTitle: "カスタムテーマ",
defaultName: "カスタムテーマ",
newName: "新しいテーマ {{n}}",
+35 -1
View File
@@ -767,6 +767,40 @@ export const zhCN = {
center: "优化可读性(居中)",
start: "靠左显示",
},
editorContentWidthTitle: "正文宽度",
editorContentWidthDescription: "只改变这台设备上的编辑区。",
editorContentWidths: {
standard: "标准",
wide: "较宽",
},
editorBodyFontSizeTitle: "正文字号",
editorBodyFontSizeDescription: "同时用于编辑器和公开分享。",
editorBodyLineHeightTitle: "行高",
editorBodyLineHeightDescription: "同时用于编辑器和公开分享。",
editorBodyLineHeights: {
compact: "紧凑 1.5",
standard: "标准 1.65",
relaxed: "宽松 2.0",
},
editorBodyPaletteTitle: "正文配色",
editorBodyPalettes: {
native: "原生",
clay: "红色",
orange: "橙色",
dawn: "金色",
emerald: "绿色",
teal: "青色",
azure: "蓝色",
violet: "紫色",
plum: "酒红",
pink: "粉色",
slate: "灰色",
custom: "自定义",
},
editorBodyCssTitle: "自定义 CSS",
editorBodyCssDescription: "同时用于编辑器和公开分享。字号和行高不写在这里。",
editorBodyCssEdit: "编辑样式",
editorBodyCssPlaceholder: "例如 p { text-indent: 2em; } h1 { letter-spacing: 0.04em; }",
editorBodyFontTitle: "笔记正文字体",
editorBodyFonts: {
system: "跟随系统",
@@ -803,7 +837,7 @@ export const zhCN = {
},
customEditorTheme: {
title: "自定义编辑器主题",
description: "调整正文编辑区的颜色,设置仅保存在当前浏览器中。",
description: "调整正文编辑区的颜色。这些颜色会跟着账号,用于编辑器和公开分享。",
settingsTitle: "自定义主题",
defaultName: "自定主题",
newName: "新主题 {{n}}",
+2
View File
@@ -24,6 +24,8 @@ export * from "./memo-content-style";
export * from "./memo-timestamps";
export * from "./memo-template-seeds";
export * from "./note-links";
export * from "./note-prose";
export * from "./note-prose-css";
export * from "./notebooks";
export * from "./revision-diff";
export * from "./resource-links";
+67
View File
@@ -0,0 +1,67 @@
const ALLOWED_NOTE_PROSE_CSS_PROPERTIES = new Set([
"font-family",
"font-style",
"font-weight",
"letter-spacing",
"color",
"background",
"background-color",
"border",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-color",
"border-width",
"border-style",
"border-radius",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"text-align",
"text-decoration",
"text-transform",
"text-indent",
"word-break",
"word-wrap",
"white-space",
"list-style",
"list-style-type",
]);
const UNSAFE_CSS_VALUE = /url\s*\(|expression|javascript\s*:|behavior|-moz-binding/i;
export const sanitizeNoteProseDeclarationBlock = (block: string): string =>
block
.split(";")
.map((rule) => {
const parts = rule.split(":");
if (parts.length < 2) return "";
const property = parts[0].trim().toLowerCase();
const value = parts.slice(1).join(":").trim();
if (!ALLOWED_NOTE_PROSE_CSS_PROPERTIES.has(property) || UNSAFE_CSS_VALUE.test(value)) return "";
return `${property}: ${value};`;
})
.filter(Boolean)
.join(" ");
/** Drops font-size, line-height, url(), and rules that are not typography. Selectors stay intact. */
export const sanitizeNoteProseCss = (css: string): string => {
if (!css) return "";
const cleaned = css
.replace(/@import/gi, "")
.replace(/@charset/gi, "")
.replace(/@namespace/gi, "");
return cleaned.replace(/([^{]+)({[^}]+})/g, (_, selectors: string, blockContent: string) => {
const safeRules = sanitizeNoteProseDeclarationBlock(blockContent.slice(1, -1));
if (!safeRules) return "";
return `${selectors}{ ${safeRules} }`;
}).trim();
};
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_NOTE_PROSE_CUSTOM_COLORS,
MAX_NOTE_PROSE_CSS_BYTES,
NoteProseUpdateSchema,
noteProseCodeFontSize,
noteProseMigrationPatch,
paletteForLegacyEditorTheme,
sanitizeNoteProseCss,
} from "@edgeever/shared";
const emptyAccount = () => ({
fontSize: null,
lineHeight: null,
palette: null,
customCss: null,
customColors: null,
});
describe("note prose", () => {
test("maps retired editor themes onto color names and leaves the native theme unset", () => {
expect(paletteForLegacyEditorTheme("stance")).toBe("clay");
expect(paletteForLegacyEditorTheme("stub")).toBe("dawn");
expect(paletteForLegacyEditorTheme("letter")).toBe("dawn");
expect(paletteForLegacyEditorTheme("grove")).toBe("emerald");
expect(paletteForLegacyEditorTheme("minimal-emerald")).toBe("emerald");
expect(paletteForLegacyEditorTheme("wechat-green")).toBe("emerald");
expect(paletteForLegacyEditorTheme("brief")).toBe("teal");
expect(paletteForLegacyEditorTheme("zen")).toBe("teal");
expect(paletteForLegacyEditorTheme("guide")).toBe("azure");
expect(paletteForLegacyEditorTheme("blueprint")).toBe("azure");
expect(paletteForLegacyEditorTheme("outline")).toBe("violet");
expect(paletteForLegacyEditorTheme("journal")).toBe("slate");
expect(paletteForLegacyEditorTheme("default")).toBeNull();
expect(paletteForLegacyEditorTheme("marxico")).toBeNull();
expect(paletteForLegacyEditorTheme("custom-default")).toBe("custom");
expect(paletteForLegacyEditorTheme("not-a-theme")).toBeNull();
});
test("steps code one preset below the body size", () => {
expect(noteProseCodeFontSize(14)).toBe(12);
expect(noteProseCodeFontSize(16)).toBe(14);
expect(noteProseCodeFontSize(18)).toBe(16);
expect(noteProseCodeFontSize(20)).toBe(18);
});
test("drops font size, line height, and urls from account CSS", () => {
const css = sanitizeNoteProseCss("p { color: red; font-size: 40px; line-height: 3; margin: 1em 0; background: url(https://evil.test/a.png); } @import 'x';");
expect(css).toContain("color: red");
expect(css).toContain("margin: 1em 0");
expect(css).not.toContain("font-size");
expect(css).not.toContain("line-height");
expect(css).not.toContain("url(");
expect(css).not.toContain("@import");
});
test("fills only account fields that were never set", () => {
expect(noteProseMigrationPatch(emptyAccount(), {
palette: "emerald",
customCss: "p { color: red; }",
customColors: null,
})).toEqual({
palette: "emerald",
customCss: "p { color: red; }",
});
expect(noteProseMigrationPatch({
...emptyAccount(),
palette: "native",
customCss: "",
}, {
palette: "clay",
customCss: "h1 { letter-spacing: 0.04em; }",
customColors: null,
})).toEqual({});
const custom = noteProseMigrationPatch(emptyAccount(), {
palette: "custom",
customCss: " ",
customColors: null,
});
expect(custom.palette).toBe("custom");
expect(custom.customCss).toBeUndefined();
expect(custom.customColors).toEqual(DEFAULT_NOTE_PROSE_CUSTOM_COLORS);
});
test("rejects a stylesheet larger than 8 KB", () => {
expect(NoteProseUpdateSchema.safeParse({
customCss: "p { color: red; }",
}).success).toBe(true);
expect(NoteProseUpdateSchema.safeParse({
customCss: "a".repeat(MAX_NOTE_PROSE_CSS_BYTES + 1),
}).success).toBe(false);
expect(NoteProseUpdateSchema.safeParse({}).success).toBe(false);
expect(MAX_NOTE_PROSE_CSS_BYTES).toBe(8192);
});
});
+434
View File
@@ -0,0 +1,434 @@
export const NOTE_PROSE_FONT_SIZES = [14, 16, 18, 20] as const;
export type NoteProseFontSize = (typeof NOTE_PROSE_FONT_SIZES)[number];
export const DEFAULT_NOTE_PROSE_FONT_SIZE: NoteProseFontSize = 16;
export const NOTE_PROSE_LINE_HEIGHTS = [1.5, 1.65, 2] as const;
export type NoteProseLineHeight = (typeof NOTE_PROSE_LINE_HEIGHTS)[number];
export const DEFAULT_NOTE_PROSE_LINE_HEIGHT: NoteProseLineHeight = 1.65;
export const NOTE_PROSE_PALETTE_IDS = [
"clay",
"orange",
"dawn",
"emerald",
"teal",
"azure",
"violet",
"plum",
"pink",
"slate",
] as const;
export type NoteProsePaletteId = (typeof NOTE_PROSE_PALETTE_IDS)[number];
export const NOTE_PROSE_PALETTE_CHOICES = ["native", ...NOTE_PROSE_PALETTE_IDS, "custom"] as const;
export type NoteProsePaletteChoice = (typeof NOTE_PROSE_PALETTE_CHOICES)[number];
export const MAX_NOTE_PROSE_CSS_BYTES = 8 * 1024;
export const NOTE_PROSE_PARAGRAPH_SPACING_PX = 8;
export const NOTE_PROSE_HEADING_SCALE = {
h1: 1.5,
h2: 1.25,
h3: 1.125,
h4: 1.0625,
h5: 1,
h6: 0.9375,
} as const;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
export type NoteProseCustomColorSet = {
background: string;
text: string;
muted: string;
heading: string;
accent: string;
soft: string;
codeBackground: string;
border: string;
};
export type NoteProseCustomColors = {
light: NoteProseCustomColorSet;
dark: NoteProseCustomColorSet;
};
export type NoteProsePaletteColors = {
accent: string;
text: string;
muted: string;
surface: string;
divider: string;
link: string;
codeBackground: string;
codeText: string;
background: string;
};
export type AccountNoteProse = {
fontSize: NoteProseFontSize | null;
lineHeight: NoteProseLineHeight | null;
palette: NoteProsePaletteChoice | null;
customCss: string | null;
customColors: NoteProseCustomColors | null;
};
export type ResolvedNoteProse = {
fontSize: NoteProseFontSize;
lineHeight: NoteProseLineHeight;
palette: NoteProsePaletteChoice;
customCss: string;
customColors: NoteProseCustomColors | null;
};
export type PublicNoteProse = {
fontSize: NoteProseFontSize;
lineHeight: NoteProseLineHeight;
palette: NoteProsePaletteChoice;
customCss: string;
customColors: NoteProseCustomColors | null;
};
export type NoteProsePatch = {
fontSize?: NoteProseFontSize | null;
lineHeight?: NoteProseLineHeight | null;
palette?: NoteProsePaletteChoice | null;
customCss?: string | null;
customColors?: NoteProseCustomColors | null;
};
const CUSTOM_COLOR_FIELDS = [
"background",
"text",
"muted",
"heading",
"accent",
"soft",
"codeBackground",
"border",
] as const;
const palette = (colors: NoteProsePaletteColors): NoteProsePaletteColors => colors;
export const NOTE_PROSE_PALETTES: Record<NoteProsePaletteId, NoteProsePaletteColors> = {
clay: palette({
accent: "#E02D32",
text: "#374151",
muted: "#374151",
surface: "#FFF7F7",
divider: "#FFD6D6",
link: "#B42318",
codeBackground: "#FFF1F1",
codeText: "#B42318",
background: "#FFFFFF",
}),
orange: palette({
accent: "#E07A2D",
text: "#3A2E28",
muted: "#746055",
surface: "#FFF4EA",
divider: "#F6D7BE",
link: "#B45309",
codeBackground: "#FFF4EA",
codeText: "#B45309",
background: "#FFFFFF",
}),
dawn: palette({
accent: "#D9A521",
text: "#3D2A1B",
muted: "#74613C",
surface: "#FFFDF8",
divider: "#F0E3BD",
link: "#7C5500",
codeBackground: "#FFFAF0",
codeText: "#7C5500",
background: "#FFFFFF",
}),
emerald: palette({
accent: "#16A06E",
text: "#2C3A34",
muted: "#5C6F66",
surface: "#F4FBF7",
divider: "#D7EBE1",
link: "#0F7A54",
codeBackground: "#F3F7F5",
codeText: "#0F7A54",
background: "#FFFFFF",
}),
teal: palette({
accent: "#22B8A7",
text: "#4D4F46",
muted: "#4D4F46",
surface: "#EEEFE9",
divider: "#CDEFEA",
link: "#1A8F82",
codeBackground: "#ECFBF8",
codeText: "#1A8F82",
background: "#FFFFFF",
}),
azure: palette({
accent: "#2F80ED",
text: "#374151",
muted: "#4B5563",
surface: "#F5F8FC",
divider: "#D7E8FF",
link: "#1D64C7",
codeBackground: "#EEF6FF",
codeText: "#1D64C7",
background: "#FFFFFF",
}),
violet: palette({
accent: "#7567F8",
text: "#3F3F46",
muted: "#3F3F46",
surface: "#F4F4F5",
divider: "#E1DEFF",
link: "#5B4EE0",
codeBackground: "#F3F1FF",
codeText: "#5B4EE0",
background: "#FFFFFF",
}),
plum: palette({
accent: "#7A4E6E",
text: "#3A2E36",
muted: "#746068",
surface: "#FBF7F9",
divider: "#E8D7E0",
link: "#5E3A54",
codeBackground: "#F4ECF1",
codeText: "#5E3A54",
background: "#FFFFFF",
}),
pink: palette({
accent: "#D4537E",
text: "#3A2E34",
muted: "#746068",
surface: "#FDF2F6",
divider: "#F3D0DC",
link: "#BE185D",
codeBackground: "#FDF2F6",
codeText: "#BE185D",
background: "#FFFFFF",
}),
slate: palette({
accent: "#89798B",
text: "#5C505E",
muted: "#5C505E",
surface: "#FAFAFA",
divider: "#E0DBE1",
link: "#6B5C6D",
codeBackground: "#F5F4F6",
codeText: "#6B5C6D",
background: "#FFFFFF",
}),
};
const LEGACY_EDITOR_THEME_PALETTES: Record<string, NoteProsePaletteChoice> = {
"minimal-emerald": "emerald",
"outline-emerald": "emerald",
"wechat-green": "emerald",
"modern-mint": "emerald",
grove: "emerald",
letter: "dawn",
stub: "dawn",
brief: "teal",
zen: "teal",
guide: "azure",
blueprint: "azure",
stance: "clay",
outline: "violet",
journal: "slate",
};
export const DEFAULT_NOTE_PROSE_CUSTOM_COLORS: NoteProseCustomColors = {
light: {
background: "#fffdf7",
text: "#292524",
muted: "#57534e",
heading: "#1c1917",
accent: "#0f766e",
soft: "#f0fdfa",
codeBackground: "#e0ece9",
border: "#99f6e4",
},
dark: {
background: "#1c1917",
text: "#fafaf9",
muted: "#d6d3d1",
heading: "#fafaf9",
accent: "#2dd4bf",
soft: "#292524",
codeBackground: "#3a3635",
border: "#44403c",
},
};
export const parseNoteProseFontSize = (value: unknown): NoteProseFontSize | null =>
(NOTE_PROSE_FONT_SIZES as readonly unknown[]).includes(value) ? value as NoteProseFontSize : null;
export const parseNoteProseLineHeight = (value: unknown): NoteProseLineHeight | null => {
if (typeof value === "number" && (NOTE_PROSE_LINE_HEIGHTS as readonly number[]).includes(value)) {
return value as NoteProseLineHeight;
}
if (typeof value !== "string") return null;
const normalized = value.trim();
if (normalized === "1.5" || normalized === "1.50") return 1.5;
if (normalized === "1.65") return 1.65;
if (normalized === "2" || normalized === "2.0" || normalized === "2.00") return 2;
return null;
};
export const parseNoteProsePalette = (value: unknown): NoteProsePaletteChoice | null =>
typeof value === "string" && (NOTE_PROSE_PALETTE_CHOICES as readonly string[]).includes(value)
? value as NoteProsePaletteChoice
: null;
const isColorSet = (value: unknown): value is NoteProseCustomColorSet => {
if (typeof value !== "object" || value === null) return false;
const record = value as Record<string, unknown>;
return CUSTOM_COLOR_FIELDS.every((field) => typeof record[field] === "string" && HEX_COLOR_PATTERN.test(record[field]));
};
export const parseNoteProseCustomColors = (value: unknown): NoteProseCustomColors | null => {
let parsed = value;
if (typeof value === "string") {
if (!value.trim()) return null;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
}
if (typeof parsed !== "object" || parsed === null) return null;
const record = parsed as { light?: unknown; dark?: unknown };
if (!isColorSet(record.light) || !isColorSet(record.dark)) return null;
return { light: record.light, dark: record.dark };
};
export const paletteForLegacyEditorTheme = (theme: string | null | undefined): NoteProsePaletteChoice | null => {
if (!theme || theme === "default" || theme === "marxico") return null;
const mapped = LEGACY_EDITOR_THEME_PALETTES[theme];
if (mapped) return mapped;
if (theme === "custom" || theme.startsWith("custom-")) return "custom";
return null;
};
export const noteProseCodeFontSize = (fontSize: number): number => {
const index = NOTE_PROSE_FONT_SIZES.indexOf(fontSize as NoteProseFontSize);
if (index > 0) return NOTE_PROSE_FONT_SIZES[index - 1];
if (index === 0) return 12;
return Math.max(12, Math.round(fontSize) - 2);
};
export const resolveNoteProse = (account: Partial<AccountNoteProse> | null | undefined): ResolvedNoteProse => ({
fontSize: account?.fontSize ?? DEFAULT_NOTE_PROSE_FONT_SIZE,
lineHeight: account?.lineHeight ?? DEFAULT_NOTE_PROSE_LINE_HEIGHT,
palette: account?.palette ?? "native",
customCss: account?.customCss ?? "",
customColors: account?.palette === "custom"
? account.customColors ?? DEFAULT_NOTE_PROSE_CUSTOM_COLORS
: account?.customColors ?? null,
});
export const publicNoteProseFromAccount = (account: Partial<AccountNoteProse> | null | undefined): PublicNoteProse => {
const resolved = resolveNoteProse(account);
return {
fontSize: resolved.fontSize,
lineHeight: resolved.lineHeight,
palette: resolved.palette,
customCss: resolved.customCss,
customColors: resolved.palette === "custom" ? resolved.customColors : null,
};
};
export type NoteProseAccountRow = {
note_prose_font_size: number | null;
note_prose_line_height: string | null;
note_prose_palette: string | null;
note_prose_custom_css: string | null;
note_prose_custom_colors: string | null;
};
export const accountNoteProseFromRow = (row: NoteProseAccountRow | null | undefined): AccountNoteProse => ({
fontSize: parseNoteProseFontSize(row?.note_prose_font_size),
lineHeight: parseNoteProseLineHeight(row?.note_prose_line_height),
palette: parseNoteProsePalette(row?.note_prose_palette),
customCss: row?.note_prose_custom_css ?? null,
customColors: parseNoteProseCustomColors(row?.note_prose_custom_colors),
});
export const noteProseMigrationPatch = (
account: AccountNoteProse,
local: {
palette: NoteProsePaletteChoice | null;
customCss: string | null;
customColors: NoteProseCustomColors | null;
},
): NoteProsePatch => {
const patch: NoteProsePatch = {};
if (account.palette == null && local.palette && local.palette !== "native") {
patch.palette = local.palette;
if (local.palette === "custom") {
patch.customColors = local.customColors ?? DEFAULT_NOTE_PROSE_CUSTOM_COLORS;
}
}
if (account.customCss == null && local.customCss?.trim()) {
patch.customCss = local.customCss;
}
return patch;
};
export const noteProsePaletteColors = (prose: Pick<ResolvedNoteProse, "palette" | "customColors">): NoteProsePaletteColors | null => {
if (prose.palette === "native") return null;
if (prose.palette === "custom") {
const light = prose.customColors?.light ?? DEFAULT_NOTE_PROSE_CUSTOM_COLORS.light;
return {
accent: light.accent,
text: light.text,
muted: light.muted,
surface: light.soft,
divider: light.border,
link: light.accent,
codeBackground: light.codeBackground,
codeText: light.text,
background: light.background,
};
}
return NOTE_PROSE_PALETTES[prose.palette];
};
export const noteProseCssVariables = (prose: ResolvedNoteProse): Record<string, string> => {
const variables: Record<string, string> = {
"--editor-body-font-size": `${prose.fontSize}px`,
"--editor-body-line-height": String(prose.lineHeight),
"--editor-paragraph-spacing": `${NOTE_PROSE_PARAGRAPH_SPACING_PX}px`,
"--editor-code-font-size": `${noteProseCodeFontSize(prose.fontSize)}px`,
};
const colors = noteProsePaletteColors(prose);
if (!colors) return variables;
variables["--note-palette-accent"] = colors.accent;
variables["--note-palette-text"] = colors.text;
variables["--note-palette-muted"] = colors.muted;
variables["--note-palette-surface"] = colors.surface;
variables["--note-palette-divider"] = colors.divider;
variables["--note-palette-link"] = colors.link;
variables["--note-palette-code-bg"] = colors.codeBackground;
variables["--note-palette-code-text"] = colors.codeText;
variables["--note-palette-bg"] = colors.background;
if (prose.palette === "custom") {
const light = prose.customColors?.light ?? DEFAULT_NOTE_PROSE_CUSTOM_COLORS.light;
const dark = prose.customColors?.dark ?? DEFAULT_NOTE_PROSE_CUSTOM_COLORS.dark;
variables["--note-palette-heading"] = light.heading;
variables["--note-palette-dark-accent"] = dark.accent;
variables["--note-palette-dark-text"] = dark.text;
variables["--note-palette-dark-muted"] = dark.muted;
variables["--note-palette-dark-heading"] = dark.heading;
variables["--note-palette-dark-surface"] = dark.soft;
variables["--note-palette-dark-divider"] = dark.border;
variables["--note-palette-dark-link"] = dark.accent;
variables["--note-palette-dark-code-bg"] = dark.codeBackground;
variables["--note-palette-dark-code-text"] = dark.text;
variables["--note-palette-dark-bg"] = dark.background;
}
return variables;
};
+29
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
import {
MAX_NOTE_PROSE_CSS_BYTES,
NOTE_PROSE_PALETTE_CHOICES,
} from "./note-prose";
import {
AI_ACTIONS,
AI_ATTACHMENT_MEDIA_TYPES,
@@ -386,6 +390,31 @@ export const NoteBodyFontUpdateSchema = z.object({
bodyFont: z.enum(["wenkai", "wenkai-screen", "zhuque", "source-han-serif", "neo-zhi-song", "source-han-sans", "source-serif"]).nullable(),
});
const noteProseHexColor = z.string().regex(/^#[0-9a-f]{6}$/i);
const noteProseCustomColorSetSchema = z.object({
background: noteProseHexColor,
text: noteProseHexColor,
muted: noteProseHexColor,
heading: noteProseHexColor,
accent: noteProseHexColor,
soft: noteProseHexColor,
codeBackground: noteProseHexColor,
border: noteProseHexColor,
});
export const NoteProseUpdateSchema = z.object({
fontSize: z.union([z.literal(14), z.literal(16), z.literal(18), z.literal(20)]).nullable().optional(),
lineHeight: z.union([z.literal(1.5), z.literal(1.65), z.literal(2)]).nullable().optional(),
palette: z.enum(NOTE_PROSE_PALETTE_CHOICES).nullable().optional(),
customCss: z.string().refine((value) => new TextEncoder().encode(value).byteLength <= MAX_NOTE_PROSE_CSS_BYTES).nullable().optional(),
customColors: z.object({
light: noteProseCustomColorSetSchema,
dark: noteProseCustomColorSetSchema,
}).nullable().optional(),
}).refine((input) => Object.values(input).some((value) => value !== undefined), {
message: "At least one field is required.",
});
export const PublicShareUnlockSchema = z.object({
password: z.string().min(1).max(64),
});
+3
View File
@@ -1,4 +1,5 @@
import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content";
import type { PublicNoteProse } from "./note-prose";
import { parseMemoLinkHref } from "./note-links";
export type MemoShare = {
@@ -37,6 +38,8 @@ export type PublicMemoShare = {
memoShareTokens: Record<string, string>;
/** Built-in face chosen by the author. Null keeps the system font and downloads nothing. */
bodyFont: PublishedNoteBodyFont | null;
/** Account reading settings for the article body. Column width stays on the author's editor. */
prose: PublicNoteProse;
};
const RESOURCE_URL_PATTERN = /^\/api\/v1\/resources\/([^/?#]+)\/blob(?:[?#].*)?$/;