feat: localize config and fix mobile viewport
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s

This commit is contained in:
2026-08-12 18:43:33 +08:00
parent fdd51f01a2
commit ea716b2628
7 changed files with 385 additions and 25 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content"
name="viewport"
/>
<base href="{{ host_prefix | safe }}/" />
+38 -2
View File
@@ -26,6 +26,7 @@
background: var(--pl-canvas);
font-family: var(--ui-font);
letter-spacing: 0;
overflow: hidden;
}
html[data-theme="dark"] #pl-workspace-shell {
@@ -835,11 +836,16 @@ button.pl-setting-row:hover {
min-height: 0;
color: var(--pl-text);
background: var(--pl-surface);
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.pl-home-inner {
box-sizing: border-box;
width: min(100%, 920px);
max-width: 100%;
margin: 0 auto;
padding: 48px 80px 90px;
}
@@ -902,6 +908,12 @@ button.pl-setting-row:hover {
border-radius: 7px;
}
.pl-home-actions > button > span:last-child,
.pl-home-grid > section,
.pl-settings-main-row > span:first-child {
min-width: 0;
}
.pl-home-actions .is-green { color: var(--pl-green); background: var(--pl-green-soft); }
.pl-home-actions .is-coral { color: var(--pl-coral); background: var(--pl-coral-soft); }
.pl-home-actions .is-blue { color: var(--pl-blue); background: var(--pl-blue-soft); }
@@ -911,6 +923,7 @@ button.pl-setting-row:hover {
.pl-home-task strong,
.pl-home-task small {
display: block;
min-width: 0;
}
.pl-home-actions strong {
@@ -991,6 +1004,10 @@ button.pl-setting-row:hover {
.pl-home-recent span {
grid-column: 1 / -1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pl-home-task {
@@ -1032,7 +1049,9 @@ button.pl-setting-row:hover {
}
.pl-settings-inner {
box-sizing: border-box;
width: min(100%, 820px);
max-width: 100%;
margin: 0 auto;
padding: 48px 80px 90px;
}
@@ -1075,8 +1094,10 @@ button.pl-setting-row:hover {
.pl-settings-main-row small {
margin-top: 3px;
overflow: hidden;
color: var(--pl-faint);
font-size: 11px;
text-overflow: ellipsis;
}
.pl-settings-main-row > button {
@@ -1148,7 +1169,9 @@ button.pl-setting-row:hover {
}
.pl-new-note-dialog {
box-sizing: border-box;
width: min(440px, calc(100vw - 32px));
max-width: 100%;
padding: 18px;
border: 1px solid var(--pl-border);
border-radius: 8px;
@@ -1290,7 +1313,12 @@ button.pl-setting-row:hover {
#pl-workspace-shell {
display: block;
box-sizing: border-box;
padding-bottom: calc(62px + env(safe-area-inset-bottom));
width: 100%;
max-width: 100vw;
height: 100vh;
height: 100dvh;
padding-bottom: 0;
overflow: hidden;
}
.pl-sidebar {
@@ -1331,7 +1359,10 @@ button.pl-setting-row:hover {
.pl-editor-workspace {
width: 100%;
max-width: 100vw;
height: calc(100vh - 62px - env(safe-area-inset-bottom));
height: calc(100dvh - 62px - env(safe-area-inset-bottom));
overflow: hidden;
}
.pl-mobile-menu-button {
@@ -1357,6 +1388,7 @@ button.pl-setting-row:hover {
.pl-home-inner,
.pl-settings-inner {
width: 100%;
max-width: 100%;
padding: 54px 22px 70px;
}
@@ -1397,6 +1429,9 @@ button.pl-setting-row:hover {
right: 0;
bottom: 0;
left: 0;
box-sizing: border-box;
width: 100%;
max-width: 100vw;
height: calc(62px + env(safe-area-inset-bottom));
padding: 4px 6px env(safe-area-inset-bottom);
border-top: 1px solid var(--pl-border);
@@ -1437,6 +1472,7 @@ button.pl-setting-row:hover {
.pl-new-note-dialog {
width: 100%;
max-width: 100vw;
padding: 18px 18px calc(18px + env(safe-area-inset-bottom));
border-right: 0;
border-bottom: 0;
+31
View File
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";
const workspaceStyles = readFileSync(
new URL("./plainleaf_workspace.scss", import.meta.url),
"utf8",
);
const clientHtml = readFileSync(
new URL("../html/index.html", import.meta.url),
"utf8",
);
test("keeps mobile workspace within the dynamic viewport", () => {
expect(workspaceStyles).toContain("height: 100dvh;");
expect(workspaceStyles).toContain(
"height: calc(100dvh - 62px - env(safe-area-inset-bottom));",
);
expect(clientHtml).toContain("viewport-fit=cover");
});
test("uses internal vertical scrolling without widening the page", () => {
expect(workspaceStyles).toMatch(
/\.pl-home-surface,\s*\.pl-settings-surface\s*\{[^}]*overflow-x: hidden;[^}]*overflow-y: auto;/s,
);
expect(workspaceStyles).toMatch(
/\.pl-home-inner\s*\{[^}]*box-sizing: border-box;[^}]*max-width: 100%;/s,
);
expect(workspaceStyles).toMatch(
/\.pl-settings-inner\s*\{[^}]*box-sizing: border-box;[^}]*max-width: 100%;/s,
);
});
@@ -1,10 +1,18 @@
import { Checkbox, Input, Select } from "@silverbulletmd/silverbullet/ui";
import { useEffect, useRef, useState } from "preact/hooks";
import { RotateCcw } from "preact-feather";
import { useCfg } from "../cfg_context.tsx";
import {
configurationCategoryDescriptionZhCN,
configurationCategoryZhCN,
configurationDescriptionZhCN,
configurationFieldMatches,
configurationLabelZhCN,
configurationOptionZhCN,
} from "../config_zh_cn.ts";
import { useConfig } from "../editors_context.tsx";
import { cls } from "./chord_display.tsx";
import { Checkbox, Input, Select } from "@silverbulletmd/silverbullet/ui";
import type { UiSchema } from "../schema.ts";
import { cls } from "./chord_display.tsx";
function Control({
path,
@@ -35,7 +43,7 @@ function Control({
>
{schema.enum.map((opt: string) => (
<option key={opt} value={opt} selected={opt === value}>
{opt}
{configurationOptionZhCN(path, opt)}
</option>
))}
</Select>
@@ -66,7 +74,7 @@ function Control({
/>
);
}
return <span class="cfg-hint">Configure manually in CONFIG</span>;
return <span class="cfg-hint">请在 CONFIG 页面中手动配置</span>;
}
function Field({ path, schema }: { path: string; schema: any }) {
@@ -75,9 +83,13 @@ function Field({ path, schema }: { path: string; schema: any }) {
return (
<div class="cfg-field">
<div class="cfg-field-info">
<div class="cfg-field-label">{schema.ui?.label || path}</div>
{schema.description && (
<div class="cfg-field-description">{schema.description}</div>
<div class="cfg-field-label">
{configurationLabelZhCN(path, schema.ui?.label || path)}
</div>
{configurationDescriptionZhCN(path, schema.description) && (
<div class="cfg-field-description">
{configurationDescriptionZhCN(path, schema.description)}
</div>
)}
</div>
<div class="cfg-field-control">
@@ -88,24 +100,17 @@ function Field({ path, schema }: { path: string; schema: any }) {
/>
<button
class={cls({ "cfg-field-reset": true, hidden: !modified })}
title="Reset to default"
title="恢复默认值"
onClick={() => config.resetField(path)}
>
<RotateCcw size={12} />
Reset
重置
</button>
</div>
</div>
);
}
function fieldMatches(path: string, schema: any, query: string): boolean {
if (!query) return true;
const label = schema.ui?.label || path;
const description = schema.description || "";
return `${label} ${description} ${path}`.toLowerCase().includes(query);
}
function Category({
name,
fields,
@@ -116,12 +121,17 @@ function Category({
query: string;
}) {
const { cfg } = useCfg();
const visible = fields.filter((f) => fieldMatches(f.path, f.schema, query));
const visible = fields.filter((f) =>
configurationFieldMatches(f.path, f.schema, query),
);
if (visible.length === 0) return null;
const description = cfg.categories?.[name]?.description;
const description = configurationCategoryDescriptionZhCN(
name,
cfg.categories?.[name]?.description,
);
return (
<div class="cfg-category">
<h2 class="cfg-category-title">{name}</h2>
<h2 class="cfg-category-title">{configurationCategoryZhCN(name)}</h2>
{description && <div class="cfg-category-description">{description}</div>}
{visible.map((f) => (
<Field key={f.path} path={f.path} schema={f.schema} />
@@ -144,7 +154,7 @@ export function ConfigurationTab() {
inputRef={inputRef}
class="cfg-search"
type="text"
placeholder="Filter configuration options..."
placeholder="搜索设置名称、说明或配置路径…"
value={search}
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
/>
@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import {
configurationCategoryDescriptionZhCN,
configurationCategoryZhCN,
configurationDescriptionZhCN,
configurationFieldMatches,
configurationLabelZhCN,
configurationOptionZhCN,
} from "./config_zh_cn.ts";
describe("configuration Chinese display names", () => {
test("translates built-in categories and fields", () => {
expect(configurationCategoryZhCN("Editor")).toBe("编辑器");
expect(configurationCategoryDescriptionZhCN("Journal")).toContain(
"每日笔记",
);
expect(
configurationLabelZhCN("journal.prefix", "Journal page prefix"),
).toBe("日记保存位置");
expect(
configurationDescriptionZhCN(
"sync.documents",
"Sync document files locally",
),
).toContain("离线");
expect(
configurationOptionZhCN("frontmatterFolding.foldByDefault", "always"),
).toBe("总是");
});
test("preserves configuration supplied by third-party extensions", () => {
expect(configurationCategoryZhCN("My Extension")).toBe("My Extension");
expect(configurationLabelZhCN("my.setting", "Custom setting")).toBe(
"Custom setting",
);
expect(
configurationDescriptionZhCN("my.setting", "Custom description"),
).toBe("Custom description");
expect(configurationOptionZhCN("my.setting", "custom-value")).toBe(
"custom-value",
);
});
});
describe("configuration search", () => {
const schema = {
description: "Page name to use as the template for new journal entries",
ui: { label: "Journal template page" },
};
test.each([
"日记模板",
"journal template",
"journal.template",
])("matches %s without changing the underlying path", (query) => {
expect(configurationFieldMatches("journal.template", schema, query)).toBe(
true,
);
});
test("does not match unrelated text", () => {
expect(
configurationFieldMatches("journal.template", schema, "GitHub"),
).toBe(false);
});
});
@@ -0,0 +1,208 @@
type LocalizedConfiguration = {
label: string;
description?: string;
options?: Record<string, string>;
};
const categories: Record<string, LocalizedConfiguration> = {
Editor: {
label: "编辑器",
description: "设置括号补全、双链显示和页面属性折叠等编辑体验。",
},
"Smart Quotes": {
label: "智能引号",
description: "输入时自动替换直引号,并设置使用的引号字符。",
},
Comments: {
label: "批注",
description: "设置行内批注的署名信息。",
},
Sync: {
label: "同步",
description: "设置当前设备与服务器之间同步的内容。",
},
Indexing: {
label: "索引",
description: "选择哪些段落、列表项和待办可以被查询。",
},
Query: {
label: "查询",
description: "设置查询结果中的文本比较和排序规则。",
},
Widgets: {
label: "页面组件",
description: "开启并设置目录、关联提及和关联待办等内置组件。",
},
Journal: {
label: "日记",
description: "设置每日笔记的模板、保存位置和标签。",
},
GitHub: {
label: "GitHub 集成",
description: "设置写入 GitHub 仓库和 Gist 时使用的身份与凭据。",
},
};
const fields: Record<string, LocalizedConfiguration> = {
"sync.documents": {
label: "同步附件文件",
description: "将非 Markdown 文件同步到本机,离线时也能访问附件。",
},
"index.paragraph.all": {
label: "索引所有段落",
description: "没有标签的普通段落也加入索引。",
},
"index.item.all": {
label: "索引所有列表项",
description: "没有标签的列表项也加入索引。",
},
"index.task.all": {
label: "索引所有待办",
description: "没有标签的待办也加入索引。",
},
shortWikiLinks: {
label: "简化双链名称",
description: "双链只显示最后一段名称,例如“人物/小明”显示为“小明”。",
},
"frontmatterFolding.foldByDefault": {
label: "自动折叠页面属性",
description: "打开笔记时,何时自动折叠顶部的 YAML 页面属性。",
options: {
never: "从不",
long: "内容较长时",
always: "总是",
},
},
"frontmatterFolding.foldByDefaultLines": {
label: "页面属性折叠行数",
description: "页面属性超过此行数时自动折叠,仅在“内容较长时”生效。",
},
"smartQuotes.enabled": {
label: "启用智能引号",
description: "输入时自动将直引号替换为排版引号。",
},
"smartQuotes.double.left": {
label: "左双引号",
description: "左侧双引号使用的字符。",
},
"smartQuotes.double.right": {
label: "右双引号",
description: "右侧双引号使用的字符。",
},
"smartQuotes.single.left": {
label: "左单引号",
description: "左侧单引号使用的字符。",
},
"smartQuotes.single.right": {
label: "右单引号",
description: "右侧单引号使用的字符。",
},
"comments.author": {
label: "批注署名",
description: "填写批注签名;留空时只记录日期。",
},
"queryCollation.enabled": {
label: "启用本地化排序",
description: "按照语言规则比较文本,而不是只按字符编码排序。",
},
"queryCollation.locale": {
label: "排序语言",
description: "用于文本排序的语言标签,例如 zh-CN。",
},
"std.widgets.toc.enabled": {
label: "显示页面目录",
description: "在页面顶部显示目录。",
},
"std.widgets.toc.minHeaders": {
label: "显示目录所需标题数",
description: "页面至少包含多少个标题时才显示目录。",
},
"std.widgets.linkedMentions.enabled": {
label: "显示关联提及",
description: "在页面底部显示其他笔记中对当前页面的引用。",
},
"std.widgets.linkedTasks.enabled": {
label: "显示关联待办",
description: "在页面顶部显示与当前页面相关的未完成待办。",
},
"journal.enabled": {
label: "启用日记",
description: "启用内置的每日日记功能和相关操作。",
},
"journal.template": {
label: "日记模板页面",
description: "新建日记时使用的模板页面。",
},
"journal.prefix": {
label: "日记保存位置",
description: "日记页面的路径前缀,例如 Journal/。以 / 结尾可归入文件夹。",
},
"journal.tag": {
label: "日记标签",
description: "用于识别日记页面的标签。",
},
"github.name": {
label: "Git 提交者姓名",
description: "写入 GitHub 仓库时使用的提交者姓名。",
},
"github.email": {
label: "Git 提交者邮箱",
description: "写入 GitHub 仓库时使用的提交者邮箱。",
},
"github.token": {
label: "GitHub 访问令牌",
description: "访问 GitHub API 所需的个人令牌。",
},
};
export function configurationCategoryZhCN(name: string): string {
return categories[name]?.label ?? name;
}
export function configurationCategoryDescriptionZhCN(
name: string,
fallback?: string,
): string | undefined {
return categories[name]?.description ?? fallback;
}
export function configurationLabelZhCN(path: string, fallback: string): string {
return fields[path]?.label ?? fallback;
}
export function configurationDescriptionZhCN(
path: string,
fallback?: string,
): string | undefined {
return fields[path]?.description ?? fallback;
}
export function configurationOptionZhCN(path: string, option: string): string {
return fields[path]?.options?.[option] ?? option;
}
export function configurationFieldMatches(
path: string,
schema: any,
query: string,
): boolean {
if (!query) return true;
const originalLabel = schema.ui?.label || path;
const originalDescription = schema.description || "";
const localizedLabel = configurationLabelZhCN(path, originalLabel);
const localizedDescription = configurationDescriptionZhCN(
path,
originalDescription,
);
return [
path,
originalLabel,
originalDescription,
localizedLabel,
localizedDescription,
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase()
.includes(query.toLocaleLowerCase());
}
@@ -24,7 +24,7 @@ html[data-theme="dark"] {
--cfg-recording-bg: #3a3520;
}
html, body { height: 100%; background: var(--cfg-bg); color: var(--cfg-color); }
html, body { width: 100%; height: 100%; overflow: hidden; background: var(--cfg-bg); color: var(--cfg-color); }
#cfg-root {
display: flex; flex-direction: column; height: 100%;
@@ -293,9 +293,16 @@ html[data-theme="dark"] .lib-badge-pull { background: #1f2a40; color: #89a0e5; }
@media only screen and (max-width: 600px) {
#cfg-header { padding: 12px 14px 0; }
#cfg-content { padding: 14px; }
#cfg-content {
min-width: 0;
padding: 14px;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
#cfg-footer { padding: 10px 14px; flex-wrap: wrap; gap: 8px; }
#cfg-close { top: 8px; right: 8px; }
#cfg-edit-config { display: none; }
.cfg-field {
flex-direction: column;
@@ -303,6 +310,7 @@ html[data-theme="dark"] .lib-badge-pull { background: #1f2a40; color: #89a0e5; }
gap: 8px;
}
.cfg-field-control {
min-width: 0;
margin-left: 0;
justify-content: flex-end;
}
@@ -311,6 +319,7 @@ html[data-theme="dark"] .lib-badge-pull { background: #1f2a40; color: #89a0e5; }
.cfg-field-control select {
flex: 1;
min-width: 0;
max-width: 100%;
}
.lib-row { flex-direction: column; align-items: stretch; }