fix(web): localize untitled notes and diagrams on public share pages (#605)

* fix(web): show the localized label for untitled notes everywhere

Untitled notes are stored with the zh-CN default title, and several views
only replaced an empty title, so they showed 无标题笔记 in every locale.
The visible cases were the public share page (heading and browser tab),
the public notebook site, AI companion cards and mentions, the note link
picker, link text inserted for a note, and the title of a duplicated
note.

Add getMemoDisplayTitle() next to DEFAULT_MEMO_TITLE and use it in those
views, matching what MemoCard already does with isDefaultMemoTitle.

* fix(web): localize diagrams and content label on the public share page

The public share page passed zh-CN to read-only diagrams for every
language other than English, so Japanese and Polish visitors saw Chinese
diagram labels. Resolve the locale with resolveSupportedLocale(), which
the diagram renderer already supports for all four languages.

Also translate the note content aria-label, which was hard-coded in
English.

---------

Co-authored-by: tianma-if <yingwaizhiying@gmail.com>
This commit is contained in:
Dariusz Korytoandtianma-if authored and GitHub committed 2026-10-18 11:26:50 +08:00
1 parent 82dac9b5cb
commit d0f441dafa
15 files changed
+54 -24

No files matched your search

@@ -1,4 +1,5 @@
import type { CompanionAction } from "@edgeever/shared";
import { getMemoDisplayTitle } from "@edgeever/shared";
import { ArrowUpRight, Check, FileText, GitMerge } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -37,7 +38,7 @@ export function CompanionActionCard({ action, busy, onApply, onDismiss, onOpenNo
onClick={() => onOpenNote(note.id, note.notebookId)}
>
<FileText className="h-3 w-3 shrink-0 text-slate-400" />
<span className="truncate">{note.title || t("common.untitledMemo")}</span>
<span className="truncate">{getMemoDisplayTitle(note.title, t("common.untitledMemo"))}</span>
<ArrowUpRight className="h-2.5 w-2.5 shrink-0 opacity-50" />
</button>
{note.excerpt ? (
@@ -63,7 +64,7 @@ export function CompanionActionCard({ action, busy, onApply, onDismiss, onOpenNo
className="group/note inline-flex max-w-full items-center gap-1.5 text-xs font-semibold text-slate-800 hover:text-slate-950 "
>
<FileText className="h-3.5 w-3.5 shrink-0 text-slate-400 group-hover/note:text-slate-950 " />
<span className="truncate">{action.notes[0].title || t("common.untitledMemo")}</span>
<span className="truncate">{getMemoDisplayTitle(action.notes[0].title, t("common.untitledMemo"))}</span>
<ArrowUpRight className="h-3 w-3 shrink-0 opacity-40 group-hover/note:opacity-100" />
</button>
{action.notes[0].excerpt ? (
+2 -2
View File
@@ -4,7 +4,7 @@ import { FileText, Folder, Loader2, MessageCircle, Tag, X } from "lucide-react";
import type {
CompanionAction, CompanionAnswer, CompanionDiscoverySettings, CompanionEvent, CompanionMention, CompanionTurn, CompanionTurnInput,
} from "@edgeever/shared";
import { parseCompanionMentionQuery } from "@edgeever/shared";
import { parseCompanionMentionQuery, getMemoDisplayTitle } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { api, ApiRequestError } from "@/lib/api";
import { companionLocale } from "@/lib/companion-locale";
@@ -118,7 +118,7 @@ export function CompanionChat({
if (cancelled || !alive.current) return;
const lower = query.toLowerCase();
setMentionHits([
...memos.memos.slice(0, 5).map(memo => ({ type: "memo" as const, id: memo.id, title: memo.title || t("common.untitledMemo") })),
...memos.memos.slice(0, 5).map(memo => ({ type: "memo" as const, id: memo.id, title: getMemoDisplayTitle(memo.title, t("common.untitledMemo")) })),
...notebooks.notebooks.filter(notebook => !query || notebook.name.toLowerCase().includes(lower)).slice(0, 4)
.map(notebook => ({ type: "notebook" as const, id: notebook.id, title: notebook.name })),
...tags.tags.filter(tag => !query || tag.name.toLowerCase().includes(lower)).slice(0, 4)
@@ -1,4 +1,5 @@
import type { CompanionTodo, CompanionToolCall, CompanionToolEffect } from "@edgeever/shared";
import { getMemoDisplayTitle } from "@edgeever/shared";
import { Check, ChevronDown, Circle, FileText, Loader2, RotateCcw, Undo2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -61,7 +62,7 @@ function CompanionToolList({
<button type="button" disabled={busy} className="inline-flex max-w-full items-center gap-1 font-medium hover:text-slate-950 hover:underline"
onClick={() => onOpenNote(effect.memoId!, effect.notebookId || "")}>
<FileText className="h-3 w-3 shrink-0 text-slate-400" />
<span className="truncate">{effect.title || t("common.untitledMemo")}</span>
<span className="truncate">{getMemoDisplayTitle(effect.title, t("common.untitledMemo"))}</span>
</button>
{effect.kind === "trashed" ? (
<Button type="button" size="sm" variant="ghost" className="h-6 px-1.5 text-xs" disabled={busy || pending === key}
@@ -1,4 +1,5 @@
import type { CompanionAction } from "@edgeever/shared";
import { getMemoDisplayTitle } from "@edgeever/shared";
import { ArrowUpRight, Check, FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -12,7 +13,7 @@ export function CompanionToolActionCard({ action, busy, onApply, onDismiss, onOp
if (action.plan.kind !== "tool") return null;
const name = action.plan.toolName;
const title = t(`companion.actions.tools.${name}`, { defaultValue: name });
const names = new Map([...action.notes.map(note => [note.id, note.title || t("common.untitledMemo")] as const),
const names = new Map([...action.notes.map(note => [note.id, getMemoDisplayTitle(note.title, t("common.untitledMemo"))] as const),
...(action.preview?.notebooks ?? []).map(notebook => [notebook.id, notebook.name] as const)]);
const display = (value: unknown): string => {
if (typeof value === "string") return names.has(value) ? `${names.get(value)} (${value})` : value;
@@ -45,7 +46,7 @@ export function CompanionToolActionCard({ action, busy, onApply, onDismiss, onOp
className="inline-flex max-w-full items-center gap-1 text-xs font-medium text-slate-800 hover:text-slate-950 hover:underline "
>
<FileText className="h-3 w-3 shrink-0 text-slate-400" />
<span className="truncate">{note.title || t("common.untitledMemo")}</span>
<span className="truncate">{getMemoDisplayTitle(note.title, t("common.untitledMemo"))}</span>
<ArrowUpRight className="h-2.5 w-2.5 shrink-0 opacity-50" />
</button>
{note.excerpt ? (
+2 -1
View File
@@ -153,6 +153,7 @@ import {
getAiDocumentFingerprint,
isAiSelectionSnapshotCurrent,
parseMemoLinkHref,
getMemoDisplayTitle,
} from "@edgeever/shared";
import { NEW_IMAGE_WIDTH_PERCENT } from "@edgeever/shared/image-display";
import { EdgeEverLink } from "@edgeever/shared/editor-link";
@@ -1517,7 +1518,7 @@ const RichEditorPane = ({
.focus()
.insertContent({
type: "text",
text: selectedText || target.title || t("common.untitledMemo"),
text: selectedText || getMemoDisplayTitle(target.title, t("common.untitledMemo")),
marks: [{ type: "link", attrs: { href: createMemoLinkHref(target.id), class: "edgeever-note-link" } }],
})
.run();
@@ -1,4 +1,5 @@
import type { PublicNotebookShare } from "@edgeever/shared";
import { getMemoDisplayTitle } from "@edgeever/shared";
import {
ArrowLeft,
ArrowRight,
@@ -63,7 +64,7 @@ const NavigationNotes = ({
return (
<ul className="space-y-0.5">
{memos.map((memo) => {
const title = memo.title?.trim() || t("common.untitledMemo");
const title = getMemoDisplayTitle(memo.title, t("common.untitledMemo"));
return (
<li key={memo.id}>
<Tooltip>
@@ -144,7 +145,7 @@ const NoteTitle = ({ memo }: { memo: Memo }) => {
const { t } = useTranslation();
return (
<span className="min-w-0 break-words">
{memo.title?.trim() || t("common.untitledMemo")}
{getMemoDisplayTitle(memo.title, t("common.untitledMemo"))}
</span>
);
};
@@ -307,7 +308,7 @@ export const PublicNotebookSite = ({
const matches = useMemo(
() =>
notes.filter(({ memo }) =>
(memo.title?.trim() || t("common.untitledMemo"))
getMemoDisplayTitle(memo.title, t("common.untitledMemo"))
.toLocaleLowerCase()
.includes(query),
),
+6 -10
View File
@@ -24,7 +24,9 @@ import {
resolveNoteProse,
type PublicMemoShare,
type ShareBodyAccess,
getMemoDisplayTitle,
} from "@edgeever/shared";
import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales";
import { applyEditorBodyFontPreference } from "@/lib/editor-body-font";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { PdfAttachment } from "@/components/editor/PdfAttachment";
@@ -104,7 +106,7 @@ const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }
editorProps: {
attributes: {
class: "prose prose-slate max-w-none focus:outline-none",
"aria-label": "Shared note content",
"aria-label": t("sharing.contentLabel"),
},
},
}, [content]);
@@ -239,7 +241,7 @@ export const PublicSharePage = () => {
document.head.appendChild(robots);
if (share) {
document.title = withEnvironmentTitlePrefix(
`${share.title?.trim() || t("common.untitledMemo")} · EdgeEver`,
`${getMemoDisplayTitle(share.title, t("common.untitledMemo"))} · EdgeEver`,
{ development: import.meta.env.DEV, profile: __EDGEEVER_DEVELOPMENT_PROFILE__ },
);
} else if (passwordRequired) {
@@ -364,7 +366,7 @@ export const PublicShareArticle = ({
layout === "site" && "order-1",
)}
>
{share.title?.trim() || t("common.untitledMemo")}
{getMemoDisplayTitle(share.title, t("common.untitledMemo"))}
</h1>
{share.tags.length > 0 ? (
<div
@@ -401,13 +403,7 @@ export const PublicShareArticle = ({
/>
) : null}
<SharedDocument
locale={
(i18n.resolvedLanguage || i18n.language || "zh-CN").startsWith(
"en",
)
? "en-US"
: "zh-CN"
}
locale={resolveSupportedLocale(i18n.resolvedLanguage || i18n.language)}
share={share}
token={token}
access={access}
+2 -2
View File
@@ -45,7 +45,7 @@ import {
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
import { createDefaultDiagramDocument, createDefaultInfographicDocument, hasPosterDocumentMarker, posterFallbackMarkdown, serializePosterDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookScopeIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { createDefaultDiagramDocument, createDefaultInfographicDocument, hasPosterDocumentMarker, posterFallbackMarkdown, serializePosterDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookScopeIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate, getMemoDisplayTitle } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
Pane,
@@ -2151,7 +2151,7 @@ export const WorkspaceApp = ({
if (duplicateMemoMutation.isPending || memoView === "trash") return;
duplicateMemoMutation.mutate({
memoId: memo.id,
title: t("editor.duplicateTitle", { title: memo.title?.trim() || t("common.untitledMemo") }).slice(0, 160),
title: t("editor.duplicateTitle", { title: getMemoDisplayTitle(memo.title, t("common.untitledMemo")) }).slice(0, 160),
});
};
@@ -1,4 +1,5 @@
import { Link2, X } from "lucide-react";
import { getMemoDisplayTitle } from "@edgeever/shared";
import { useTranslation } from "react-i18next";
import type { MemoSummary } from "@edgeever/shared";
import {
@@ -66,7 +67,7 @@ export const EditorNoteLinkPicker = ({
{candidates.map((candidate) => (
<CommandItem key={candidate.id} value={candidate.id} onSelect={() => onInsert(candidate)}>
<Link2 className="h-4 w-4 shrink-0 text-slate-600" />
<span className="min-w-0 flex-1 truncate">{candidate.title || t("common.untitledMemo")}</span>
<span className="min-w-0 flex-1 truncate">{getMemoDisplayTitle(candidate.title, t("common.untitledMemo"))}</span>
<span className="max-w-40 truncate text-xs text-slate-400">{candidate.excerpt}</span>
</CommandItem>
))}
+15
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import {
countMemoCharacters,
DEFAULT_MEMO_TITLE,
getMemoDisplayTitle,
docToMarkdown,
docToText,
markdownToDoc,
@@ -812,3 +814,16 @@ describe("plugin embed Markdown compatibility", () => {
expect(resolveMemoContentDoc(parsed, "fallback").content[0].type).toBe(PLUGIN_EMBED_NODE_TYPE);
});
});
describe("getMemoDisplayTitle", () => {
test("shows the localized label for empty and default untitled titles", () => {
expect(getMemoDisplayTitle("", "Untitled note")).toBe("Untitled note");
expect(getMemoDisplayTitle(null, "Untitled note")).toBe("Untitled note");
expect(getMemoDisplayTitle(` ${DEFAULT_MEMO_TITLE} `, "Notatka bez tytułu")).toBe("Notatka bez tytułu");
});
test("keeps real titles, including Chinese ones", () => {
expect(getMemoDisplayTitle(" Plan ", "Untitled note")).toBe("Plan");
expect(getMemoDisplayTitle("会议记录", "Untitled note")).toBe("会议记录");
});
});
+9
View File
@@ -78,6 +78,15 @@ export type TiptapDoc = {
export const DEFAULT_MEMO_TITLE = "无标题笔记";
/**
* Title to show for a note. Untitled notes are stored with the zh-CN default
* title, so it is replaced by the reader's localized label like an empty one.
*/
export const getMemoDisplayTitle = (title: string | null | undefined, untitledLabel: string) => {
const trimmed = title?.trim() ?? "";
return trimmed && trimmed !== DEFAULT_MEMO_TITLE ? trimmed : untitledLabel;
};
export const resolveMergedMemoTitle = (
inputTitle: string | null | undefined,
sourceMemos: Array<{ title: string | null | undefined }>,
+1
View File
@@ -2618,6 +2618,7 @@ export const enUS = {
expandOutlineHeading: "Expand {{name}}",
},
sharing: {
contentLabel: "Shared note content",
siteHideContents: "Hide contents",
siteShowContents: "Show contents",
siteHome: "Home",
+1
View File
@@ -2607,6 +2607,7 @@ export const ja = {
expandOutlineHeading: "{{name}} を開く",
},
sharing: {
contentLabel: "共有されたノートの内容",
siteHideContents: "目次を隠す",
siteShowContents: "目次を表示",
siteHome: "ホーム",
+1
View File
@@ -2607,6 +2607,7 @@ export const pl = {
expandOutlineHeading: "Rozwiń {{name}}",
},
sharing: {
contentLabel: "Treść udostępnionej notatki",
siteHideContents: "Ukryj spis treści",
siteShowContents: "Pokaż spis treści",
siteHome: "Strona główna",
+1
View File
@@ -2616,6 +2616,7 @@ export const zhCN = {
expandOutlineHeading: "展开 {{name}}",
},
sharing: {
contentLabel: "分享的笔记内容",
siteHideContents: "收起目录",
siteShowContents: "展开目录",
siteHome: "首页",