From 46e04af90090ab6b094c13e87c0dcfd862a7bab4 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Tue, 20 Oct 2026 15:30:53 +0800 Subject: [PATCH 1/7] feat: add favorite math templates store Persist favorite math template ids in localStorage (max 10) behind a shared store and a useFavoriteMathTemplates hook. Stored data is sanitized on read, rejected writes leave state and storage untouched, and the list stays in sync across tabs via the storage event. Co-Authored-By: Claude Opus 5.5 --- src/lib/favorite-math-templates/store.js | 187 ++++++++++ src/lib/favorite-math-templates/store.test.js | 341 ++++++++++++++++++ .../favorite-math-templates/template-index.js | 10 + .../template-index.test.js | 18 + .../use-favorite-math-templates.js | 21 ++ .../use-favorite-math-templates.test.jsx | 61 ++++ 6 files changed, 638 insertions(+) create mode 100644 src/lib/favorite-math-templates/store.js create mode 100644 src/lib/favorite-math-templates/store.test.js create mode 100644 src/lib/favorite-math-templates/template-index.js create mode 100644 src/lib/favorite-math-templates/template-index.test.js create mode 100644 src/lib/favorite-math-templates/use-favorite-math-templates.js create mode 100644 src/lib/favorite-math-templates/use-favorite-math-templates.test.jsx diff --git a/src/lib/favorite-math-templates/store.js b/src/lib/favorite-math-templates/store.js new file mode 100644 index 0000000..4965593 --- /dev/null +++ b/src/lib/favorite-math-templates/store.js @@ -0,0 +1,187 @@ +import createStore from '@/lib/create-store'; +import { mathTemplateIndex } from './template-index'; + +export const FAVORITE_MATH_TEMPLATE_STORAGE_KEY = 'access8math.favoriteMathTemplateIds'; +export const FAVORITE_MATH_TEMPLATE_LIMIT = 10; + +export const FavoriteResult = { + SUCCESS: 'success', + NOT_FOUND: 'not_found', + LIMIT_REACHED: 'limit_reached', + ALREADY_EXISTS: 'already_exists', + INVALID: 'invalid', + STORAGE_ERROR: 'storage_error', +}; + +/** + * @typedef {object} FavoriteOperationResult + * @property {boolean} ok + * @property {string} reason - One of FavoriteResult. + */ + +const succeed = () => ({ ok: true, reason: FavoriteResult.SUCCESS }); +const fail = (reason) => ({ ok: false, reason }); + +// Accessing `window.localStorage` itself throws when site data is blocked. +const getLocalStorage = () => { + try { + return window.localStorage; + } catch { + return null; + } +}; + +/** + * Turns a raw stored value into a list that upholds every invariant: + * known template ids only, no duplicates, at most FAVORITE_MATH_TEMPLATE_LIMIT. + * @param {string | null} raw + * @returns {string[]} + */ +const parseStoredIds = (raw) => { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + const ids = parsed.filter((id) => typeof id === 'string' && mathTemplateIndex.has(id)); + return [...new Set(ids)].slice(0, FAVORITE_MATH_TEMPLATE_LIMIT); +}; + +const readStoredIds = () => { + try { + return parseStoredIds(getLocalStorage()?.getItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY) ?? null); + } catch { + return []; + } +}; + +const isValidIdList = (ids) => + Array.isArray(ids) && + ids.length <= FAVORITE_MATH_TEMPLATE_LIMIT && + // Spread so holes in a sparse array are checked as `undefined` instead of skipped. + [...ids].every((id) => typeof id === 'string' && mathTemplateIndex.has(id)) && + new Set(ids).size === ids.length; + +const isSameList = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]); + +const createFavoriteMathTemplatesStore = () => { + // `null` until first read, so nothing touches localStorage at import time. + const store = createStore(null); + const subscriptions = new Set(); + + // Keeps the current reference when nothing changed, so React sees a stable snapshot. + const setIds = (ids) => { + const current = store.getState(); + if (current === null || !isSameList(current, ids)) { + store.update(ids); + } + }; + + // Without subscribers no `storage` events are received, so the cached list may be stale. + const getIds = () => { + if (store.getState() === null || subscriptions.size === 0) { + setIds(readStoredIds()); + } + return store.getState(); + }; + + // Write first and update state only on success, so state never runs ahead of storage. + const persist = (ids) => { + try { + getLocalStorage().setItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY, JSON.stringify(ids)); + } catch { + return fail(FavoriteResult.STORAGE_ERROR); + } + setIds(ids); + return succeed(); + }; + + const handleStorage = (event) => { + if (event.storageArea !== getLocalStorage()) { + return; + } + // `key` is null when another tab cleared the whole storage. + if (event.key !== null && event.key !== FAVORITE_MATH_TEMPLATE_STORAGE_KEY) { + return; + } + // Read the current value rather than `event.newValue`: events arrive asynchronously and + // this tab may have written again since. + setIds(readStoredIds()); + }; + + return { + getIds, + + /** + * @param {(ids: string[]) => void} callback + * @returns {() => void} Unsubscribe function. + */ + subscribe(callback) { + // Load before registering, so the lazy first read never notifies anyone. + getIds(); + const subscription = {}; + const unsubscribe = store.subscribe(callback); + if (subscriptions.size === 0) { + window.addEventListener('storage', handleStorage); + } + subscriptions.add(subscription); + + return () => { + // Ignore repeated calls, and calls for subscriptions dropped by `reset()`. + if (!subscriptions.delete(subscription)) { + return; + } + unsubscribe(); + if (subscriptions.size === 0) { + window.removeEventListener('storage', handleStorage); + } + }; + }, + + /** + * Appends a template to the end of the list. The limit is checked before duplicates. + * @param {string} id + * @returns {FavoriteOperationResult} + */ + addFavorite(id) { + const ids = getIds(); + if (!mathTemplateIndex.has(id)) { + return fail(FavoriteResult.NOT_FOUND); + } + if (ids.length >= FAVORITE_MATH_TEMPLATE_LIMIT) { + return fail(FavoriteResult.LIMIT_REACHED); + } + if (ids.includes(id)) { + return fail(FavoriteResult.ALREADY_EXISTS); + } + return persist([...ids, id]); + }, + + /** + * Replaces the whole list, e.g. after reordering or removing in the edit modal. + * @param {string[]} ids + * @returns {FavoriteOperationResult} + */ + saveFavorites(ids) { + if (!isValidIdList(ids)) { + return fail(FavoriteResult.INVALID); + } + return persist([...ids]); + }, + + /** + * Forgets the loaded list and all subscribers. For testing purposes. + */ + reset() { + store.reset(); + subscriptions.clear(); + window.removeEventListener('storage', handleStorage); + }, + }; +}; + +export const favoriteMathTemplatesStore = createFavoriteMathTemplatesStore(); diff --git a/src/lib/favorite-math-templates/store.test.js b/src/lib/favorite-math-templates/store.test.js new file mode 100644 index 0000000..71fc6a7 --- /dev/null +++ b/src/lib/favorite-math-templates/store.test.js @@ -0,0 +1,341 @@ +import { vi } from 'vitest'; + +import { + favoriteMathTemplatesStore as store, + FavoriteResult, + FAVORITE_MATH_TEMPLATE_LIMIT, + FAVORITE_MATH_TEMPLATE_STORAGE_KEY as KEY, +} from './store'; + +const TEN_IDS = [ + 'frac', + 'sqrt', + 'root', + 'sumupdown', + 'vector', + 'limit', + 'logarithm', + 'triangle', + 'degree', + 'circ', +]; + +const seed = (value) => { + localStorage.setItem(KEY, typeof value === 'string' ? value : JSON.stringify(value)); +}; + +const rawStored = () => localStorage.getItem(KEY); + +const quotaError = () => { + throw new DOMException('Quota exceeded', 'QuotaExceededError'); +}; + +const dispatchStorageEvent = (init) => { + window.dispatchEvent(new StorageEvent('storage', { storageArea: localStorage, ...init })); +}; + +describe('favoriteMathTemplatesStore', () => { + beforeEach(() => { + localStorage.clear(); + store.reset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses a limit of 10', () => { + expect(FAVORITE_MATH_TEMPLATE_LIMIT).toBe(10); + expect(TEN_IDS).toHaveLength(FAVORITE_MATH_TEMPLATE_LIMIT); + }); + + describe('loading', () => { + it('starts empty when nothing is stored', () => { + expect(store.getIds()).toEqual([]); + }); + + it('restores a valid stored list in order', () => { + seed(['sqrt', 'frac', 'arc']); + expect(store.getIds()).toEqual(['sqrt', 'frac', 'arc']); + }); + + it.each([ + ['invalid JSON', '{'], + ['a JSON string', '"frac"'], + ['a JSON object', '{"0":"frac"}'], + ['JSON null', 'null'], + ])('treats %s as an empty list', (_, raw) => { + seed(raw); + expect(store.getIds()).toEqual([]); + }); + + it('drops non-string, unknown and duplicate ids and caps the list at the limit', () => { + seed([1, null, 'frac', 'frac', 'not-a-template', ...TEN_IDS.slice(1), 'arc', 'angle']); + expect(store.getIds()).toEqual(TEN_IDS); + }); + + it('treats an unreadable storage as an empty list', () => { + seed(['frac']); + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Blocked', 'SecurityError'); + }); + expect(store.getIds()).toEqual([]); + }); + + it('returns the same array reference until the list changes', () => { + seed(['frac']); + expect(store.getIds()).toBe(store.getIds()); + }); + + it('does not read localStorage at import time', async () => { + vi.resetModules(); + const getItem = vi.spyOn(Storage.prototype, 'getItem'); + await import('./store'); + expect(getItem).not.toHaveBeenCalled(); + }); + }); + + describe('addFavorite', () => { + it('appends a new id to the end and persists it', () => { + seed(['sqrt']); + expect(store.addFavorite('frac')).toEqual({ ok: true, reason: FavoriteResult.SUCCESS }); + expect(store.getIds()).toEqual(['sqrt', 'frac']); + expect(JSON.parse(rawStored())).toEqual(['sqrt', 'frac']); + }); + + it('cleans stale ids from storage on the next successful write', () => { + seed(['gone', 'sqrt']); + store.addFavorite('frac'); + expect(JSON.parse(rawStored())).toEqual(['sqrt', 'frac']); + }); + + it('rejects an unknown id without touching storage', () => { + seed(['sqrt']); + const before = rawStored(); + expect(store.addFavorite('not-a-template')).toEqual({ + ok: false, + reason: FavoriteResult.NOT_FOUND, + }); + expect(store.getIds()).toEqual(['sqrt']); + expect(rawStored()).toBe(before); + }); + + it('rejects a duplicate without touching storage', () => { + seed(['sqrt', 'frac']); + const before = rawStored(); + expect(store.addFavorite('frac')).toEqual({ + ok: false, + reason: FavoriteResult.ALREADY_EXISTS, + }); + expect(store.getIds()).toEqual(['sqrt', 'frac']); + expect(rawStored()).toBe(before); + }); + + it('rejects an 11th id without touching storage', () => { + seed(TEN_IDS); + const before = rawStored(); + expect(store.addFavorite('arc')).toEqual({ + ok: false, + reason: FavoriteResult.LIMIT_REACHED, + }); + expect(store.getIds()).toEqual(TEN_IDS); + expect(rawStored()).toBe(before); + }); + + it('checks the limit before checking for duplicates', () => { + seed(TEN_IDS); + expect(store.addFavorite('frac').reason).toBe(FavoriteResult.LIMIT_REACHED); + }); + + it('leaves state and storage unchanged when the write fails', () => { + seed(['sqrt']); + const before = rawStored(); + const listener = vi.fn(); + store.subscribe(listener); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(quotaError); + + expect(store.addFavorite('frac')).toEqual({ + ok: false, + reason: FavoriteResult.STORAGE_ERROR, + }); + expect(store.getIds()).toEqual(['sqrt']); + expect(listener).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + expect(rawStored()).toBe(before); + }); + + it('reports a storage error when localStorage is not accessible at all', () => { + vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Blocked', 'SecurityError'); + }); + expect(store.getIds()).toEqual([]); + expect(store.addFavorite('frac')).toEqual({ + ok: false, + reason: FavoriteResult.STORAGE_ERROR, + }); + expect(store.getIds()).toEqual([]); + }); + + it('notifies subscribers with the new list', () => { + const listener = vi.fn(); + store.subscribe(listener); + store.addFavorite('frac'); + expect(listener).toHaveBeenCalledWith(['frac']); + }); + }); + + describe('saveFavorites', () => { + it('persists a reordered and shortened list', () => { + seed(['frac', 'sqrt', 'arc']); + expect(store.saveFavorites(['arc', 'frac'])).toEqual({ + ok: true, + reason: FavoriteResult.SUCCESS, + }); + expect(store.getIds()).toEqual(['arc', 'frac']); + expect(JSON.parse(rawStored())).toEqual(['arc', 'frac']); + }); + + it('persists an empty list', () => { + seed(['frac']); + expect(store.saveFavorites([]).ok).toBe(true); + expect(JSON.parse(rawStored())).toEqual([]); + }); + + it.each([ + ['a non-array', 'frac'], + ['a non-string id', ['frac', 1]], + ['an unknown id', ['frac', 'not-a-template']], + ['a duplicate id', ['frac', 'frac']], + ['more ids than the limit', [...TEN_IDS, 'arc']], + ['a sparse array', Object.assign([], { 1: 'frac' })], + ])('rejects %s without touching storage', (_, ids) => { + seed(['sqrt']); + const before = rawStored(); + expect(store.saveFavorites(ids)).toEqual({ ok: false, reason: FavoriteResult.INVALID }); + expect(store.getIds()).toEqual(['sqrt']); + expect(rawStored()).toBe(before); + }); + + it('leaves state and storage unchanged when the write fails', () => { + seed(['frac', 'sqrt']); + const before = rawStored(); + const listener = vi.fn(); + store.subscribe(listener); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(quotaError); + + expect(store.saveFavorites(['sqrt'])).toEqual({ + ok: false, + reason: FavoriteResult.STORAGE_ERROR, + }); + expect(store.getIds()).toEqual(['frac', 'sqrt']); + expect(listener).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + expect(rawStored()).toBe(before); + }); + + it('does not keep a reference to the caller array', () => { + const ids = ['frac']; + store.saveFavorites(ids); + ids.push('sqrt'); + expect(store.getIds()).toEqual(['frac']); + }); + }); + + describe('sync from other tabs', () => { + // jsdom does not fire `storage` in the writing document, so another tab's write is + // simulated by changing localStorage and then dispatching the event by hand. + const writeFromOtherTab = (value) => { + seed(value); + dispatchStorageEvent({ key: KEY }); + }; + + it('adopts a list written by another tab', () => { + const listener = vi.fn(); + store.subscribe(listener); + writeFromOtherTab(['sqrt', 'frac']); + expect(store.getIds()).toEqual(['sqrt', 'frac']); + expect(listener).toHaveBeenCalledWith(['sqrt', 'frac']); + }); + + it('sanitizes a garbage value written by another tab', () => { + store.subscribe(vi.fn()); + writeFromOtherTab(['frac', 'nope', 'frac']); + expect(store.getIds()).toEqual(['frac']); + }); + + it('reads the current value instead of a stale event value', () => { + store.subscribe(vi.fn()); + seed(['sqrt']); + dispatchStorageEvent({ key: KEY, newValue: JSON.stringify(['frac']) }); + expect(store.getIds()).toEqual(['sqrt']); + }); + + it('empties the list when another tab clears storage', () => { + seed(['frac']); + store.subscribe(vi.fn()); + expect(store.getIds()).toEqual(['frac']); + localStorage.clear(); + dispatchStorageEvent({ key: null }); + expect(store.getIds()).toEqual([]); + }); + + it('ignores other keys and other storage areas', () => { + seed(['frac']); + const listener = vi.fn(); + store.subscribe(listener); + const before = store.getIds(); + + seed(['sqrt']); + dispatchStorageEvent({ key: 'i18nextLng' }); + dispatchStorageEvent({ key: null, storageArea: sessionStorage }); + + expect(store.getIds()).toBe(before); + expect(listener).not.toHaveBeenCalled(); + }); + + it('stops listening once every subscriber has unsubscribed', () => { + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + unsubscribe(); + writeFromOtherTab(['frac']); + expect(listener).not.toHaveBeenCalled(); + }); + + it('keeps listening for the remaining subscriber when another unsubscribes twice', () => { + const leaving = store.subscribe(vi.fn()); + const listener = vi.fn(); + store.subscribe(listener); + + leaving(); + leaving(); + writeFromOtherTab(['frac']); + + expect(listener).toHaveBeenCalledWith(['frac']); + }); + + it('keeps listening after a stale unsubscribe from before reset()', () => { + const stale = store.subscribe(vi.fn()); + store.reset(); + stale(); + + const listener = vi.fn(); + store.subscribe(listener); + writeFromOtherTab(['frac']); + + expect(listener).toHaveBeenCalledWith(['frac']); + }); + + it('re-reads storage while nobody is subscribed', () => { + seed(['frac']); + const unsubscribe = store.subscribe(vi.fn()); + unsubscribe(); + + // Another tab writes while no `storage` listener is attached. + seed(['frac', 'sqrt']); + + expect(store.getIds()).toEqual(['frac', 'sqrt']); + store.addFavorite('root'); + expect(JSON.parse(rawStored())).toEqual(['frac', 'sqrt', 'root']); + }); + }); +}); diff --git a/src/lib/favorite-math-templates/template-index.js b/src/lib/favorite-math-templates/template-index.js new file mode 100644 index 0000000..a06d235 --- /dev/null +++ b/src/lib/favorite-math-templates/template-index.js @@ -0,0 +1,10 @@ +import mathTabList from '@/lib/tabs/math'; + +/** + * Lookup of every math template by id, built from the static `mathTabList`. + * Favorites persist only ids, so ids must stay globally unique across categories. + * @type {Map} + */ +export const mathTemplateIndex = new Map( + mathTabList.flatMap((tab) => tab.subTabs.map((template) => [template.id, template])) +); diff --git a/src/lib/favorite-math-templates/template-index.test.js b/src/lib/favorite-math-templates/template-index.test.js new file mode 100644 index 0000000..2490fdf --- /dev/null +++ b/src/lib/favorite-math-templates/template-index.test.js @@ -0,0 +1,18 @@ +import mathTabList from '@/lib/tabs/math'; +import { mathTemplateIndex } from './template-index'; + +describe('mathTemplateIndex', () => { + const allTemplates = mathTabList.flatMap((tab) => tab.subTabs); + + it('keeps every math template id globally unique', () => { + // Favorites persist bare ids, so a duplicate id would make two templates indistinguishable. + expect(mathTemplateIndex.size).toBe(allTemplates.length); + }); + + it('maps an id to its full template object', () => { + const frac = allTemplates.find((template) => template.id === 'frac'); + + expect(mathTemplateIndex.get('frac')).toBe(frac); + expect(mathTemplateIndex.get('frac')).toMatchObject({ latex: '\\frac{}{}', offset: -3 }); + }); +}); diff --git a/src/lib/favorite-math-templates/use-favorite-math-templates.js b/src/lib/favorite-math-templates/use-favorite-math-templates.js new file mode 100644 index 0000000..09dd2e7 --- /dev/null +++ b/src/lib/favorite-math-templates/use-favorite-math-templates.js @@ -0,0 +1,21 @@ +import { useMemo, useSyncExternalStore } from 'react'; + +import { favoriteMathTemplatesStore } from './store'; +import { mathTemplateIndex } from './template-index'; + +const { subscribe, getIds, addFavorite, saveFavorites } = favoriteMathTemplatesStore; + +/** + * Favorite math templates shared by every component, persisted in localStorage. + */ +const useFavoriteMathTemplates = () => { + const favoriteIds = useSyncExternalStore(subscribe, getIds); + const favoriteTemplates = useMemo( + () => favoriteIds.map((id) => mathTemplateIndex.get(id)), + [favoriteIds] + ); + + return { favoriteIds, favoriteTemplates, addFavorite, saveFavorites }; +}; + +export default useFavoriteMathTemplates; diff --git a/src/lib/favorite-math-templates/use-favorite-math-templates.test.jsx b/src/lib/favorite-math-templates/use-favorite-math-templates.test.jsx new file mode 100644 index 0000000..aef5e83 --- /dev/null +++ b/src/lib/favorite-math-templates/use-favorite-math-templates.test.jsx @@ -0,0 +1,61 @@ +import { vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; + +import { favoriteMathTemplatesStore, FAVORITE_MATH_TEMPLATE_STORAGE_KEY } from './store'; +import { mathTemplateIndex } from './template-index'; +import useFavoriteMathTemplates from './use-favorite-math-templates'; + +describe('useFavoriteMathTemplates', () => { + beforeEach(() => { + localStorage.clear(); + favoriteMathTemplatesStore.reset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('resolves stored ids to template objects', () => { + localStorage.setItem( + FAVORITE_MATH_TEMPLATE_STORAGE_KEY, + JSON.stringify(['sqrt', 'gone', 'frac']) + ); + const { result } = renderHook(() => useFavoriteMathTemplates()); + + expect(result.current.favoriteIds).toEqual(['sqrt', 'frac']); + expect(result.current.favoriteTemplates).toEqual([ + mathTemplateIndex.get('sqrt'), + mathTemplateIndex.get('frac'), + ]); + }); + + it('re-renders after a change and keeps references stable otherwise', () => { + const consoleError = vi.spyOn(console, 'error'); + const { result, rerender } = renderHook(() => useFavoriteMathTemplates()); + const { favoriteIds, favoriteTemplates } = result.current; + + rerender(); + expect(result.current.favoriteIds).toBe(favoriteIds); + expect(result.current.favoriteTemplates).toBe(favoriteTemplates); + + act(() => { + result.current.addFavorite('frac'); + }); + expect(result.current.favoriteIds).toEqual(['frac']); + expect(result.current.favoriteTemplates).toEqual([mathTemplateIndex.get('frac')]); + + // useSyncExternalStore warns when getSnapshot returns a new value on every call. + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('shares one list across components', () => { + const first = renderHook(() => useFavoriteMathTemplates()); + const second = renderHook(() => useFavoriteMathTemplates()); + + act(() => { + first.result.current.saveFavorites(['sqrt', 'frac']); + }); + + expect(second.result.current.favoriteIds).toEqual(['sqrt', 'frac']); + }); +}); From 743c51d013fe148b97bc837a3028b8262a6ed3bc Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Tue, 20 Oct 2026 15:35:03 +0800 Subject: [PATCH 2/7] feat: add favorites category to math templates Show the saved favorite templates as the first math category, insert them on click, and open on it by default when the list is not empty. Co-Authored-By: Claude Opus 5.5 --- public/locales/en/tabs.json | 4 + public/locales/zh-TW/tabs.json | 4 + src/components/edit-icons-tab.jsx | 87 +++++++++-- src/components/edit-icons-tab.test.jsx | 144 ++++++++++++++++++ .../favorite-math-templates/locales.test.js | 21 +++ 5 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 src/components/edit-icons-tab.test.jsx create mode 100644 src/lib/favorite-math-templates/locales.test.js diff --git a/public/locales/en/tabs.json b/public/locales/en/tabs.json index d758166..4d4bedb 100644 --- a/public/locales/en/tabs.json +++ b/public/locales/en/tabs.json @@ -22,7 +22,11 @@ "quote": "quote", "table": "table" }, + "favorite": { + "empty": "No favorite symbols yet. Right-click a symbol (or press the context menu key or Shift+F10) to add it to favorites." + }, "categorys": { + "favorite": "Favorites", "common": "Common", "operator": "Operator", "relation": "Relation", diff --git a/public/locales/zh-TW/tabs.json b/public/locales/zh-TW/tabs.json index d88f9b7..67b9fbb 100644 --- a/public/locales/zh-TW/tabs.json +++ b/public/locales/zh-TW/tabs.json @@ -21,7 +21,11 @@ "quote": "引用", "table": "表格" }, + "favorite": { + "empty": "尚未建立常用符號。在符號上按右鍵(或快顯鍵、Shift+F10)即可加入常用。" + }, "categorys": { + "favorite": "常用符號", "common": "常見", "operator": "運算子", "relation": "關係", diff --git a/src/components/edit-icons-tab.jsx b/src/components/edit-icons-tab.jsx index 6737458..1ac858d 100644 --- a/src/components/edit-icons-tab.jsx +++ b/src/components/edit-icons-tab.jsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import { Tab } from '@headlessui/react'; +import { IconTagStarred } from '@tabler/icons-react'; import { useTranslation } from '@/lib/i18n'; import mainTabList from '@/lib/tabs/main'; @@ -8,11 +9,16 @@ import markdowns from '@/lib/tabs/markdowns'; import { compare } from '@/lib/data-process'; import mathTabList from '@/lib/tabs/math'; +import useFavoriteMathTemplates from '@/lib/favorite-math-templates/use-favorite-math-templates'; import ImageUploadModal from './image-upload-modal'; import LinkInputModal from './link-input-modal'; import IframeInputModal from './iframe-input-modal'; import Tooltip from './core/tooltip'; +// The favorites category comes before every category in mathTabList. +const FAVORITE_TAB_INDEX = 0; +const FIRST_MATH_TAB_INDEX = 1; + const generateUniqueId = (length = 8) => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length }, () => chars.charAt(Math.floor(Math.random() * chars.length))).join( @@ -20,9 +26,38 @@ const generateUniqueId = (length = 8) => { ); }; +const getCategoryTabClassName = (isSelected) => + `group relative rounded category-icon flex items-center justify-center cursor-pointer transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${ + isSelected ? 'active bg-blue-100' : 'bg-white hover:bg-blue-100' + }`; + +const MathTemplateButton = ({ template, label, onClick }) => ( + + + +); + +MathTemplateButton.propTypes = { + template: PropTypes.shape({ + id: PropTypes.string.isRequired, + Icon: PropTypes.elementType.isRequired, + }).isRequired, + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, +}; + const EditIconsTab = ({ insertLatex, addImageToExport }) => { + const { favoriteTemplates } = useFavoriteMathTemplates(); const [selectedMainTabIndex, setSelectedMainTabIndex] = useState(0); - const [selectedMathTabIndex, setSelectedMathTabIndex] = useState(0); + const [selectedMathTabIndex, setSelectedMathTabIndex] = useState(() => + favoriteTemplates.length > 0 ? FAVORITE_TAB_INDEX : FIRST_MATH_TAB_INDEX + ); const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const [isIframeModalOpen, setIsIframeModalOpen] = useState(false); @@ -114,17 +149,24 @@ const EditIconsTab = ({ insertLatex, addImageToExport }) => { msOverflowStyle: 'none', // hide scrollbar in IE/Edge }} > - {mathTabList.map((tab, mathTabIndex) => ( + + getCategoryTabClassName(selected)} + > + + + + + {mathTabList.map((tab) => ( getCategoryTabClassName(selected)} > @@ -132,19 +174,32 @@ const EditIconsTab = ({ insertLatex, addImageToExport }) => { ))} + + {favoriteTemplates.length > 0 ? ( + favoriteTemplates.map((template) => ( + insertLatex(template)} + /> + )) + ) : ( +

+ {t('favorite.empty')} +

+ )} +
{mathTabList.map((mathTab) => { return ( {(mathTab?.subTabs || []).sort(compare('order', 'asc')).map((subTab) => ( - - - + insertLatex(subTab)} + /> ))} ); diff --git a/src/components/edit-icons-tab.test.jsx b/src/components/edit-icons-tab.test.jsx new file mode 100644 index 0000000..94ee735 --- /dev/null +++ b/src/components/edit-icons-tab.test.jsx @@ -0,0 +1,144 @@ +import { vi } from 'vitest'; +import React from 'react'; +import { act, render, screen, fireEvent, within } from '@testing-library/react'; + +import EditIconsTab from './edit-icons-tab'; +import markdowns from '@/lib/tabs/markdowns'; +import { + favoriteMathTemplatesStore, + FAVORITE_MATH_TEMPLATE_STORAGE_KEY, +} from '@/lib/favorite-math-templates/store'; +import { mathTemplateIndex } from '@/lib/favorite-math-templates/template-index'; + +vi.mock('@/lib/i18n', () => ({ + useTranslation: () => (key) => key, +})); + +const seedFavorites = (ids) => { + localStorage.setItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY, JSON.stringify(ids)); +}; + +const renderEditIconsTab = () => { + const insertLatex = vi.fn(); + render(); + return { insertLatex }; +}; + +const getCategoryTabs = () => within(screen.getAllByRole('tablist')[1]).getAllByRole('tab'); + +describe('', () => { + beforeEach(() => { + localStorage.clear(); + favoriteMathTemplatesStore.reset(); + }); + + it('lists the favorites category before every math category', () => { + renderEditIconsTab(); + const [favoriteTab, commonTab] = getCategoryTabs(); + + expect(favoriteTab).toHaveAccessibleName('categorys.favorite'); + expect(commonTab).toHaveAccessibleName('categorys.common'); + }); + + describe('without favorites', () => { + it('opens on the common category', () => { + renderEditIconsTab(); + expect(screen.getByRole('tab', { name: 'categorys.common' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('shows the empty state in the favorites category', () => { + renderEditIconsTab(); + fireEvent.click(screen.getByRole('tab', { name: 'categorys.favorite' })); + expect(screen.getByText('favorite.empty')).toBeInTheDocument(); + }); + }); + + describe('with favorites', () => { + beforeEach(() => { + seedFavorites(['sqrt', 'frac']); + }); + + it('opens on the favorites category, in the saved order', () => { + renderEditIconsTab(); + expect(screen.getByRole('tab', { name: 'categorys.favorite' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + + const panel = screen.getByRole('tabpanel', { name: 'categorys.favorite' }); + expect( + within(panel) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')) + ).toEqual(['latexs.sqrt', 'latexs.frac']); + }); + + it('inserts a favorite template with the same template object', () => { + const { insertLatex } = renderEditIconsTab(); + fireEvent.click(screen.getByRole('button', { name: 'latexs.sqrt' })); + + expect(insertLatex).toHaveBeenCalledTimes(1); + expect(insertLatex.mock.calls[0][0]).toBe(mathTemplateIndex.get('sqrt')); + }); + }); + + it('only picks the default category on mount', () => { + renderEditIconsTab(); + + act(() => { + favoriteMathTemplatesStore.addFavorite('sqrt'); + }); + + expect(screen.getByRole('tab', { name: 'categorys.common' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('highlights and shows the selected later category', () => { + renderEditIconsTab(); + fireEvent.click(screen.getByRole('tab', { name: 'categorys.geometry' })); + + const geometryTab = screen.getByRole('tab', { name: 'categorys.geometry' }); + expect(geometryTab).toHaveAttribute('aria-selected', 'true'); + expect(geometryTab).toHaveClass('active'); + expect(screen.getByRole('tab', { name: 'categorys.common' })).not.toHaveClass('active'); + expect( + within(screen.getByRole('tabpanel', { name: 'categorys.geometry' })).getByRole('button', { + name: 'latexs.arc', + }) + ).toBeInTheDocument(); + }); + + it('updates the favorites category when the list changes', () => { + renderEditIconsTab(); + fireEvent.click(screen.getByRole('tab', { name: 'categorys.favorite' })); + + act(() => { + favoriteMathTemplatesStore.addFavorite('arc'); + }); + + expect(screen.queryByText('favorite.empty')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'latexs.arc' })).toBeInTheDocument(); + }); + + it('keeps inserting a regular math template on left click', () => { + const { insertLatex } = renderEditIconsTab(); + fireEvent.click(screen.getByRole('button', { name: 'latexs.frac' })); + + expect(insertLatex).toHaveBeenCalledTimes(1); + expect(insertLatex.mock.calls[0][0]).toBe(mathTemplateIndex.get('frac')); + }); + + it('keeps inserting a markdown template on left click', () => { + const { insertLatex } = renderEditIconsTab(); + fireEvent.click(screen.getByRole('tab', { name: 'main.markdown' })); + fireEvent.click(screen.getByRole('button', { name: 'markdown.bold' })); + + expect(insertLatex).toHaveBeenCalledTimes(1); + expect(insertLatex).toHaveBeenCalledWith(markdowns.find((tab) => tab.id === 'bold')); + }); +}); diff --git a/src/lib/favorite-math-templates/locales.test.js b/src/lib/favorite-math-templates/locales.test.js new file mode 100644 index 0000000..e03cff1 --- /dev/null +++ b/src/lib/favorite-math-templates/locales.test.js @@ -0,0 +1,21 @@ +import zhTabs from '@/../public/locales/zh-TW/tabs.json'; +import enTabs from '@/../public/locales/en/tabs.json'; + +const keyPaths = (value, prefix = '') => + value !== null && typeof value === 'object' + ? Object.entries(value).flatMap(([key, child]) => + keyPaths(child, prefix ? `${prefix}.${key}` : key) + ) + : [prefix]; + +// Only the favorites copy is checked: some existing namespaces already differ between locales. +describe('favorite math templates copy', () => { + it.each([ + ['tabs.json favorite', zhTabs.favorite, enTabs.favorite], + ['tabs.json categorys.favorite', zhTabs.categorys.favorite, enTabs.categorys.favorite], + ])('has the same keys in zh-TW and en: %s', (_, zh, en) => { + expect(zh).toBeDefined(); + expect(en).toBeDefined(); + expect(keyPaths(en).sort()).toEqual(keyPaths(zh).sort()); + }); +}); From e85c96b74104d2f0f843a937d50cce2ac0bb3fe4 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Tue, 20 Oct 2026 15:53:20 +0800 Subject: [PATCH 3/7] feat: add math templates to favorites from a context menu Right-clicking a math template, or pressing the context menu key or Shift+F10 on it, opens an accessible menu to add it to favorites. Success is announced with a toast; reaching the limit or adding a duplicate shows an alert dialog, and focus returns to the template. Co-Authored-By: Claude Opus 5.5 --- public/locales/en/tabs.json | 10 +- public/locales/zh-TW/tabs.json | 10 +- src/components/core/context-menu.jsx | 203 ++++++++++++++++ src/components/core/context-menu.test.jsx | 228 ++++++++++++++++++ src/components/core/modal/alert-modal.jsx | 64 +++++ .../core/modal/alert-modal.test.jsx | 52 ++++ src/components/edit-icons-tab.jsx | 97 +++++++- src/components/edit-icons-tab.test.jsx | 181 +++++++++++++- src/setupTests.js | 10 + 9 files changed, 849 insertions(+), 6 deletions(-) create mode 100644 src/components/core/context-menu.jsx create mode 100644 src/components/core/context-menu.test.jsx create mode 100644 src/components/core/modal/alert-modal.jsx create mode 100644 src/components/core/modal/alert-modal.test.jsx diff --git a/public/locales/en/tabs.json b/public/locales/en/tabs.json index 4d4bedb..e8ede0b 100644 --- a/public/locales/en/tabs.json +++ b/public/locales/en/tabs.json @@ -23,7 +23,15 @@ "table": "table" }, "favorite": { - "empty": "No favorite symbols yet. Right-click a symbol (or press the context menu key or Shift+F10) to add it to favorites." + "empty": "No favorite symbols yet. Right-click a symbol (or press the context menu key or Shift+F10) to add it to favorites.", + "add": "Add to favorites", + "added": "Added {{name}} to favorites", + "limitReachedTitle": "Favorites limit reached", + "limitReachedMessage": "You can have up to {{limit}} favorite symbols.\nRemove one before adding a new symbol.", + "duplicateTitle": "Already in favorites", + "duplicateMessage": "This symbol is already in your favorites.", + "confirm": "OK", + "saveFailed": "Couldn't save favorites. Please try again." }, "categorys": { "favorite": "Favorites", diff --git a/public/locales/zh-TW/tabs.json b/public/locales/zh-TW/tabs.json index 67b9fbb..b6f063c 100644 --- a/public/locales/zh-TW/tabs.json +++ b/public/locales/zh-TW/tabs.json @@ -22,7 +22,15 @@ "table": "表格" }, "favorite": { - "empty": "尚未建立常用符號。在符號上按右鍵(或快顯鍵、Shift+F10)即可加入常用。" + "empty": "尚未建立常用符號。在符號上按右鍵(或快顯鍵、Shift+F10)即可加入常用。", + "add": "加入常用", + "added": "您已成功將 {{name}} 加入至常用符號", + "limitReachedTitle": "常用符號已超過上限", + "limitReachedMessage": "最多只能有 {{limit}} 個常用符號\n請刪除其他符號以加入新的符號", + "duplicateTitle": "常用符號已重複", + "duplicateMessage": "請再檢查一次常用符號列", + "confirm": "確認", + "saveFailed": "無法儲存常用符號,請稍後再試" }, "categorys": { "favorite": "常用符號", diff --git a/src/components/core/context-menu.jsx b/src/components/core/context-menu.jsx new file mode 100644 index 0000000..390c286 --- /dev/null +++ b/src/components/core/context-menu.jsx @@ -0,0 +1,203 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; + +const VIEWPORT_MARGIN = 8; +const TRIGGER_GAP = 4; + +/** + * Where to open a context menu for a `contextmenu` or keyboard event on `event.currentTarget`. + * Pointer events open at the pointer; keyboard-triggered ones open just below the trigger. + * @param {MouseEvent | KeyboardEvent} event + * @returns {{ x: number, y: number }} + */ +export const getContextMenuPosition = (event) => { + const rect = event.currentTarget.getBoundingClientRect(); + const { clientX, clientY } = event; + // Keyboard-triggered `contextmenu` events report the trigger's centre (or 0,0) as their + // position. Browsers that dispatch them as PointerEvents mark them with an empty pointerType. + const isKeyboard = (event.nativeEvent ?? event).pointerType === ''; + const isPointerOnTrigger = + !isKeyboard && + typeof clientX === 'number' && + (clientX !== 0 || clientY !== 0) && + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom; + + return isPointerOnTrigger + ? { x: clientX, y: clientY } + : { x: rect.left, y: rect.bottom + TRIGGER_GAP }; +}; + +/** + * A menu (menu/menuitem pattern) shown at a viewport position, e.g. on right click. + * Mount it to open it; it calls `onClose` when it should be unmounted. + */ +const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { + const menuRef = useRef(null); + const itemRefs = useRef([]); + const [activeIndex, setActiveIndex] = useState(0); + const [placement, setPlacement] = useState(position); + + // preventScroll: scrolling the template panel under the pointer would make the next click miss. + const returnFocus = useCallback(() => { + returnFocusTo?.focus({ preventScroll: true }); + }, [returnFocusTo]); + + const close = useCallback(() => { + // Never leave focus on an element that is about to be removed. + if (menuRef.current?.contains(document.activeElement)) { + returnFocus(); + } + onClose(); + }, [onClose, returnFocus]); + + const select = (index) => { + returnFocus(); + onClose(); + items[index].onSelect(); + }; + + // Keep the menu inside the viewport. + useLayoutEffect(() => { + const { width, height } = menuRef.current.getBoundingClientRect(); + setPlacement({ + x: Math.max( + VIEWPORT_MARGIN, + Math.min(position.x, window.innerWidth - width - VIEWPORT_MARGIN) + ), + y: Math.max( + VIEWPORT_MARGIN, + Math.min(position.y, window.innerHeight - height - VIEWPORT_MARGIN) + ), + }); + }, [position]); + + useEffect(() => { + itemRefs.current[activeIndex]?.focus(); + }, [activeIndex]); + + useEffect(() => { + const handlePointerDown = (event) => { + if (!menuRef.current?.contains(event.target)) { + close(); + } + }; + document.addEventListener('mousedown', handlePointerDown, true); + window.addEventListener('resize', close); + window.addEventListener('scroll', close, true); + return () => { + document.removeEventListener('mousedown', handlePointerDown, true); + window.removeEventListener('resize', close); + window.removeEventListener('scroll', close, true); + }; + }, [close]); + + const handleKeyDown = (event) => { + const lastIndex = items.length - 1; + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setActiveIndex((index) => (index === lastIndex ? 0 : index + 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setActiveIndex((index) => (index === 0 ? lastIndex : index - 1)); + break; + case 'Home': + event.preventDefault(); + setActiveIndex(0); + break; + case 'End': + event.preventDefault(); + setActiveIndex(lastIndex); + break; + case 'Enter': + event.preventDefault(); + select(activeIndex); + break; + case ' ': + // Runs on keyup, like a native button, so the keyup cannot reach the trigger. + event.preventDefault(); + break; + case 'Escape': + event.preventDefault(); + close(); + break; + case 'Tab': + // Focus returns to the trigger first, so Tab then moves on from there. + close(); + break; + default: + } + }; + + const handleKeyUp = (event) => { + if (event.key === ' ') { + event.preventDefault(); + select(activeIndex); + } + }; + + // Focus moving elsewhere without a press on the page, e.g. into an iframe or another app. + const handleBlur = (event) => { + if (!menuRef.current?.contains(event.relatedTarget)) { + onClose(); + } + }; + + return createPortal( +
event.preventDefault()} + onContextMenu={(event) => event.preventDefault()} + > + {items.map((item, index) => ( + + ))} +
, + document.body + ); +}; + +ContextMenu.propTypes = { + position: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }).isRequired, + label: PropTypes.string.isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, + }) + ).isRequired, + returnFocusTo: PropTypes.instanceOf(Element), + onClose: PropTypes.func.isRequired, +}; + +export default ContextMenu; diff --git a/src/components/core/context-menu.test.jsx b/src/components/core/context-menu.test.jsx new file mode 100644 index 0000000..c834b2c --- /dev/null +++ b/src/components/core/context-menu.test.jsx @@ -0,0 +1,228 @@ +import { vi } from 'vitest'; +import React, { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import ContextMenu, { getContextMenuPosition } from './context-menu'; + +const TRIGGER_RECT = { left: 100, top: 50, right: 150, bottom: 100, width: 50, height: 50 }; + +const Harness = ({ items }) => { + const [menu, setMenu] = useState(null); + return ( + <> + + + {menu && ( + setMenu(null)} + /> + )} + + ); +}; + +const renderOpenMenu = () => { + const items = [ + { id: 'first', label: 'First', onSelect: vi.fn() }, + { id: 'second', label: 'Second', onSelect: vi.fn() }, + { id: 'third', label: 'Third', onSelect: vi.fn() }, + ]; + render(); + const trigger = screen.getByRole('button', { name: 'trigger' }); + trigger.focus(); + fireEvent.contextMenu(trigger); + return { items, trigger, menu: screen.getByRole('menu', { name: 'menu label' }) }; +}; + +describe('', () => { + it('focuses the first item when it opens', () => { + renderOpenMenu(); + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([ + 'First', + 'Second', + 'Third', + ]); + expect(screen.getByRole('menuitem', { name: 'First' })).toHaveFocus(); + }); + + it('moves between items with the arrow, Home and End keys, wrapping around', () => { + const { menu } = renderOpenMenu(); + const item = (name) => screen.getByRole('menuitem', { name }); + + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(item('Third')).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(item('First')).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(item('Second')).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'End' }); + expect(item('Third')).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(item('First')).toHaveFocus(); + }); + + it('runs the focused item with Enter, closes and returns focus to the trigger', () => { + const { items, trigger, menu } = renderOpenMenu(); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + fireEvent.keyDown(menu, { key: 'Enter' }); + + expect(items[1].onSelect).toHaveBeenCalledTimes(1); + expect(items[0].onSelect).not.toHaveBeenCalled(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it('runs the focused item with Space on keyup, so the keyup never reaches the trigger', () => { + const { items, trigger, menu } = renderOpenMenu(); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + fireEvent.keyDown(menu, { key: ' ' }); + + expect(items[1].onSelect).not.toHaveBeenCalled(); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + fireEvent.keyUp(menu, { key: ' ' }); + expect(items[1].onSelect).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it('runs an item on click', () => { + const { items, trigger } = renderOpenMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Third' })); + + expect(items[2].onSelect).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it('closes on Escape without running anything and returns focus to the trigger', () => { + const { items, trigger, menu } = renderOpenMenu(); + fireEvent.keyDown(menu, { key: 'Escape' }); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + items.forEach((item) => expect(item.onSelect).not.toHaveBeenCalled()); + expect(trigger).toHaveFocus(); + }); + + it('closes on Tab and returns focus to the trigger', () => { + const { trigger, menu } = renderOpenMenu(); + fireEvent.keyDown(menu, { key: 'Tab' }); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it('closes when pressing outside, without running anything, and returns focus to the trigger', () => { + const { items, trigger } = renderOpenMenu(); + fireEvent.mouseDown(screen.getByRole('button', { name: 'outside' })); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + items.forEach((item) => expect(item.onSelect).not.toHaveBeenCalled()); + expect(trigger).toHaveFocus(); + }); + + it('returns focus without scrolling the trigger into view', () => { + const { trigger, menu } = renderOpenMenu(); + const focus = vi.spyOn(trigger, 'focus'); + fireEvent.keyDown(menu, { key: 'Escape' }); + + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('stays open, keeping focus, when pressing inside', () => { + renderOpenMenu(); + const pressed = fireEvent.mouseDown(screen.getByRole('menuitem', { name: 'Second' })); + + expect(pressed).toBe(false); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it.each([ + ['the page scrolls', () => fireEvent.scroll(window)], + ['the window resizes', () => fireEvent(window, new Event('resize'))], + ])('closes when %s', (_, act) => { + renderOpenMenu(); + act(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('closes, without taking focus back, when focus leaves the page', () => { + const { trigger } = renderOpenMenu(); + fireEvent.blur(screen.getByRole('menuitem', { name: 'First' }), { relatedTarget: null }); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).not.toHaveFocus(); + }); + + it('stays open when focus moves between its items', () => { + renderOpenMenu(); + fireEvent.blur(screen.getByRole('menuitem', { name: 'First' }), { + relatedTarget: screen.getByRole('menuitem', { name: 'Second' }), + }); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('suppresses the browser context menu on itself', () => { + const { menu } = renderOpenMenu(); + expect(fireEvent.contextMenu(menu)).toBe(false); + }); + + it('keeps itself inside the viewport', () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 100, + height: 40, + }); + render( + + ); + + expect(screen.getByRole('menu')).toHaveStyle({ + left: `${window.innerWidth - 100 - 8}px`, + top: `${window.innerHeight - 40 - 8}px`, + }); + vi.restoreAllMocks(); + }); +}); + +describe('getContextMenuPosition', () => { + const eventOn = (init) => { + const currentTarget = { getBoundingClientRect: () => TRIGGER_RECT }; + return { currentTarget, ...init }; + }; + + it('opens at the pointer for a right click on the trigger', () => { + expect(getContextMenuPosition(eventOn({ clientX: 120, clientY: 70 }))).toEqual({ + x: 120, + y: 70, + }); + }); + + it.each([ + ['a keyboard event', {}], + ['a contextmenu event without coordinates', { clientX: 0, clientY: 0 }], + ['coordinates outside the trigger', { clientX: 5, clientY: 5 }], + [ + 'a keyboard-triggered contextmenu at the trigger centre', + { clientX: 125, clientY: 75, nativeEvent: { pointerType: '' } }, + ], + ])('opens below the trigger for %s', (_, init) => { + expect(getContextMenuPosition(eventOn(init))).toEqual({ x: 100, y: 104 }); + }); +}); diff --git a/src/components/core/modal/alert-modal.jsx b/src/components/core/modal/alert-modal.jsx new file mode 100644 index 0000000..6a9c78f --- /dev/null +++ b/src/components/core/modal/alert-modal.jsx @@ -0,0 +1,64 @@ +import React, { Fragment, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Dialog, Transition } from '@headlessui/react'; +import { IconAlertTriangle } from '@tabler/icons-react'; + +import Button from '@/components/core/button'; + +/** + * A blocking message with a single confirm button (alertdialog pattern). + * `message` may contain line breaks. + */ +const AlertModal = ({ isOpen, title, message, confirmLabel, onClose }) => { + const confirmButtonRef = useRef(null); + + return ( + + +
+ {/* background layer */} +
+ + + + {title} + + + {message} + + + +
+
+
+ ); +}; + +AlertModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + title: PropTypes.string, + message: PropTypes.string, + confirmLabel: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default AlertModal; diff --git a/src/components/core/modal/alert-modal.test.jsx b/src/components/core/modal/alert-modal.test.jsx new file mode 100644 index 0000000..a8fd0d0 --- /dev/null +++ b/src/components/core/modal/alert-modal.test.jsx @@ -0,0 +1,52 @@ +import { vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import AlertModal from './alert-modal'; + +const renderAlertModal = (props) => { + const onClose = vi.fn(); + render( + + ); + return { onClose }; +}; + +describe('', () => { + // Headless UI wires up the description and initial focus after mounting. + it('exposes its title and message to assistive technology', async () => { + renderAlertModal(); + const dialog = screen.getByRole('alertdialog', { name: 'Limit reached' }); + + await waitFor(() => expect(dialog).toHaveAccessibleDescription(/First line\s+Second line/)); + }); + + it('focuses the confirm button when it opens', async () => { + renderAlertModal(); + await waitFor(() => expect(screen.getByRole('button', { name: 'OK' })).toHaveFocus()); + }); + + it.each([ + ['the confirm button', () => fireEvent.click(screen.getByRole('button', { name: 'OK' }))], + [ + 'Escape', + () => fireEvent.keyDown(screen.getByRole('button', { name: 'OK' }), { key: 'Escape' }), + ], + ])('closes with %s', (_, act) => { + const { onClose } = renderAlertModal(); + act(); + expect(onClose).toHaveBeenCalled(); + }); + + it('renders nothing while closed', () => { + renderAlertModal({ isOpen: false }); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/edit-icons-tab.jsx b/src/components/edit-icons-tab.jsx index 1ac858d..ebbc08a 100644 --- a/src/components/edit-icons-tab.jsx +++ b/src/components/edit-icons-tab.jsx @@ -10,10 +10,14 @@ import markdowns from '@/lib/tabs/markdowns'; import { compare } from '@/lib/data-process'; import mathTabList from '@/lib/tabs/math'; import useFavoriteMathTemplates from '@/lib/favorite-math-templates/use-favorite-math-templates'; +import { FavoriteResult, FAVORITE_MATH_TEMPLATE_LIMIT } from '@/lib/favorite-math-templates/store'; import ImageUploadModal from './image-upload-modal'; import LinkInputModal from './link-input-modal'; import IframeInputModal from './iframe-input-modal'; import Tooltip from './core/tooltip'; +import ContextMenu, { getContextMenuPosition } from './core/context-menu'; +import AlertModal from './core/modal/alert-modal'; +import { showToast } from './core/toast/service'; // The favorites category comes before every category in mathTabList. const FAVORITE_TAB_INDEX = 0; @@ -31,12 +35,25 @@ const getCategoryTabClassName = (isSelected) => isSelected ? 'active bg-blue-100' : 'bg-white hover:bg-blue-100' }`; -const MathTemplateButton = ({ template, label, onClick }) => ( +const FAVORITE_ALERT_COPY = { + [FavoriteResult.LIMIT_REACHED]: { + title: 'favorite.limitReachedTitle', + message: 'favorite.limitReachedMessage', + }, + [FavoriteResult.ALREADY_EXISTS]: { + title: 'favorite.duplicateTitle', + message: 'favorite.duplicateMessage', + }, +}; + +const MathTemplateButton = ({ template, label, onClick, onContextMenu, onKeyDown }) => ( @@ -50,10 +67,12 @@ MathTemplateButton.propTypes = { }).isRequired, label: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, + onContextMenu: PropTypes.func, + onKeyDown: PropTypes.func, }; const EditIconsTab = ({ insertLatex, addImageToExport }) => { - const { favoriteTemplates } = useFavoriteMathTemplates(); + const { favoriteTemplates, addFavorite } = useFavoriteMathTemplates(); const [selectedMainTabIndex, setSelectedMainTabIndex] = useState(0); const [selectedMathTabIndex, setSelectedMathTabIndex] = useState(() => favoriteTemplates.length > 0 ? FAVORITE_TAB_INDEX : FIRST_MATH_TAB_INDEX @@ -61,9 +80,53 @@ const EditIconsTab = ({ insertLatex, addImageToExport }) => { const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const [isIframeModalOpen, setIsIframeModalOpen] = useState(false); + const [contextMenu, setContextMenu] = useState(null); + // `reason` outlives `isOpen` so the copy does not blank out while the alert closes. + const [favoriteAlert, setFavoriteAlert] = useState({ isOpen: false, reason: null }); const t = useTranslation('tabs'); + const closeContextMenu = useCallback(() => setContextMenu(null), []); + + const openContextMenu = useCallback((event, template) => { + event.preventDefault(); + const trigger = event.currentTarget; + const position = getContextMenuPosition(event); + // A new key per opening remounts the menu, so it re-focuses and re-measures. + setContextMenu((previous) => ({ + key: (previous?.key ?? 0) + 1, + template, + trigger, + position, + })); + }, []); + + const handleTemplateKeyDown = useCallback( + (event, template) => { + // Not every browser maps Shift+F10 to a `contextmenu` event (e.g. Chrome on macOS). + if (event.key === 'F10' && event.shiftKey) { + openContextMenu(event, template); + } + }, + [openContextMenu] + ); + + const handleAddFavorite = useCallback( + (template) => { + const { ok, reason } = addFavorite(template.id); + if (ok) { + showToast.success(t('favorite.added', { name: t(`latexs.${template.id}`) })); + return; + } + if (FAVORITE_ALERT_COPY[reason]) { + setFavoriteAlert({ isOpen: true, reason }); + return; + } + showToast.error(t('favorite.saveFailed')); + }, + [addFavorite, t] + ); + const handleLinkConfirm = useCallback( (markdown) => { insertLatex({ @@ -199,6 +262,8 @@ const EditIconsTab = ({ insertLatex, addImageToExport }) => { template={subTab} label={t(`latexs.${subTab.id}`)} onClick={() => insertLatex(subTab)} + onContextMenu={(event) => openContextMenu(event, subTab)} + onKeyDown={(event) => handleTemplateKeyDown(event, subTab)} /> ))} @@ -252,6 +317,34 @@ const EditIconsTab = ({ insertLatex, addImageToExport }) => { onClose={() => setIsIframeModalOpen(false)} onConfirm={handleIframeConfirm} /> + {contextMenu && ( + handleAddFavorite(contextMenu.template), + }, + ]} + returnFocusTo={contextMenu.trigger} + onClose={closeContextMenu} + /> + )} + setFavoriteAlert((alert) => ({ ...alert, isOpen: false }))} + /> ); }; diff --git a/src/components/edit-icons-tab.test.jsx b/src/components/edit-icons-tab.test.jsx index 94ee735..7d086f3 100644 --- a/src/components/edit-icons-tab.test.jsx +++ b/src/components/edit-icons-tab.test.jsx @@ -1,19 +1,38 @@ import { vi } from 'vitest'; import React from 'react'; -import { act, render, screen, fireEvent, within } from '@testing-library/react'; +import { act, render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import EditIconsTab from './edit-icons-tab'; import markdowns from '@/lib/tabs/markdowns'; +import { showToast } from '@/components/core/toast/service'; import { favoriteMathTemplatesStore, FAVORITE_MATH_TEMPLATE_STORAGE_KEY, } from '@/lib/favorite-math-templates/store'; import { mathTemplateIndex } from '@/lib/favorite-math-templates/template-index'; +// Echo the key, plus interpolation values when given, so tests can check both. vi.mock('@/lib/i18n', () => ({ - useTranslation: () => (key) => key, + useTranslation: () => (key, options) => (options ? `${key} ${JSON.stringify(options)}` : key), })); +vi.mock('@/components/core/toast/service', () => ({ + showToast: { success: vi.fn(), error: vi.fn() }, +})); + +const TEN_IDS = [ + 'frac', + 'sqrt', + 'root', + 'sumupdown', + 'vector', + 'limit', + 'logarithm', + 'triangle', + 'degree', + 'circ', +]; + const seedFavorites = (ids) => { localStorage.setItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY, JSON.stringify(ids)); }; @@ -26,10 +45,30 @@ const renderEditIconsTab = () => { const getCategoryTabs = () => within(screen.getAllByRole('tablist')[1]).getAllByRole('tab'); +const openCategory = (id) => { + fireEvent.click(screen.getByRole('tab', { name: `categorys.${id}` })); +}; + +const rightClickTemplate = (id) => { + const button = screen.getByRole('button', { name: `latexs.${id}` }); + act(() => button.focus()); + fireEvent.contextMenu(button); + return button; +}; + +const chooseAddFavorite = () => { + fireEvent.click(screen.getByRole('menuitem', { name: 'favorite.add' })); +}; + describe('', () => { beforeEach(() => { localStorage.clear(); favoriteMathTemplatesStore.reset(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('lists the favorites category before every math category', () => { @@ -141,4 +180,142 @@ describe('', () => { expect(insertLatex).toHaveBeenCalledTimes(1); expect(insertLatex).toHaveBeenCalledWith(markdowns.find((tab) => tab.id === 'bold')); }); + + describe('adding a favorite', () => { + it('opens a menu named after the template on right click, without inserting', () => { + const { insertLatex } = renderEditIconsTab(); + rightClickTemplate('frac'); + + const menu = screen.getByRole('menu', { name: 'latexs.frac' }); + expect(within(menu).getByRole('menuitem', { name: 'favorite.add' })).toHaveFocus(); + expect(insertLatex).not.toHaveBeenCalled(); + }); + + it('opens exactly one menu with Shift+F10, without inserting', () => { + const { insertLatex } = renderEditIconsTab(); + const button = screen.getByRole('button', { name: 'latexs.frac' }); + act(() => button.focus()); + fireEvent.keyDown(button, { key: 'F10', shiftKey: true }); + const menu = screen.getByRole('menu'); + const item = screen.getByRole('menuitem', { name: 'favorite.add' }); + expect(item).toHaveFocus(); + + // Some browsers also fire `contextmenu` for the key press, at the now focused item. + expect(fireEvent.contextMenu(item)).toBe(false); + + expect(screen.getAllByRole('menu')).toHaveLength(1); + expect(screen.getByRole('menu')).toBe(menu); + expect(insertLatex).not.toHaveBeenCalled(); + }); + + it('ignores F10 without Shift', () => { + renderEditIconsTab(); + fireEvent.keyDown(screen.getByRole('button', { name: 'latexs.frac' }), { key: 'F10' }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('returns focus to the template when the menu is dismissed with Escape', () => { + renderEditIconsTab(); + const button = rightClickTemplate('frac'); + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(button).toHaveFocus(); + expect(favoriteMathTemplatesStore.getIds()).toEqual([]); + }); + + it('appends the template, announces it and returns focus to the template', () => { + seedFavorites(['sqrt']); + const { insertLatex } = renderEditIconsTab(); + openCategory('common'); + const button = rightClickTemplate('frac'); + chooseAddFavorite(); + + expect(favoriteMathTemplatesStore.getIds()).toEqual(['sqrt', 'frac']); + expect(showToast.success).toHaveBeenCalledWith('favorite.added {"name":"latexs.frac"}'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(button).toHaveFocus(); + expect(insertLatex).not.toHaveBeenCalled(); + }); + + it('shows the duplicate alert for a template that is already a favorite', async () => { + seedFavorites(['sqrt', 'frac']); + renderEditIconsTab(); + openCategory('common'); + const button = rightClickTemplate('frac'); + chooseAddFavorite(); + + const alert = await screen.findByRole('alertdialog', { name: 'favorite.duplicateTitle' }); + expect(alert).toHaveTextContent('favorite.duplicateMessage'); + expect(showToast.success).not.toHaveBeenCalled(); + expect(favoriteMathTemplatesStore.getIds()).toEqual(['sqrt', 'frac']); + + const confirm = within(alert).getByRole('button', { name: 'favorite.confirm' }); + await waitFor(() => expect(confirm).toHaveFocus()); + fireEvent.click(confirm); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(button).toHaveFocus()); + }); + + it('shows the limit alert for an 11th template, even if it is a duplicate', async () => { + seedFavorites(TEN_IDS); + renderEditIconsTab(); + openCategory('geometry'); + const button = rightClickTemplate('angle'); + chooseAddFavorite(); + + const alert = await screen.findByRole('alertdialog', { name: 'favorite.limitReachedTitle' }); + expect(alert).toHaveTextContent('favorite.limitReachedMessage {"limit":10}'); + expect(showToast.success).not.toHaveBeenCalled(); + expect(favoriteMathTemplatesStore.getIds()).toEqual(TEN_IDS); + + const confirm = within(alert).getByRole('button', { name: 'favorite.confirm' }); + await waitFor(() => expect(confirm).toHaveFocus()); + fireEvent.keyDown(confirm, { + key: 'Escape', + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(button).toHaveFocus()); + + // A duplicate still hits the limit first. + openCategory('common'); + rightClickTemplate('frac'); + chooseAddFavorite(); + expect( + await screen.findByRole('alertdialog', { name: 'favorite.limitReachedTitle' }) + ).toBeInTheDocument(); + }); + + it('reports a failed save without changing the list', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError'); + }); + renderEditIconsTab(); + rightClickTemplate('frac'); + chooseAddFavorite(); + + expect(showToast.error).toHaveBeenCalledWith('favorite.saveFailed'); + expect(showToast.success).not.toHaveBeenCalled(); + expect(favoriteMathTemplatesStore.getIds()).toEqual([]); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('keeps the current category after adding a favorite', () => { + renderEditIconsTab(); + rightClickTemplate('frac'); + chooseAddFavorite(); + + expect(screen.getByRole('tab', { name: 'categorys.common' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('does not open the add menu on templates in the favorites category', () => { + seedFavorites(['frac']); + renderEditIconsTab(); + rightClickTemplate('frac'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/setupTests.js b/src/setupTests.js index fb7452c..fcf3e84 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -6,3 +6,13 @@ import '@testing-library/jest-dom/vitest'; // jest.* call behaves like its vi.* equivalent instead of throwing from a file // contributors never touch. vi.stubGlobal('jest', vi); + +// Headless UI's Dialog observes its panel size, and jsdom has no ResizeObserver. +vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } +); From 24b9c44e9ff9fba431ff000a171a29a7e0599bfd Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Tue, 20 Oct 2026 16:03:22 +0800 Subject: [PATCH 4/7] feat: add modal to reorder and remove favorite math templates A new "Edit favorites" toolbar button opens a modal that edits a draft of the favorites list with move up, move down and remove buttons; it is saved only on Done. Changes are announced inside the dialog, the success toast waits until the dialog has closed, and an empty list shows how to add favorites instead. The toolbar now wraps on narrow screens. Co-Authored-By: Claude Opus 5.5 --- public/locales/en/edit-favorites-modal.json | 18 ++ public/locales/en/home.json | 1 + .../locales/zh-TW/edit-favorites-modal.json | 18 ++ public/locales/zh-TW/home.json | 1 + src/components/core/modal/basic-modal.jsx | 12 +- .../home/edit-favorites-modal/index.jsx | 229 +++++++++++++++ .../home/edit-favorites-modal/index.test.jsx | 260 ++++++++++++++++++ .../favorite-math-templates/locales.test.js | 6 + src/pages/home/home.jsx | 21 +- 9 files changed, 561 insertions(+), 5 deletions(-) create mode 100644 public/locales/en/edit-favorites-modal.json create mode 100644 public/locales/zh-TW/edit-favorites-modal.json create mode 100644 src/components/home/edit-favorites-modal/index.jsx create mode 100644 src/components/home/edit-favorites-modal/index.test.jsx diff --git a/public/locales/en/edit-favorites-modal.json b/public/locales/en/edit-favorites-modal.json new file mode 100644 index 0000000..c50d77c --- /dev/null +++ b/public/locales/en/edit-favorites-modal.json @@ -0,0 +1,18 @@ +{ + "title": "Edit favorite symbols", + "listLabel": "Favorite symbols", + "moveUp": "Move {{name}} up", + "moveDown": "Move {{name}} down", + "remove": "Remove {{name}}", + "movedUp": "Moved {{name}} up, now {{position}} of {{total}}", + "movedDown": "Moved {{name}} down, now {{position}} of {{total}}", + "removed": "Removed {{name}}", + "noFavoritesLeft": "All favorite symbols removed. Select Done to save.", + "cancel": "Cancel", + "confirm": "Done", + "saved": "Favorite symbols updated", + "saveFailed": "Couldn't save favorites. Please try again.", + "emptyTitle": "No favorite symbols yet", + "emptyMessage": "Right-click a symbol (or press the context menu key or Shift+F10) to add it to favorites.", + "emptyConfirm": "OK" +} diff --git a/public/locales/en/home.json b/public/locales/en/home.json index 6d6b902..f5ba479 100644 --- a/public/locales/en/home.json +++ b/public/locales/en/home.json @@ -18,6 +18,7 @@ "setting": "Setting", "bracket2dollar": "{} to $", "dollar2bracket": "$ to {}", + "editFavorites": "Edit favorites", "pleaseInputTitle": "Please input file title", "latexDelimiter": { "name": "LaTeX delimiter", diff --git a/public/locales/zh-TW/edit-favorites-modal.json b/public/locales/zh-TW/edit-favorites-modal.json new file mode 100644 index 0000000..09f704a --- /dev/null +++ b/public/locales/zh-TW/edit-favorites-modal.json @@ -0,0 +1,18 @@ +{ + "title": "編輯常用符號", + "listLabel": "常用符號", + "moveUp": "上移 {{name}}", + "moveDown": "下移 {{name}}", + "remove": "刪除 {{name}}", + "movedUp": "{{name}} 已上移,目前第 {{position}} 個,共 {{total}} 個", + "movedDown": "{{name}} 已下移,目前第 {{position}} 個,共 {{total}} 個", + "removed": "已刪除 {{name}}", + "noFavoritesLeft": "已刪除所有常用符號,按「完成」儲存。", + "cancel": "取消", + "confirm": "完成", + "saved": "您已成功編輯常用符號", + "saveFailed": "無法儲存常用符號,請稍後再試", + "emptyTitle": "尚未建立任何常用符號", + "emptyMessage": "請先在欲建立的符號上按右鍵(或快顯鍵、Shift+F10)來加入常用符號", + "emptyConfirm": "確認" +} diff --git a/public/locales/zh-TW/home.json b/public/locales/zh-TW/home.json index f61c39f..cb18c09 100644 --- a/public/locales/zh-TW/home.json +++ b/public/locales/zh-TW/home.json @@ -11,6 +11,7 @@ "exportWebsite": "匯出網站", "bracket2dollar": "括號轉錢號", "dollar2bracket": "錢號轉括號", + "editFavorites": "編輯常用", "descript": "說明", "instructions": "教學", "preview": "轉換結果", diff --git a/src/components/core/modal/basic-modal.jsx b/src/components/core/modal/basic-modal.jsx index 34e9341..fef0f77 100644 --- a/src/components/core/modal/basic-modal.jsx +++ b/src/components/core/modal/basic-modal.jsx @@ -17,6 +17,8 @@ const BasicModal = ({ confirmLabel = 'Confirm', size = 'l', asForm = false, + initialFocus, + afterLeave, children, }) => { // When asForm, the body and footer live inside a
so Enter submits from any @@ -35,8 +37,12 @@ const BasicModal = ({ : {}; return ( - - + +
{/* background layer */}
@@ -102,6 +108,8 @@ BasicModal.propTypes = { hasConfirm: PropTypes.bool, size: PropTypes.oneOf(['sm', 'l']), asForm: PropTypes.bool, + initialFocus: PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + afterLeave: PropTypes.func, }; export default BasicModal; diff --git a/src/components/home/edit-favorites-modal/index.jsx b/src/components/home/edit-favorites-modal/index.jsx new file mode 100644 index 0000000..9fecaf7 --- /dev/null +++ b/src/components/home/edit-favorites-modal/index.jsx @@ -0,0 +1,229 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import { IconArrowUp, IconArrowDown, IconTrash } from '@tabler/icons-react'; + +import { useTranslation } from '@/lib/i18n'; +import BasicModal from '@/components/core/modal/basic-modal'; +import AlertModal from '@/components/core/modal/alert-modal'; +import { showToast } from '@/components/core/toast/service'; +import useFavoriteMathTemplates from '@/lib/favorite-math-templates/use-favorite-math-templates'; +import { mathTemplateIndex } from '@/lib/favorite-math-templates/template-index'; + +const Action = { + MOVE_UP: 'moveUp', + MOVE_DOWN: 'moveDown', + REMOVE: 'remove', +}; + +const ActionButton = React.forwardRef(({ label, Icon, disabled, onClick }, ref) => ( + +)); + +ActionButton.displayName = 'ActionButton'; + +ActionButton.propTypes = { + label: PropTypes.string.isRequired, + Icon: PropTypes.elementType.isRequired, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired, +}; + +/** + * Reorders and removes favorite math templates on a draft that is saved only on confirm. + * Remount it (e.g. with a new `key`) for every opening, so the draft starts from the saved list. + */ +const EditFavoritesModal = ({ isOpen, onClose }) => { + const t = useTranslation('edit-favorites-modal'); + const tTabs = useTranslation('tabs'); + const { favoriteIds, saveFavorites } = useFavoriteMathTemplates(); + const [draftIds, setDraftIds] = useState(favoriteIds); + // Decided once per opening, so removing every row does not swap the dialog for the empty alert. + const [hasFavorites] = useState(favoriteIds.length > 0); + const [announcement, setAnnouncement] = useState(''); + // Counts failed saves in a row; each failure remounts the alert so it is announced again. + const [saveFailureCount, setSaveFailureCount] = useState(0); + const [focusTarget, setFocusTarget] = useState(null); + const actionRefs = useRef({}); + const initialFocusRef = useRef(null); + const noFavoritesLeftRef = useRef(null); + const isSavedRef = useRef(false); + + // Start on the first usable button rather than the first row's disabled ↑. + const initialFocusAction = draftIds.length > 1 ? Action.MOVE_DOWN : Action.REMOVE; + + const getName = (id) => tTabs(`latexs.${id}`); + + // Rows are keyed by id, so React may move the focused button's row; focus it again after render. + useEffect(() => { + if (!focusTarget) { + return; + } + const target = focusTarget.id + ? actionRefs.current[`${focusTarget.id}:${focusTarget.action}`] + : noFavoritesLeftRef.current; + target?.focus(); + }, [focusTarget]); + + const move = (index, offset) => { + const targetIndex = index + offset; + if (targetIndex < 0 || targetIndex >= draftIds.length) { + return; + } + const id = draftIds[index]; + const nextIds = [...draftIds]; + [nextIds[index], nextIds[targetIndex]] = [nextIds[targetIndex], nextIds[index]]; + setDraftIds(nextIds); + setSaveFailureCount(0); + setAnnouncement( + t(offset < 0 ? 'movedUp' : 'movedDown', { + name: getName(id), + position: targetIndex + 1, + total: nextIds.length, + }) + ); + setFocusTarget({ id, action: offset < 0 ? Action.MOVE_UP : Action.MOVE_DOWN }); + }; + + const remove = (index) => { + const id = draftIds[index]; + const nextIds = draftIds.filter((_, i) => i !== index); + setDraftIds(nextIds); + setSaveFailureCount(0); + setAnnouncement(t('removed', { name: getName(id) })); + const nextFocusId = nextIds[index] ?? nextIds[index - 1] ?? null; + setFocusTarget({ id: nextFocusId, action: Action.REMOVE }); + }; + + const handleConfirm = () => { + if (!saveFavorites(draftIds).ok) { + setSaveFailureCount((count) => count + 1); + return; + } + isSavedRef.current = true; + onClose(); + }; + + // While the dialog is open the rest of the page, including the toaster, is hidden from + // assistive technology, so the success toast waits until the dialog is gone. + const handleAfterLeave = () => { + if (isSavedRef.current) { + isSavedRef.current = false; + showToast.success(t('saved')); + } + }; + + if (!hasFavorites) { + return ( + + ); + } + + return ( + +
+ {announcement} +
+ {draftIds.length > 0 ? ( +
    + {draftIds.map((id, index) => { + const template = mathTemplateIndex.get(id); + const name = getName(id); + const setActionRef = (action) => (node) => { + actionRefs.current[`${id}:${action}`] = node; + if (index === 0 && action === initialFocusAction) { + initialFocusRef.current = node; + } + }; + return ( +
  • + + +
    + move(index, -1)} + /> + move(index, 1)} + /> + remove(index)} + /> +
    +
  • + ); + })} +
+ ) : ( +

+ {t('noFavoritesLeft')} +

+ )} + {saveFailureCount > 0 && ( +

+ {t('saveFailed')} +

+ )} +
+ ); +}; + +EditFavoritesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default EditFavoritesModal; diff --git a/src/components/home/edit-favorites-modal/index.test.jsx b/src/components/home/edit-favorites-modal/index.test.jsx new file mode 100644 index 0000000..340634c --- /dev/null +++ b/src/components/home/edit-favorites-modal/index.test.jsx @@ -0,0 +1,260 @@ +import { vi } from 'vitest'; +import React, { useState } from 'react'; +import { act, render, screen, fireEvent, waitFor, within } from '@testing-library/react'; + +import EditFavoritesModal from '.'; +import { showToast } from '@/components/core/toast/service'; +import { + favoriteMathTemplatesStore, + FAVORITE_MATH_TEMPLATE_STORAGE_KEY, +} from '@/lib/favorite-math-templates/store'; + +// Echo the key, plus interpolation values when given, so tests can check both. +vi.mock('@/lib/i18n', () => ({ + useTranslation: () => (key, options) => (options ? `${key} ${JSON.stringify(options)}` : key), +})); + +vi.mock('@/components/core/toast/service', () => ({ + showToast: { success: vi.fn(), error: vi.fn() }, +})); + +// Opens the modal the way the home page does: a new key per opening. +const Harness = () => { + const [modal, setModal] = useState({ isOpen: false, key: 0 }); + return ( + <> + + setModal((current) => ({ ...current, isOpen: false }))} + /> + + ); +}; + +const seedFavorites = (ids) => { + localStorage.setItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY, JSON.stringify(ids)); +}; + +const openModal = async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'open' })); + return screen.findByRole('dialog', { name: 'title' }); +}; + +const rowNames = () => + within(screen.getByRole('list', { name: 'listLabel' })) + .getAllByRole('img') + .map((img) => img.getAttribute('aria-label')); + +// The live region must sit inside the dialog: the rest of the page is inert while it is open. +const liveRegion = () => within(screen.getByRole('dialog', { name: 'title' })).getByRole('status'); + +// Headless UI skips its leave transition, and so `afterLeave`, for elements without a size, +// which is every element in jsdom. +const giveElementsASize = () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + }); +}; + +const actionButton = (action, id) => + screen.getByRole('button', { name: `${action} {"name":"latexs.${id}"}` }); + +describe('', () => { + beforeEach(() => { + localStorage.clear(); + favoriteMathTemplatesStore.reset(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('without favorites', () => { + it('explains how to add favorites instead of opening the editor', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'open' })); + + const alert = await screen.findByRole('alertdialog', { name: 'emptyTitle' }); + expect(alert).toHaveTextContent('emptyMessage'); + expect(screen.queryByRole('dialog', { name: 'title' })).not.toBeInTheDocument(); + + fireEvent.click(within(alert).getByRole('button', { name: 'emptyConfirm' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toHaveFocus()); + }); + }); + + describe('with favorites', () => { + beforeEach(() => { + seedFavorites(['frac', 'sqrt', 'arc']); + }); + + it('starts on the first usable button instead of the disabled first ↑', async () => { + await openModal(); + await waitFor(() => expect(actionButton('moveDown', 'frac')).toHaveFocus()); + }); + + it('lists the favorites in order and disables moving past either end', async () => { + await openModal(); + + expect(rowNames()).toEqual(['latexs.frac', 'latexs.sqrt', 'latexs.arc']); + expect(actionButton('moveUp', 'frac')).toHaveAttribute('aria-disabled', 'true'); + expect(actionButton('moveDown', 'frac')).not.toHaveAttribute('aria-disabled'); + expect(actionButton('moveUp', 'arc')).not.toHaveAttribute('aria-disabled'); + expect(actionButton('moveDown', 'arc')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('moves a favorite in the draft, announces it and keeps focus on it', async () => { + await openModal(); + fireEvent.click(actionButton('moveDown', 'frac')); + + expect(rowNames()).toEqual(['latexs.sqrt', 'latexs.frac', 'latexs.arc']); + expect(liveRegion()).toHaveTextContent( + 'movedDown {"name":"latexs.frac","position":2,"total":3}' + ); + expect(actionButton('moveDown', 'frac')).toHaveFocus(); + // Nothing is saved until "confirm". + expect(favoriteMathTemplatesStore.getIds()).toEqual(['frac', 'sqrt', 'arc']); + + fireEvent.click(actionButton('moveUp', 'arc')); + expect(rowNames()).toEqual(['latexs.sqrt', 'latexs.arc', 'latexs.frac']); + expect(liveRegion()).toHaveTextContent( + 'movedUp {"name":"latexs.arc","position":2,"total":3}' + ); + expect(actionButton('moveUp', 'arc')).toHaveFocus(); + }); + + it('keeps focus on a button that becomes disabled at an end, and ignores it there', async () => { + await openModal(); + fireEvent.click(actionButton('moveUp', 'sqrt')); + + expect(rowNames()).toEqual(['latexs.sqrt', 'latexs.frac', 'latexs.arc']); + expect(actionButton('moveUp', 'sqrt')).toHaveFocus(); + expect(actionButton('moveUp', 'sqrt')).toHaveAttribute('aria-disabled', 'true'); + + fireEvent.click(actionButton('moveUp', 'sqrt')); + expect(rowNames()).toEqual(['latexs.sqrt', 'latexs.frac', 'latexs.arc']); + }); + + it('removes a favorite and moves focus to the next row, or the previous one at the end', async () => { + await openModal(); + fireEvent.click(actionButton('remove', 'sqrt')); + + expect(rowNames()).toEqual(['latexs.frac', 'latexs.arc']); + expect(liveRegion()).toHaveTextContent('removed {"name":"latexs.sqrt"}'); + expect(actionButton('remove', 'arc')).toHaveFocus(); + + fireEvent.click(actionButton('remove', 'arc')); + expect(actionButton('remove', 'frac')).toHaveFocus(); + }); + + it('focuses a message once every favorite is removed', async () => { + await openModal(); + fireEvent.click(actionButton('remove', 'frac')); + fireEvent.click(actionButton('remove', 'sqrt')); + fireEvent.click(actionButton('remove', 'arc')); + + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.getByText('noFavoritesLeft')).toHaveFocus(); + expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument(); + }); + + it('saves the draft on confirm, then announces it once the dialog has closed', async () => { + await openModal(); + fireEvent.click(actionButton('remove', 'sqrt')); + fireEvent.click(actionButton('moveUp', 'arc')); + giveElementsASize(); + fireEvent.click(screen.getByRole('button', { name: 'confirm' })); + + expect(favoriteMathTemplatesStore.getIds()).toEqual(['arc', 'frac']); + expect(JSON.parse(localStorage.getItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY))).toEqual([ + 'arc', + 'frac', + ]); + // Not while the dialog still hides the toaster from assistive technology. + expect(showToast.success).not.toHaveBeenCalled(); + + await waitFor(() => expect(showToast.success).toHaveBeenCalledWith('saved')); + expect(showToast.success).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toHaveFocus()); + }); + + it.each([ + ['cancel', () => fireEvent.click(screen.getByRole('button', { name: 'cancel' }))], + [ + 'Escape', + () => fireEvent.keyDown(screen.getByRole('dialog', { name: 'title' }), { key: 'Escape' }), + ], + ])('discards the draft on %s', async (_, close) => { + await openModal(); + fireEvent.click(actionButton('remove', 'frac')); + giveElementsASize(); + close(); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + // Give `afterLeave` the same chance it gets in the saving test. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(favoriteMathTemplatesStore.getIds()).toEqual(['frac', 'sqrt', 'arc']); + expect(showToast.success).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + + // The next opening starts again from the saved list. + fireEvent.click(screen.getByRole('button', { name: 'open' })); + await screen.findByRole('dialog', { name: 'title' }); + expect(rowNames()).toEqual(['latexs.frac', 'latexs.sqrt', 'latexs.arc']); + }); + + it('stays open and reports a failed save without changing the saved list', async () => { + await openModal(); + fireEvent.click(actionButton('remove', 'frac')); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError'); + }); + fireEvent.click(screen.getByRole('button', { name: 'confirm' })); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('saveFailed'); + expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument(); + expect(favoriteMathTemplatesStore.getIds()).toEqual(['frac', 'sqrt', 'arc']); + expect(showToast.success).not.toHaveBeenCalled(); + + // A retry that fails again is announced again, as a new alert. + fireEvent.click(screen.getByRole('button', { name: 'confirm' })); + expect(screen.getByRole('alert')).not.toBe(alert); + + // Editing again clears the error. + fireEvent.click(actionButton('remove', 'sqrt')); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('starts on the remove button when there is only one favorite', async () => { + seedFavorites(['frac']); + await openModal(); + await waitFor(() => expect(actionButton('remove', 'frac')).toHaveFocus()); + }); + + it('keeps showing the editor after a favorite is removed elsewhere while it is open', async () => { + await openModal(); + act(() => { + favoriteMathTemplatesStore.saveFavorites([]); + }); + + expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument(); + expect(rowNames()).toEqual(['latexs.frac', 'latexs.sqrt', 'latexs.arc']); + }); + }); +}); diff --git a/src/lib/favorite-math-templates/locales.test.js b/src/lib/favorite-math-templates/locales.test.js index e03cff1..12aa2f9 100644 --- a/src/lib/favorite-math-templates/locales.test.js +++ b/src/lib/favorite-math-templates/locales.test.js @@ -1,5 +1,9 @@ import zhTabs from '@/../public/locales/zh-TW/tabs.json'; import enTabs from '@/../public/locales/en/tabs.json'; +import zhHome from '@/../public/locales/zh-TW/home.json'; +import enHome from '@/../public/locales/en/home.json'; +import zhEditFavoritesModal from '@/../public/locales/zh-TW/edit-favorites-modal.json'; +import enEditFavoritesModal from '@/../public/locales/en/edit-favorites-modal.json'; const keyPaths = (value, prefix = '') => value !== null && typeof value === 'object' @@ -13,6 +17,8 @@ describe('favorite math templates copy', () => { it.each([ ['tabs.json favorite', zhTabs.favorite, enTabs.favorite], ['tabs.json categorys.favorite', zhTabs.categorys.favorite, enTabs.categorys.favorite], + ['home.json editFavorites', zhHome.editFavorites, enHome.editFavorites], + ['edit-favorites-modal.json', zhEditFavoritesModal, enEditFavoritesModal], ])('has the same keys in zh-TW and en: %s', (_, zh, en) => { expect(zh).toBeDefined(); expect(en).toBeDefined(); diff --git a/src/pages/home/home.jsx b/src/pages/home/home.jsx index cab0201..b4b787d 100644 --- a/src/pages/home/home.jsx +++ b/src/pages/home/home.jsx @@ -29,6 +29,7 @@ import SegmentedControl from '@/components/core/button/segmented-control'; import EditIconsTab from '@/components/edit-icons-tab'; import SettingModal from '@/components/home/setting-modal'; import ConvertHintModal from '@/components/home/convert-hint-modal'; +import EditFavoritesModal from '@/components/home/edit-favorites-modal'; import { useDisplayConfig, ExportType, LatexDelimiter, DocumentColor } from '@/lib/display-config'; import { importSource } from '@/lib/import-source'; @@ -42,6 +43,8 @@ export default function Home() { const [data, setData] = useState(''); const [showSettingModal, setShowSettingModal] = useState(false); const [showConvertHintModal, setShowConvertHintModal] = useState(false); + // A new key per opening remounts the modal, so its draft starts from the saved favorites. + const [editFavoritesModal, setEditFavoritesModal] = useState({ isOpen: false, key: 0 }); const [exportType, setExportType] = useState(ExportType.ZIP); const [imageFiles, setImageFiles] = useState({}); @@ -325,9 +328,10 @@ export default function Home() {
{/* Left side input panel */}
-
-

{t('editContent')}

-
+ {/* Wraps on narrow screens instead of squeezing the toolbar. */} +
+

{t('editContent')}

+
+
@@ -443,6 +453,11 @@ export default function Home() { LatexDelimiter={LatexDelimiter} data={data} /> + setEditFavoritesModal((modal) => ({ ...modal, isOpen: false }))} + />
); From d2caf8568125c39cea721821b9bf2263143e6389 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Mon, 26 Oct 2026 20:41:59 +0800 Subject: [PATCH 5/7] style: size the context menu to its items like a tooltip Drop the minimum width and inner padding so a single item reads as one dark label with even padding, matching the design. The item highlights on hover and keyboard focus instead of on any focus. Co-Authored-By: Claude Opus 5.5 --- src/components/core/context-menu.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/core/context-menu.jsx b/src/components/core/context-menu.jsx index 390c286..e8b0a5f 100644 --- a/src/components/core/context-menu.jsx +++ b/src/components/core/context-menu.jsx @@ -153,12 +153,13 @@ const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { ref={menuRef} role="menu" aria-label={label} - className="fixed z-50 min-w-[96px] py-1 rounded bg-[#1A1A1A99] text-white text-sm leading-[1.4]" + // Sized to its items and styled like Tooltip, so a single item reads as one dark label. + className="fixed z-50 overflow-hidden rounded bg-[#1A1A1A99] text-white text-sm leading-[1.4]" style={{ left: `${placement.x}px`, top: `${placement.y}px` }} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onBlur={handleBlur} - // Keep focus on the active item when pressing the menu's padding. + // Keep focus on the active item while pressing inside the menu. onMouseDown={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()} > @@ -171,7 +172,7 @@ const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { type="button" role="menuitem" tabIndex={index === activeIndex ? 0 : -1} - className="block w-full px-3 py-1 text-left whitespace-nowrap outline-none focus:bg-[#1A1A1A]" + className="block w-full px-3 py-1 text-left whitespace-nowrap outline-none hover:bg-[#1A1A1A] focus-visible:bg-[#1A1A1A]" onClick={() => select(index)} onMouseEnter={() => setActiveIndex(index)} > From 1a4cf2bbe0caf529b333353985c58d9a8d9f26d8 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Mon, 26 Oct 2026 20:58:10 +0800 Subject: [PATCH 6/7] chore: trim and tighten favorites comments Remove comments that restate the code or a test name, and rewrite the rest in plain, active sentences. Co-Authored-By: Claude Opus 5.5 --- src/components/core/context-menu.jsx | 21 +++++++++---------- src/components/core/modal/alert-modal.jsx | 3 +-- src/components/edit-icons-tab.jsx | 2 +- src/components/edit-icons-tab.test.jsx | 1 - .../home/edit-favorites-modal/index.jsx | 15 ++++++------- .../home/edit-favorites-modal/index.test.jsx | 13 ++++++------ .../favorite-math-templates/locales.test.js | 3 ++- src/lib/favorite-math-templates/store.js | 20 +++++++----------- src/lib/favorite-math-templates/store.test.js | 4 ++-- .../favorite-math-templates/template-index.js | 6 +----- src/pages/home/home.jsx | 1 - 11 files changed, 40 insertions(+), 49 deletions(-) diff --git a/src/components/core/context-menu.jsx b/src/components/core/context-menu.jsx index e8b0a5f..2404c8c 100644 --- a/src/components/core/context-menu.jsx +++ b/src/components/core/context-menu.jsx @@ -6,16 +6,14 @@ const VIEWPORT_MARGIN = 8; const TRIGGER_GAP = 4; /** - * Where to open a context menu for a `contextmenu` or keyboard event on `event.currentTarget`. - * Pointer events open at the pointer; keyboard-triggered ones open just below the trigger. - * @param {MouseEvent | KeyboardEvent} event - * @returns {{ x: number, y: number }} + * Pointer events open the menu at the pointer. Keyboard-triggered ones open it just below + * `event.currentTarget`. */ export const getContextMenuPosition = (event) => { const rect = event.currentTarget.getBoundingClientRect(); const { clientX, clientY } = event; - // Keyboard-triggered `contextmenu` events report the trigger's centre (or 0,0) as their - // position. Browsers that dispatch them as PointerEvents mark them with an empty pointerType. + // Keyboard-triggered `contextmenu` events report the trigger's centre or 0,0 as their + // position. Browsers that dispatch them as PointerEvents give them an empty pointerType. const isKeyboard = (event.nativeEvent ?? event).pointerType === ''; const isPointerOnTrigger = !isKeyboard && @@ -32,8 +30,8 @@ export const getContextMenuPosition = (event) => { }; /** - * A menu (menu/menuitem pattern) shown at a viewport position, e.g. on right click. - * Mount it to open it; it calls `onClose` when it should be unmounted. + * A context menu that follows the WAI-ARIA menu pattern. Mounting it opens it, and it calls + * `onClose` when the parent should unmount it. */ const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { const menuRef = useRef(null); @@ -41,7 +39,8 @@ const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { const [activeIndex, setActiveIndex] = useState(0); const [placement, setPlacement] = useState(position); - // preventScroll: scrolling the template panel under the pointer would make the next click miss. + // Without preventScroll the template panel could scroll under the pointer and the next click + // would miss. const returnFocus = useCallback(() => { returnFocusTo?.focus({ preventScroll: true }); }, [returnFocusTo]); @@ -60,7 +59,6 @@ const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { items[index].onSelect(); }; - // Keep the menu inside the viewport. useLayoutEffect(() => { const { width, height } = menuRef.current.getBoundingClientRect(); setPlacement({ @@ -141,7 +139,8 @@ const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => { } }; - // Focus moving elsewhere without a press on the page, e.g. into an iframe or another app. + // Closes the menu when focus leaves without a press on the page, for example into an iframe + // or another app. const handleBlur = (event) => { if (!menuRef.current?.contains(event.relatedTarget)) { onClose(); diff --git a/src/components/core/modal/alert-modal.jsx b/src/components/core/modal/alert-modal.jsx index 6a9c78f..fa465f8 100644 --- a/src/components/core/modal/alert-modal.jsx +++ b/src/components/core/modal/alert-modal.jsx @@ -6,7 +6,7 @@ import { IconAlertTriangle } from '@tabler/icons-react'; import Button from '@/components/core/button'; /** - * A blocking message with a single confirm button (alertdialog pattern). + * A blocking message with one confirm button, following the alertdialog pattern. * `message` may contain line breaks. */ const AlertModal = ({ isOpen, title, message, confirmLabel, onClose }) => { @@ -21,7 +21,6 @@ const AlertModal = ({ isOpen, title, message, confirmLabel, onClose }) => { onClose={onClose} >
- {/* background layer */}
{ const handleTemplateKeyDown = useCallback( (event, template) => { - // Not every browser maps Shift+F10 to a `contextmenu` event (e.g. Chrome on macOS). + // Chrome on macOS, for one, does not turn Shift+F10 into a `contextmenu` event. if (event.key === 'F10' && event.shiftKey) { openContextMenu(event, template); } diff --git a/src/components/edit-icons-tab.test.jsx b/src/components/edit-icons-tab.test.jsx index 7d086f3..93b9b60 100644 --- a/src/components/edit-icons-tab.test.jsx +++ b/src/components/edit-icons-tab.test.jsx @@ -277,7 +277,6 @@ describe('', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); await waitFor(() => expect(button).toHaveFocus()); - // A duplicate still hits the limit first. openCategory('common'); rightClickTemplate('frac'); chooseAddFavorite(); diff --git a/src/components/home/edit-favorites-modal/index.jsx b/src/components/home/edit-favorites-modal/index.jsx index 9fecaf7..6c8a38b 100644 --- a/src/components/home/edit-favorites-modal/index.jsx +++ b/src/components/home/edit-favorites-modal/index.jsx @@ -21,7 +21,7 @@ const ActionButton = React.forwardRef(({ label, Icon, disabled, onClick }, ref) ref={ref} type="button" aria-label={label} - // aria-disabled instead of disabled, so the button keeps focus after moving to an end. + // Uses aria-disabled instead of disabled so the button keeps focus once its row reaches an end. aria-disabled={disabled || undefined} className={cn( 'p-2 rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary', @@ -43,18 +43,19 @@ ActionButton.propTypes = { }; /** - * Reorders and removes favorite math templates on a draft that is saved only on confirm. - * Remount it (e.g. with a new `key`) for every opening, so the draft starts from the saved list. + * Reorders and removes favorite math templates in a draft and saves the draft only on confirm. + * Give it a new `key` for every opening so the draft starts from the saved list. */ const EditFavoritesModal = ({ isOpen, onClose }) => { const t = useTranslation('edit-favorites-modal'); const tTabs = useTranslation('tabs'); const { favoriteIds, saveFavorites } = useFavoriteMathTemplates(); const [draftIds, setDraftIds] = useState(favoriteIds); - // Decided once per opening, so removing every row does not swap the dialog for the empty alert. + // Fixed at opening so that removing every row does not swap the dialog for the empty alert. const [hasFavorites] = useState(favoriteIds.length > 0); const [announcement, setAnnouncement] = useState(''); - // Counts failed saves in a row; each failure remounts the alert so it is announced again. + // Counts failed saves in a row. Each failure remounts the alert so screen readers announce it + // again. const [saveFailureCount, setSaveFailureCount] = useState(0); const [focusTarget, setFocusTarget] = useState(null); const actionRefs = useRef({}); @@ -117,8 +118,8 @@ const EditFavoritesModal = ({ isOpen, onClose }) => { onClose(); }; - // While the dialog is open the rest of the page, including the toaster, is hidden from - // assistive technology, so the success toast waits until the dialog is gone. + // While the dialog is open, Headless UI hides the rest of the page, toaster included, from + // assistive technology. The success toast waits until the dialog is gone. const handleAfterLeave = () => { if (isSavedRef.current) { isSavedRef.current = false; diff --git a/src/components/home/edit-favorites-modal/index.test.jsx b/src/components/home/edit-favorites-modal/index.test.jsx index 340634c..0d76f0b 100644 --- a/src/components/home/edit-favorites-modal/index.test.jsx +++ b/src/components/home/edit-favorites-modal/index.test.jsx @@ -18,7 +18,7 @@ vi.mock('@/components/core/toast/service', () => ({ showToast: { success: vi.fn(), error: vi.fn() }, })); -// Opens the modal the way the home page does: a new key per opening. +// Opens the modal like the home page does, with a new key per opening. const Harness = () => { const [modal, setModal] = useState({ isOpen: false, key: 0 }); return ( @@ -50,11 +50,12 @@ const rowNames = () => .getAllByRole('img') .map((img) => img.getAttribute('aria-label')); -// The live region must sit inside the dialog: the rest of the page is inert while it is open. +// The live region must sit inside the dialog, because the rest of the page is inert while the +// dialog is open. const liveRegion = () => within(screen.getByRole('dialog', { name: 'title' })).getByRole('status'); -// Headless UI skips its leave transition, and so `afterLeave`, for elements without a size, -// which is every element in jsdom. +// Headless UI skips the leave transition, and with it `afterLeave`, for elements without a +// size. In jsdom no element has a size. const giveElementsASize = () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ x: 0, @@ -184,7 +185,7 @@ describe('', () => { 'arc', 'frac', ]); - // Not while the dialog still hides the toaster from assistive technology. + // The toast waits while the dialog still hides the toaster from assistive technology. expect(showToast.success).not.toHaveBeenCalled(); await waitFor(() => expect(showToast.success).toHaveBeenCalledWith('saved')); @@ -232,7 +233,7 @@ describe('', () => { expect(favoriteMathTemplatesStore.getIds()).toEqual(['frac', 'sqrt', 'arc']); expect(showToast.success).not.toHaveBeenCalled(); - // A retry that fails again is announced again, as a new alert. + // A second failed save renders a new alert, so screen readers announce it again. fireEvent.click(screen.getByRole('button', { name: 'confirm' })); expect(screen.getByRole('alert')).not.toBe(alert); diff --git a/src/lib/favorite-math-templates/locales.test.js b/src/lib/favorite-math-templates/locales.test.js index 12aa2f9..2772344 100644 --- a/src/lib/favorite-math-templates/locales.test.js +++ b/src/lib/favorite-math-templates/locales.test.js @@ -12,7 +12,8 @@ const keyPaths = (value, prefix = '') => ) : [prefix]; -// Only the favorites copy is checked: some existing namespaces already differ between locales. +// This checks only the favorites copy, because some existing namespaces already differ +// between locales. describe('favorite math templates copy', () => { it.each([ ['tabs.json favorite', zhTabs.favorite, enTabs.favorite], diff --git a/src/lib/favorite-math-templates/store.js b/src/lib/favorite-math-templates/store.js index 4965593..4f25dcc 100644 --- a/src/lib/favorite-math-templates/store.js +++ b/src/lib/favorite-math-templates/store.js @@ -32,8 +32,8 @@ const getLocalStorage = () => { }; /** - * Turns a raw stored value into a list that upholds every invariant: - * known template ids only, no duplicates, at most FAVORITE_MATH_TEMPLATE_LIMIT. + * Turns a raw stored value into a clean list: known template ids only, no duplicates, + * at most FAVORITE_MATH_TEMPLATE_LIMIT. * @param {string | null} raw * @returns {string[]} */ @@ -62,7 +62,7 @@ const readStoredIds = () => { const isValidIdList = (ids) => Array.isArray(ids) && ids.length <= FAVORITE_MATH_TEMPLATE_LIMIT && - // Spread so holes in a sparse array are checked as `undefined` instead of skipped. + // Spread so `every` sees holes in a sparse array as `undefined` instead of skipping them. [...ids].every((id) => typeof id === 'string' && mathTemplateIndex.has(id)) && new Set(ids).size === ids.length; @@ -81,7 +81,7 @@ const createFavoriteMathTemplatesStore = () => { } }; - // Without subscribers no `storage` events are received, so the cached list may be stale. + // Without subscribers the store does not listen for `storage` events, so its cache may be stale. const getIds = () => { if (store.getState() === null || subscriptions.size === 0) { setIds(readStoredIds()); @@ -108,18 +108,14 @@ const createFavoriteMathTemplatesStore = () => { if (event.key !== null && event.key !== FAVORITE_MATH_TEMPLATE_STORAGE_KEY) { return; } - // Read the current value rather than `event.newValue`: events arrive asynchronously and - // this tab may have written again since. + // Read the current value instead of `event.newValue`, because the event arrives + // asynchronously and this tab may have written since. setIds(readStoredIds()); }; return { getIds, - /** - * @param {(ids: string[]) => void} callback - * @returns {() => void} Unsubscribe function. - */ subscribe(callback) { // Load before registering, so the lazy first read never notifies anyone. getIds(); @@ -143,7 +139,7 @@ const createFavoriteMathTemplatesStore = () => { }, /** - * Appends a template to the end of the list. The limit is checked before duplicates. + * Appends a template to the end of the list. Checks the limit before duplicates. * @param {string} id * @returns {FavoriteOperationResult} */ @@ -162,7 +158,7 @@ const createFavoriteMathTemplatesStore = () => { }, /** - * Replaces the whole list, e.g. after reordering or removing in the edit modal. + * Replaces the whole list, for example after edits in the edit modal. * @param {string[]} ids * @returns {FavoriteOperationResult} */ diff --git a/src/lib/favorite-math-templates/store.test.js b/src/lib/favorite-math-templates/store.test.js index 71fc6a7..667ade6 100644 --- a/src/lib/favorite-math-templates/store.test.js +++ b/src/lib/favorite-math-templates/store.test.js @@ -242,8 +242,8 @@ describe('favoriteMathTemplatesStore', () => { }); describe('sync from other tabs', () => { - // jsdom does not fire `storage` in the writing document, so another tab's write is - // simulated by changing localStorage and then dispatching the event by hand. + // jsdom does not fire `storage` in the document that wrote, so these tests simulate another + // tab by changing localStorage and dispatching the event by hand. const writeFromOtherTab = (value) => { seed(value); dispatchStorageEvent({ key: KEY }); diff --git a/src/lib/favorite-math-templates/template-index.js b/src/lib/favorite-math-templates/template-index.js index a06d235..5ac86b5 100644 --- a/src/lib/favorite-math-templates/template-index.js +++ b/src/lib/favorite-math-templates/template-index.js @@ -1,10 +1,6 @@ import mathTabList from '@/lib/tabs/math'; -/** - * Lookup of every math template by id, built from the static `mathTabList`. - * Favorites persist only ids, so ids must stay globally unique across categories. - * @type {Map} - */ +// Favorites persist only ids, so ids must stay unique across all categories. export const mathTemplateIndex = new Map( mathTabList.flatMap((tab) => tab.subTabs.map((template) => [template.id, template])) ); diff --git a/src/pages/home/home.jsx b/src/pages/home/home.jsx index b4b787d..69d838d 100644 --- a/src/pages/home/home.jsx +++ b/src/pages/home/home.jsx @@ -328,7 +328,6 @@ export default function Home() {
{/* Left side input panel */}
- {/* Wraps on narrow screens instead of squeezing the toolbar. */}

{t('editContent')}

From fb8852627aec5bbb286927060fc78cb3d43aef95 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Fri, 6 Nov 2026 08:17:57 +0800 Subject: [PATCH 7/7] fix: close the edit favorites modal without saving an unedited draft Pressing Done without any change saved the list the modal opened with, which silently reverted changes another tab made in the meantime. Done now just closes the modal when the draft still matches the list at opening, and skips the "saved" toast because nothing was saved. Co-Authored-By: Claude Opus 5.5 --- .../home/edit-favorites-modal/index.jsx | 14 ++++++++++++-- .../home/edit-favorites-modal/index.test.jsx | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/home/edit-favorites-modal/index.jsx b/src/components/home/edit-favorites-modal/index.jsx index 6c8a38b..64798a9 100644 --- a/src/components/home/edit-favorites-modal/index.jsx +++ b/src/components/home/edit-favorites-modal/index.jsx @@ -16,6 +16,8 @@ const Action = { REMOVE: 'remove', }; +const isSameList = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]); + const ActionButton = React.forwardRef(({ label, Icon, disabled, onClick }, ref) => (