chat-font.service.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { computed, effect, inject, Injectable, signal } from '@angular/core';
  2. import { SessionService } from './session.service';
  3. /**
  4. * 全局字号档位(px)
  5. *
  6. * 2026-10-30 用户要求:从「只管会话正文」升级为**全局字号**——
  7. * 菜单、列表、设置项一起缩放,否则正文调大后左栏还是小字,比例失衡。
  8. */
  9. export const CHAT_FONT_SIZES = [13, 14, 15, 16, 17, 18, 19, 20] as const;
  10. export type ChatFontSize = (typeof CHAT_FONT_SIZES)[number];
  11. /** 默认 **16px** —— 大字号优先(原 15px 在高分屏上偏小,用户要求默认调大) */
  12. export const DEFAULT_CHAT_FONT: ChatFontSize = 16;
  13. /**
  14. * 会话正文的字号偏好
  15. *
  16. * ## 为什么做成独立的可调项
  17. *
  18. * 会话是长时间阅读区,字号是**个人偏好**而不是设计统一值:
  19. * 有人 15px 正好,有人需要 17px。做成设置项比所有人共用一个值更合理。
  20. *
  21. * ## 实现方式
  22. *
  23. * 写一个 CSS 变量 `--chat-read-size` 到 `<html>`,消息组件里所有正文字号引用它 ——
  24. * 这样调字号不需要重建任何组件,浏览器直接重排。
  25. */
  26. @Injectable({ providedIn: 'root' })
  27. export class ChatFontService {
  28. private readonly session = inject(SessionService);
  29. private readonly _size = signal<ChatFontSize>(DEFAULT_CHAT_FONT);
  30. readonly size = this._size.asReadonly();
  31. /** 当前档位在列表中的位置(供滑块/分段控件用) */
  32. readonly index = computed(() => CHAT_FONT_SIZES.indexOf(this._size()));
  33. constructor() {
  34. /*
  35. * 尺寸变化即写 DOM 变量(无需重建组件)。
  36. *
  37. * 写三个变量而不是一个:
  38. * --chat-read-size 会话正文(原来只改这个)
  39. * --ui-scale 全局缩放基准(本次新增)
  40. * 左栏/设置等用 calc(var(--ui-scale) * 原值) 跟随缩放
  41. */
  42. effect(() => {
  43. const px = this._size();
  44. if (typeof document === 'undefined') return;
  45. const root = document.documentElement;
  46. root.style.setProperty('--chat-read-size', `${px}px`);
  47. /*
  48. * 以 16px 为 1.0 的基准缩放:13px→0.81,20px→1.25。
  49. * 用无单位数字,方便各处 calc() 相乘。
  50. */
  51. root.style.setProperty('--ui-scale', String(Math.round((px / DEFAULT_CHAT_FONT) * 1000) / 1000));
  52. });
  53. }
  54. private loadedFor: string | null = null;
  55. load(): void {
  56. const uid = this.session.me()?.id ?? null;
  57. if (this.loadedFor === uid) return;
  58. this.loadedFor = uid;
  59. const key = this.key();
  60. if (!key) {
  61. this.set(DEFAULT_CHAT_FONT, false);
  62. return;
  63. }
  64. try {
  65. const raw = localStorage.getItem(key);
  66. /*
  67. * 🔴 必须先判 null 再转数字。
  68. * `Number(null)` === 0,会被 pick 吸附到最近的 14px ——
  69. * 表现为"没设置过字号,却莫名其妙变成 14"(单测实测暴露)。
  70. */
  71. if (raw === null) {
  72. this.set(DEFAULT_CHAT_FONT, false);
  73. return;
  74. }
  75. this.set(pick(Number(raw)), false);
  76. } catch {
  77. this.set(DEFAULT_CHAT_FONT, false);
  78. }
  79. }
  80. set(px: number, persist = true): void {
  81. const next = pick(px);
  82. this._size.set(next);
  83. if (!persist) return;
  84. const key = this.key();
  85. if (!key) return;
  86. try {
  87. localStorage.setItem(key, String(next));
  88. } catch {
  89. /* 隐私模式:只保留内存态 */
  90. }
  91. }
  92. private key(): string | null {
  93. const uid = this.session.me()?.id;
  94. return uid ? `fmode_chat_font_${uid}` : null;
  95. }
  96. }
  97. /** 把任意数字吸附到最近的合法档位 */
  98. function pick(v: number): ChatFontSize {
  99. if (!Number.isFinite(v)) return DEFAULT_CHAT_FONT;
  100. return CHAT_FONT_SIZES.reduce((best, cur) =>
  101. Math.abs(cur - v) < Math.abs(best - v) ? cur : best,
  102. ) as ChatFontSize;
  103. }