Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions public/locales/en/edit-favorites-modal.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions public/locales/en/home.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"setting": "Setting",
"bracket2dollar": "{} to $",
"dollar2bracket": "$ to {}",
"editFavorites": "Edit favorites",
"pleaseInputTitle": "Please input file title",
"latexDelimiter": {
"name": "LaTeX delimiter",
Expand Down
12 changes: 12 additions & 0 deletions public/locales/en/tabs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions public/locales/zh-TW/edit-favorites-modal.json
Original file line number Diff line number Diff line change
@@ -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": "確認"
}
1 change: 1 addition & 0 deletions public/locales/zh-TW/home.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"exportWebsite": "匯出網站",
"bracket2dollar": "括號轉錢號",
"dollar2bracket": "錢號轉括號",
"editFavorites": "編輯常用",
"descript": "說明",
"instructions": "教學",
"preview": "轉換結果",
Expand Down
12 changes: 12 additions & 0 deletions public/locales/zh-TW/tabs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "關係",
Expand Down
203 changes: 203 additions & 0 deletions src/components/core/context-menu.jsx
Original file line number Diff line number Diff line change
@@ -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(
<div
ref={menuRef}
role="menu"
aria-label={label}
// 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 while pressing inside the menu.
onMouseDown={(event) => event.preventDefault()}
onContextMenu={(event) => event.preventDefault()}
>
{items.map((item, index) => (
<button
key={item.id}
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
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)}
>
{item.label}
</button>
))}
</div>,
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;
Loading
Loading