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/en/tabs.json b/public/locales/en/tabs.json index d758166..e8ede0b 100644 --- a/public/locales/en/tabs.json +++ b/public/locales/en/tabs.json @@ -22,7 +22,19 @@ "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.", + "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", "common": "Common", "operator": "Operator", "relation": "Relation", 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/public/locales/zh-TW/tabs.json b/public/locales/zh-TW/tabs.json index d88f9b7..b6f063c 100644 --- a/public/locales/zh-TW/tabs.json +++ b/public/locales/zh-TW/tabs.json @@ -21,7 +21,19 @@ "quote": "引用", "table": "表格" }, + "favorite": { + "empty": "尚未建立常用符號。在符號上按右鍵(或快顯鍵、Shift+F10)即可加入常用。", + "add": "加入常用", + "added": "您已成功將 {{name}} 加入至常用符號", + "limitReachedTitle": "常用符號已超過上限", + "limitReachedMessage": "最多只能有 {{limit}} 個常用符號\n請刪除其他符號以加入新的符號", + "duplicateTitle": "常用符號已重複", + "duplicateMessage": "請再檢查一次常用符號列", + "confirm": "確認", + "saveFailed": "無法儲存常用符號,請稍後再試" + }, "categorys": { + "favorite": "常用符號", "common": "常見", "operator": "運算子", "relation": "關係", diff --git a/src/components/core/context-menu.jsx b/src/components/core/context-menu.jsx new file mode 100644 index 0000000..2404c8c --- /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; + +/** + * 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 give them 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 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); + const itemRefs = useRef([]); + const [activeIndex, setActiveIndex] = useState(0); + const [placement, setPlacement] = useState(position); + + // Without preventScroll the template panel could scroll under the pointer and the next click + // would 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(); + }; + + 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); + } + }; + + // 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(); + } + }; + + 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..fa465f8 --- /dev/null +++ b/src/components/core/modal/alert-modal.jsx @@ -0,0 +1,63 @@ +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 one confirm button, following the alertdialog pattern. + * `message` may contain line breaks. + */ +const AlertModal = ({ isOpen, title, message, confirmLabel, onClose }) => { + const confirmButtonRef = useRef(null); + + return ( + + +
+
+ + + + {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/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/edit-icons-tab.jsx b/src/components/edit-icons-tab.jsx index 6737458..3b166c9 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,10 +9,19 @@ 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; +const FIRST_MATH_TAB_INDEX = 1; const generateUniqueId = (length = 8) => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -20,15 +30,103 @@ 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 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 }) => ( + + + +); + +MathTemplateButton.propTypes = { + template: PropTypes.shape({ + id: PropTypes.string.isRequired, + Icon: PropTypes.elementType.isRequired, + }).isRequired, + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + onContextMenu: PropTypes.func, + onKeyDown: PropTypes.func, +}; + const EditIconsTab = ({ insertLatex, addImageToExport }) => { + const { favoriteTemplates, addFavorite } = 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); + 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) => { + // Chrome on macOS, for one, does not turn Shift+F10 into a `contextmenu` event. + 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({ @@ -114,17 +212,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 +237,34 @@ 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)} + onContextMenu={(event) => openContextMenu(event, subTab)} + onKeyDown={(event) => handleTemplateKeyDown(event, subTab)} + /> ))} ); @@ -197,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 new file mode 100644 index 0000000..93b9b60 --- /dev/null +++ b/src/components/edit-icons-tab.test.jsx @@ -0,0 +1,320 @@ +import { vi } from 'vitest'; +import React from '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, 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)); +}; + +const renderEditIconsTab = () => { + const insertLatex = vi.fn(); + render(); + return { insertLatex }; +}; + +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', () => { + 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')); + }); + + 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()); + + 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/components/home/edit-favorites-modal/index.jsx b/src/components/home/edit-favorites-modal/index.jsx new file mode 100644 index 0000000..64798a9 --- /dev/null +++ b/src/components/home/edit-favorites-modal/index.jsx @@ -0,0 +1,240 @@ +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 isSameList = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]); + +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 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); + const [initialIds] = useState(favoriteIds); + // Based on the list at opening, so that removing every row does not swap the dialog for the + // empty alert. + const hasFavorites = initialIds.length > 0; + const [announcement, setAnnouncement] = useState(''); + // 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({}); + 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 = () => { + // Another tab may have changed the list since the dialog opened, and saving an unedited + // draft would silently revert that. + if (isSameList(draftIds, initialIds)) { + onClose(); + return; + } + if (!saveFavorites(draftIds).ok) { + setSaveFailureCount((count) => count + 1); + return; + } + isSavedRef.current = true; + onClose(); + }; + + // 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; + 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..44b35ce --- /dev/null +++ b/src/components/home/edit-favorites-modal/index.test.jsx @@ -0,0 +1,279 @@ +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 like the home page does, with 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, 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 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, + 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', + ]); + // 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')); + 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('closes without saving when nothing was edited, keeping changes made elsewhere', async () => { + await openModal(); + act(() => { + favoriteMathTemplatesStore.saveFavorites(['sqrt']); + }); + giveElementsASize(); + fireEvent.click(screen.getByRole('button', { name: 'confirm' })); + + 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(JSON.parse(localStorage.getItem(FAVORITE_MATH_TEMPLATE_STORAGE_KEY))).toEqual([ + 'sqrt', + ]); + expect(showToast.success).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByRole('button', { name: 'open' })).toHaveFocus()); + }); + + 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 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); + + // 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 new file mode 100644 index 0000000..2772344 --- /dev/null +++ b/src/lib/favorite-math-templates/locales.test.js @@ -0,0 +1,28 @@ +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' + ? Object.entries(value).flatMap(([key, child]) => + keyPaths(child, prefix ? `${prefix}.${key}` : key) + ) + : [prefix]; + +// 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], + ['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(); + expect(keyPaths(en).sort()).toEqual(keyPaths(zh).sort()); + }); +}); diff --git a/src/lib/favorite-math-templates/store.js b/src/lib/favorite-math-templates/store.js new file mode 100644 index 0000000..4f25dcc --- /dev/null +++ b/src/lib/favorite-math-templates/store.js @@ -0,0 +1,183 @@ +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 clean list: 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 `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; + +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 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()); + } + 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 instead of `event.newValue`, because the event arrives + // asynchronously and this tab may have written since. + setIds(readStoredIds()); + }; + + return { + getIds, + + 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. Checks the limit 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, for example after edits 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..667ade6 --- /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 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 }); + }; + + 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..5ac86b5 --- /dev/null +++ b/src/lib/favorite-math-templates/template-index.js @@ -0,0 +1,6 @@ +import mathTabList from '@/lib/tabs/math'; + +// 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/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']); + }); +}); diff --git a/src/pages/home/home.jsx b/src/pages/home/home.jsx index cab0201..69d838d 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,9 @@ export default function Home() {
{/* Left side input panel */}
-
-

{t('editContent')}

-
+
+

{t('editContent')}

+
+
@@ -443,6 +452,11 @@ export default function Home() { LatexDelimiter={LatexDelimiter} data={data} /> + setEditFavoritesModal((modal) => ({ ...modal, isOpen: false }))} + />
); 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() {} + } +);