feat(client): 标签模板编辑器 P2 · 编辑器屏+存储+打印联动(原型同提交)

设备管理「价签·编辑」进入独立编辑器屏 /devices/label-template:
- 左实时预览走真实 renderLabelPreview 渲染路径(示例数据, debounce 重渲)
- 右属性面板:多模板管理(编辑对象/打印默认分离)、显示字段手风琴、
  高级区域位置、打印参数
- 存 shop.custom_fields.label_templates + label_template_active(零后端改)
- Option A:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,
  各自独立开关;仅品名支持自动字号;字体不做(热敏单色)
- 打印联动:LabelPreviewDialog 改 ConsumerStatefulWidget, 未显式传模板时
  自动取本店 active 模板→所有打印入口(商品详情/出入库列表/编辑抽屉)一致生效

守护:
- 默认输出逐像素零变化由 label_render_golden_test 守(改内置默认常量才触发)
- 存储读写由 label_template_storage_test 单测守(纯内存, 不触真实库)
- 编辑器挂载由 label_template_editor_smoke_test 守
- 本屏不入自动 fidelity/golden 闸(左预览 Picture.toImage 需 runAsync,
  pumpAndSettle 骨架驱动不了), 原因+替代守法记入 CONTRACT 块4

design-first:原型 label-template-editor.html 去字体选择器、加 Option A 配对
镜像, 与真实屏同提交。真机热敏打印坐标/条码可扫须用户在打印机验收。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
wangjia
2026-08-30 09:56:23 +08:00
parent 27c5f94329
commit 7b3a0ecdeb
9 changed files with 1244 additions and 49 deletions
+6
View File
@@ -20,6 +20,7 @@ import '../../screens/settings/settings_screen.dart';
import '../../screens/settings/users_screen.dart';
import '../../screens/about/about_screen.dart';
import '../../screens/devices/device_management_screen.dart';
import '../../screens/devices/label_template_editor_screen.dart';
import '../../screens/me/me_screen.dart';
import '../../screens/license/license_screen.dart';
import '../auth/auth_state.dart';
@@ -183,6 +184,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/devices',
pageBuilder: (_, __) =>
_noTransition(const DeviceManagementScreen())),
// 标签价签模板编辑器(照原型 label-template-editor.html
GoRoute(
path: '/devices/label-template',
pageBuilder: (_, __) =>
_noTransition(const LabelTemplateEditorScreen())),
]),
// 7 系统设置(面板:门店信息/用户管理/编号规则/偏好设置;
// 授权管理 2026-07-10 提升为独立一级屏,见 branch 10 `/license`
+26
View File
@@ -570,6 +570,32 @@ class LabelTemplate {
);
}
/// 从 shop.custom_fields 读取店级模板列表;为空/损坏时回退单个 [LabelTemplate.builtinDefault]。
List<LabelTemplate> readLabelTemplates(Map<String, dynamic> cf) {
final raw = cf['label_templates'];
if (raw is List && raw.isNotEmpty) {
final list = raw
.whereType<Map>()
.map((m) => LabelTemplate.fromJson(m.cast<String, dynamic>()))
.toList();
if (list.isNotEmpty) return list;
}
return [LabelTemplate.builtinDefault()];
}
/// 当前出签实际使用的「打印默认」模板:由 custom_fields.label_template_activeid)指定,
/// 缺失/匹配不到时取列表首个;列表空时 builtinDefault。渲染/打印路径统一取此。
LabelTemplate activeLabelTemplate(Map<String, dynamic> cf) {
final list = readLabelTemplates(cf);
final id = cf['label_template_active'];
if (id is String && id.isNotEmpty) {
for (final t in list) {
if (t.id == id) return t;
}
}
return list.first;
}
/// 二维码最小边长(逻辑像素@203dpi ≈ 10mm):低于此扫码不可靠,computeLayout 兜底钳制。
const double kMinQrSize = 80;
@@ -7,12 +7,14 @@
// 打印模板窄屏不渲染(拍板:移动端无打印)。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart' show LabelData;
import '../../core/utils/print_util.dart'
show LabelData, activeLabelTemplate;
import '../../widgets/label_preview_dialog.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
@@ -989,7 +991,8 @@ class _DeviceManagementScreenState
onPressed: onPreview ?? () => _snack('模板预览即将上线')),
const SizedBox(width: 8),
DsButton('编辑',
small: true, onPressed: () => _snack('模板编辑即将上线')),
small: true,
onPressed: () => context.push('/devices/label-template')),
]),
],
),
@@ -1023,7 +1026,10 @@ class _DeviceManagementScreenState
if (!mounted) return;
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
builder: (_) => LabelPreviewDialog(
labels: [label],
template: shop != null ? activeLabelTemplate(shop.customFields) : null,
),
);
}
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -1,11 +1,13 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/errors/error_reporter.dart';
import '../core/theme/context_tokens.dart';
import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart';
import '../providers/shop_provider.dart';
import 'ds/ds_atoms.dart';
const _kPrinterKey = 'label_printer';
@@ -14,23 +16,37 @@ const _kPrinterKey = 'label_printer';
///
/// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。
/// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。
class LabelPreviewDialog extends StatefulWidget {
class LabelPreviewDialog extends ConsumerStatefulWidget {
final List<LabelData> labels;
/// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。
final Future<Uint8List> Function(int productId)? qrFetcher;
/// 出签使用的版式模板;显式传入优先,否则自动取本店 active 模板
/// [activeLabelTemplate],缺失回退 [LabelTemplate.builtinDefault]),
/// 使店家自定义的价签版式在所有打印入口一致生效。
final LabelTemplate? template;
const LabelPreviewDialog({
super.key,
required this.labels,
this.qrFetcher,
this.template,
});
@override
State<LabelPreviewDialog> createState() => _LabelPreviewDialogState();
ConsumerState<LabelPreviewDialog> createState() =>
_LabelPreviewDialogState();
}
class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
class _LabelPreviewDialogState extends ConsumerState<LabelPreviewDialog> {
/// 有效模板:显式 template 优先,否则读本店 active 模板(异步未就绪时回退默认)。
LabelTemplate? get _effectiveTemplate {
if (widget.template != null) return widget.template;
final shop = ref.read(shopInfoProvider).valueOrNull;
return shop != null ? activeLabelTemplate(shop.customFields) : null;
}
int _selectedIndex = 0;
late List<Uint8List?> _previews;
late List<bool> _include;
@@ -80,7 +96,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
final png = await renderLabelPreview(label);
final png = await renderLabelPreview(label, template: _effectiveTemplate);
if (mounted && png != null) {
setState(() => _previews[i] = png);
}
@@ -132,6 +148,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
shopAddress: label.shopAddress,
shopPhone: label.shopPhone,
printerName: _printer,
template: _effectiveTemplate,
);
done++;
if (mounted) {
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/shop.dart';
import 'package:jiu_client/providers/shop_provider.dart';
import 'package:jiu_client/screens/devices/label_template_editor_screen.dart';
/// 冒烟测试:编辑器屏在真 provider 下能构建/挂载(本屏无法走 golden——左预览用
/// `Picture.toImage` 直画光栅化,pumpAndSettle 骨架驱动不了,见 CONTRACT 块4)。
/// 这里只验证脚手架/面板正常渲染(预览停在静态占位,不阻塞 settle)。
const _shop = ShopInfo(
id: 1,
code: 'DSJH-001',
name: '鼎晟酒行',
address: '浙江省杭州市',
phone: '0571-88886666',
managerName: '王经理',
);
void main() {
testWidgets('标签模板编辑器 · 挂载渲染面板', (tester) async {
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(ProviderScope(
overrides: [shopInfoProvider.overrideWith((ref) => _shop)],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildTheme('a'),
home: const Scaffold(body: LabelTemplateEditorScreen()),
),
));
await tester.pumpAndSettle();
expect(find.text('标签模板编辑器'), findsOneWidget);
expect(find.text('显示字段'), findsOneWidget);
expect(find.text('打印参数'), findsOneWidget);
expect(find.text('模板'), findsWidgets);
// 字段行齐全(品名 + 二维码等)
expect(find.text('品名'), findsOneWidget);
expect(find.text('二维码'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/utils/label_template.dart';
/// 锁死模板存储读写(走 shop.custom_fields,零后端改动)与 JSON 往返,
/// 不触真实库——纯内存对象校验。
void main() {
group('LabelTemplate 存储 helpers', () {
test('custom_fields 无模板时回退内置默认', () {
final list = readLabelTemplates(<String, dynamic>{});
expect(list, hasLength(1));
expect(list.first.id, 'builtin-default');
});
test('activeLabelTemplate 按 id 命中,缺失回退第一项', () {
final a = LabelTemplate.builtinDefault();
final b = a.copyWith(id: 'tpl-2', name: '大字号');
final cf = <String, dynamic>{
'label_templates': [a.toJson(), b.toJson()],
'label_template_active': 'tpl-2',
};
expect(activeLabelTemplate(cf).id, 'tpl-2');
cf['label_template_active'] = 'nope';
expect(activeLabelTemplate(cf).id, 'builtin-default');
});
test('toJson → fromJson 往返保真(几何/开关/样式)', () {
final src = LabelTemplate.builtinDefault();
src.textStack.subLines[0].fontSize = 16;
src.textStack.subLines[0].bold = true;
src.qr.size = 120;
src.print.direction = 0;
final back = LabelTemplate.fromJson(src.toJson());
expect(back.textStack.subLines[0].fontSize, 16);
expect(back.textStack.subLines[0].bold, true);
expect(back.qr.size, 120);
expect(back.print.direction, 0);
});
test('存 → 读回列表保持顺序与命名', () {
final a = LabelTemplate.builtinDefault();
final b = a.copyWith(id: 'tpl-2', name: '双语抬头');
final cf = <String, dynamic>{
'label_templates': [a.toJson(), b.toJson()],
};
final list = readLabelTemplates(cf);
expect(list.map((t) => t.id), ['builtin-default', 'tpl-2']);
expect(list[1].name, '双语抬头');
});
});
}
+1
View File
@@ -107,6 +107,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
| 出库列表(桌/移) | `stock-out-list.html` | `stock_out_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill**2026-07-14 六项优化**:详情抽屉明细同增生产日期+批次号两列(抽屉 660)、状态筛选改多选 chip 且默认「草稿+待审核」、新建单默认填充默认仓库(原型 stock-out-list.html 同步);fidelity a 3.228% / b 2.217% / c 3.538%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden |
| 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿;**2026-07-14 追加**:应收/应付汇总卡头部新增「往来单位」搜索——本地即时过滤(`/finance/summary` 全量非分页,搜索口径=显示口径),桌面 `DsSearchBox``_secHead` trailing)/移动 应收·应付 分段头 `MSearchRow`(流水分段沿用既有 chip→sheet 单选,不重复),原型 finance.html `.sum-search` / m-finance.html 应收应付分段 `.m-search` 同步,finance 相关 6 个整屏 golden + m_finance 3 个 golden 已按新布局 `--update-goldens`**2026-07-14 验收反馈调整**:桌面「往来单位」搜索由卡标题行 `DsSearchBox` 挪进汇总表表头「往来单位」列头右侧,改为下拉单选 `ComboSearchField`(同 `_flowTable` 往来单位筛选控件,选中精确匹配一行/× 清除恢复全量,候选=当前汇总行往来单位);原型 `.sec-head .sum-search` 移除,改 `.th-party-wrap` 内嵌 `.combo` + `openSearchMenu` 弹层;移动端未动。desktop `finance` golden 3 主题已 `--update-goldens`3.11% 差异,目检确认新版式),`finance_mobile`/`m_finance` 无变化、原样通过;**2026-07-14 验收反馈2**(实机截图 3 处细节):①下拉候选由「仅当前汇总行往来单位」改为「全量往来单位」(`allPartnersProvider`,与 `_flowTable` toolbar 同源),选中一个汇总没有余额行的单位时走既有「没有匹配的往来单位」空态;②占位符叠词修复——`ComboSearchField.hint``'搜索往来单位'``'往来单位'`(内部会自动拼「搜索」前缀),弹层内搜索框恢复正常显示「搜索往来单位…」,收起态触发器文案对齐 `_flowTable` 写法同为「往来单位」;③表头行下拉垂直位置——`header` 外包 `SizedBox(height: 34)` 显式钉住 Row 高度消除潜在字体行高偏差,不改表头行 pad 11/14 既有行高口径。原型 finance.html 同步:`ALL_PARTNERS``SUMMARY` 全部单位 + 1 个无余额行的「鼎丰超市」演示空态)替换 `sumPartyId`/`SUMMARY` 候选、`#sumPartyInput` 收起态 placeholder 改「往来单位」;`.th-party-wrap{align-items:center}` + 浏览器 `th` 默认 `vertical-align:middle` 本就居中,未改 CSS。desktop `finance` golden 3 主题已 `--update-goldens`(0.04% 差异,目检确认新候选/占位文案,表头对齐无回归),`finance_mobile`/`m_finance` 无变化、原样通过 | ✅ fidelity | ✅ fidelity |
| 设备管理(桌/移) | `devices.html` | `device_management_screen.dart` | 同步 | ✅ **Phase2 重建**:会话表(DsTable)+外设卡网格(custom_fields.peripherals 本地存档)+打印模板;fidelity 2.43.0%≤8% | ✅ fidelity | ✅ fidelity |
| 标签模板编辑器(桌/移) | `label-template-editor.html` | `label_template_editor_screen.dart``/devices/label-template`,设备管理「价签·编辑」进入) | 同步 | ✅ **2026-08 P2 落地**:左实时预览(走真实 `renderLabelPreview` 渲染路径,示例数据)+ 右属性面板(模板管理[编辑对象/打印默认分离]·显示字段手风琴·高级区域位置·打印参数);存 `shop.custom_fields.label_templates`+`label_template_active`(零后端改)。**Option A**:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,各自独立开关;仅品名支持自动字号;字体不做(热敏单色)。默认输出逐像素零变化由 `label_render_golden_test.dart` 守(改内置默认常量才触发)。⚠️ **本屏不入自动 fidelity/golden 闸**:左预览用 `Picture.toImage` 直画光栅化(同 label_render,需 `runAsync`),`pumpAndSettle` 的 widget-golden 骨架无法驱动→整屏 golden 会把预览拍成占位。原型=代码一致由 serve 目检 + 本行保证;存储读写由 `label_template_storage_test.dart` 单测守;真机打印坐标/条码可扫须用户在热敏机验收 | ⚠️ 见左(不入闸) | ⚠️ 见左(不入闸) |
| 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.72.3%≤8%**2026-07-10 授权管理面板迁出**(提升为独立一级屏 `license.html`),subnav 剩 门店信息/用户管理/偏好——**真实端已同步完成**(`settings_screen.dart` 子导航同步摘除授权管理项,golden 已按新态重生成,见授权管理两行) | ✅ fidelity | ✅ fidelity |
| 用户管理(桌/移) | `users.html` | `users_screen.dart``/settings/users` | 同步 | ✅ **Phase2 新独立页**KPI 4 卡+搜索/角色筛选+rcard 弹窗,角色四级拉平;fidelity 1.32.1%≤8% | ✅ fidelity | ✅ fidelity |
| 关于我们(桌/移) | `about.html` | `about_screen.dart` | 同步 | ✅ **Phase2 重建**Hero/产品信息/更新日志 timeline(/public/release 同源)fidelity 2.22.7%≤8%;授权信息卡 2026-07-04 迁至 设置→授权管理 | ✅ fidelity | ✅ fidelity |
@@ -245,25 +245,22 @@
/* ── 字段清单:样式内嵌每行,手风琴展开 ── */
/* 1 期字体:仅系统字体 + 应用已内置思源黑体,不打包新字体(宋体已撤)。
楷体/等宽蹭系统,缺失回退默认。 */
const FONTS = [
['系统默认', ''],
['思源黑体(内置)', 'sans-serif'],
['楷体', 'KaiTi, STKaiti, serif'],
['等宽数字', 'ui-monospace, monospace'],
];
// 字体不做(热敏单色 MVP);仅「品名」支持自动字号。同物理行两字段共用字号/加粗/对齐(PAIR)。
const ALIGN_LABEL = {left:'左', center:'居中', right:'右'};
const FIELDS = [
{f:'name', label:'品名', type:'text', on:true, auto:true, bold:true, align:'center', font:0, size:18},
{f:'shop', label:'店名(抬头)', type:'text', on:true, auto:false, bold:true, align:'center', font:0, size:14},
{f:'code', label:'编号', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14},
{f:'series', label:'型号 / 度数', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14},
{f:'spec', label:'版本 / 规格', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14},
{f:'date', label:'生产日期', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14},
{f:'batch', label:'批次号', type:'text', on:false, auto:false, bold:false, align:'center', font:0, size:14},
{f:'remark', label:'备注', type:'text', on:false, auto:false, bold:false, align:'center', font:0, size:14},
{f:'name', label:'品名', type:'text', on:true, auto:true, bold:true, align:'center', size:18},
{f:'shop', label:'店名(抬头)', type:'text', on:true, auto:false, bold:true, align:'center', size:14},
{f:'code', label:'编号', type:'text', on:true, auto:false, bold:false, align:'center', size:14},
{f:'series', label:'型号 / 度数', type:'text', on:true, auto:false, bold:false, align:'center', size:14},
{f:'spec', label:'版本 / 规格', type:'text', on:true, auto:false, bold:false, align:'center', size:14},
{f:'date', label:'生产日期', type:'text', on:true, auto:false, bold:false, align:'center', size:14},
{f:'batch', label:'批次号', type:'text', on:false, auto:false, bold:false, align:'center', size:14},
{f:'remark', label:'备注', type:'text', on:false, auto:false, bold:false, align:'center', size:14},
{f:'qr', label:'二维码', type:'graphic', on:true},
{f:'barcode', label:'条形码', type:'graphic', on:true},
];
// 编号↔型号 同物理行、版本↔日期 同物理行:字号/加粗/对齐共用(Option A)
const PAIR = {2:3, 3:2, 4:5, 5:4};
function metaText(fd){
if(fd.type==='graphic') return '图形 · 位置见高级';
@@ -273,18 +270,14 @@
return parts.join(' · ');
}
function fontBody(fd, i){
const opts = FONTS.map((ft,fi)=>
`<div class="lt-fontitem${fi===fd.font?' on':''}" onclick="pickFont(event,${i},${fi})">${ft[0]}</div>`).join('');
const autoCtrl = fd.f==='name'
? `<span class="lt-fmeta">自动</span>
<button class="switch${fd.auto?' on':''}" onclick="toggleAuto(event,${i})"></button>`
: '';
return `<div class="lt-fbody">
<div class="lt-srow"><span class="k">字体</span>
<div class="lt-fontsel">
<div class="ctrl" onclick="toggleFont(event,${i})"><span class="lt-fontname">${FONTS[fd.font][0]}</span><svg width="14" height="14" viewBox="0 0 24 24" stroke-width="2"><use href="#i-chevron-down"/></svg></div>
<div class="lt-fontpop">${opts}</div>
</div></div>
<div class="lt-srow"><span class="k">字号</span>
<input class="input" value="${fd.size}" ${fd.auto?'disabled':''} oninput="setSize(${i},this.value)">
<span class="lt-fmeta">自动</span>
<button class="switch${fd.auto?' on':''}" onclick="toggleAuto(event,${i})"></button></div>
${autoCtrl}</div>
<div class="lt-srow"><span class="k">加粗</span>
<button class="switch${fd.bold?' on':''}" onclick="toggleBold(event,${i})"></button></div>
<div class="lt-srow"><span class="k">对齐</span>
@@ -322,13 +315,20 @@
previewEls(i).forEach(el=>{
el.classList.toggle('lt-off', !fd.on);
if(fd.type==='text'){
el.style.fontFamily = FONTS[fd.font][1];
el.style.fontWeight = fd.bold ? '700' : '400';
el.style.textAlign = fd.align;
el.style.fontSize = fd.auto ? '' : fd.size+'px';
}
});
}
/* 同物理行配对字段(编号↔型号、版本↔日期)共用字号/加粗/对齐 */
function mirror(i){
const p = PAIR[i]; if(p==null) return;
FIELDS[p].size = FIELDS[i].size;
FIELDS[p].bold = FIELDS[i].bold;
FIELDS[p].align = FIELDS[i].align;
applyStyle(p); refreshMeta(p);
}
/* 手风琴:一次只展开一行 */
function expandField(i){
const el = fieldEl(i), wasOpen = el.classList.contains('open');
@@ -342,24 +342,9 @@
fieldEl(i).classList.toggle('disabled', !FIELDS[i].on);
applyStyle(i);
}
function toggleFont(e, i){
e.stopPropagation();
const pop = fieldEl(i).querySelector('.lt-fontpop'), open = pop.classList.contains('show');
document.querySelectorAll('.lt-fontpop.show').forEach(p=>p.classList.remove('show'));
if(!open) pop.classList.add('show');
}
function pickFont(e, i, fi){
e.stopPropagation();
FIELDS[i].font = fi;
const el = fieldEl(i);
el.querySelectorAll('.lt-fontitem').forEach((it,x)=>it.classList.toggle('on', x===fi));
el.querySelector('.lt-fontname').textContent = FONTS[fi][0];
el.querySelector('.lt-fontpop').classList.remove('show');
applyStyle(i);
}
function setSize(i, v){
const n = parseInt(v,10);
if(!isNaN(n)){ FIELDS[i].size = n; applyStyle(i); refreshMeta(i); }
if(!isNaN(n)){ FIELDS[i].size = n; applyStyle(i); refreshMeta(i); mirror(i); }
}
function toggleAuto(e, i){
e.stopPropagation();
@@ -372,14 +357,14 @@
e.stopPropagation();
FIELDS[i].bold = !FIELDS[i].bold;
e.currentTarget.classList.toggle('on', FIELDS[i].bold);
applyStyle(i); refreshMeta(i);
applyStyle(i); refreshMeta(i); mirror(i);
}
function setAlign(e, i, a){
e.stopPropagation();
FIELDS[i].align = a;
e.currentTarget.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));
e.currentTarget.classList.add('on');
applyStyle(i); refreshMeta(i);
applyStyle(i); refreshMeta(i); mirror(i);
}
function pickSeg(btn){