diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 3c709e3abf..07b62f8c5a 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -19,6 +19,11 @@
"cannot_invite_non_user": "",
"cannot_invite_self": "",
"cannot_verify_user_not_robot": "",
+ "category_arrows": "",
+ "category_greek": "",
+ "category_misc": "",
+ "category_operators": "",
+ "category_relations": "",
"change_or_cancel-cancel": "",
"change_or_cancel-change": "",
"change_or_cancel-or": "",
@@ -185,6 +190,7 @@
"no_new_commits_in_github": "",
"no_other_projects_found": "",
"no_preview_available": "",
+ "no_symbols_found": "",
"normal": "",
"off": "",
"ok": "",
@@ -240,6 +246,7 @@
"revoke": "",
"revoke_invite": "",
"run_syntax_check_now": "",
+ "search": "",
"select_a_file": "",
"select_a_project": "",
"select_an_output_file": "",
diff --git a/services/web/frontend/fonts/STIXTwoMath/LICENSE.txt b/services/web/frontend/fonts/STIXTwoMath/LICENSE.txt
new file mode 100644
index 0000000000..11b7b8e889
--- /dev/null
+++ b/services/web/frontend/fonts/STIXTwoMath/LICENSE.txt
@@ -0,0 +1,92 @@
+Copyright 2001-2021 The STIX Fonts Project Authors (https://github.com/stipub/stixfonts), with Reserved Font Name "TM Math". STIX Fonts™ is a trademark of The Institute of Electrical and Electronics Engineers, Inc.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/services/web/frontend/fonts/STIXTwoMath/STIXTwoMath-Regular.woff2 b/services/web/frontend/fonts/STIXTwoMath/STIXTwoMath-Regular.woff2
new file mode 100644
index 0000000000..76f8db85a4
Binary files /dev/null and b/services/web/frontend/fonts/STIXTwoMath/STIXTwoMath-Regular.woff2 differ
diff --git a/services/web/frontend/fonts/stix-two-math.css b/services/web/frontend/fonts/stix-two-math.css
new file mode 100644
index 0000000000..4fd5d2840c
--- /dev/null
+++ b/services/web/frontend/fonts/stix-two-math.css
@@ -0,0 +1,5 @@
+@font-face {
+ font-family: "Stix Two Math";
+ src: url("./STIXTwoMath/STIXTwoMath-Regular.woff2")
+ format("woff2");
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js
new file mode 100644
index 0000000000..d6a8908354
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js
@@ -0,0 +1,53 @@
+import React from 'react'
+import { TabPanels, TabPanel } from '@reach/tabs'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import SymbolPaletteItems from './symbol-palette-items'
+
+export default function SymbolPaletteBody({
+ categories,
+ categorisedSymbols,
+ filteredSymbols,
+ handleSelect,
+ focusInput,
+}) {
+ const { t } = useTranslation()
+
+ // not searching: show the symbols grouped by category
+ if (!filteredSymbols) {
+ return (
+
+ {categories.map(category => (
+
+
+
+ ))}
+
+ )
+ }
+
+ // searching with no matches: show a message
+ if (!filteredSymbols.length) {
+ return
{t('no_symbols_found')}
+ }
+
+ // searching with matches: show the matched symbols
+ return (
+
+ )
+}
+SymbolPaletteBody.propTypes = {
+ categories: PropTypes.arrayOf(PropTypes.object).isRequired,
+ categorisedSymbols: PropTypes.object,
+ filteredSymbols: PropTypes.arrayOf(PropTypes.object),
+ handleSelect: PropTypes.func.isRequired,
+ focusInput: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js
new file mode 100644
index 0000000000..127026e1b8
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js
@@ -0,0 +1,82 @@
+import { Tabs } from '@reach/tabs'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import { matchSorter } from 'match-sorter'
+
+import symbols from '../data/symbols.json'
+import { buildCategorisedSymbols, createCategories } from '../utils/categories'
+import SymbolPaletteSearch from './symbol-palette-search'
+
+import '@reach/tabs/styles.css'
+import SymbolPaletteBody from './symbol-palette-body'
+import SymbolPaletteTabs from './symbol-palette-tabs'
+
+export default function SymbolPaletteContent({ handleSelect }) {
+ const [input, setInput] = useState('')
+
+ const { t } = useTranslation()
+
+ // build the list of categories with translated labels
+ const categories = useMemo(() => createCategories(t), [t])
+
+ // group the symbols by category
+ const categorisedSymbols = useMemo(
+ () => buildCategorisedSymbols(categories),
+ [categories]
+ )
+
+ // select symbols which match the input
+ const filteredSymbols = useMemo(() => {
+ if (input === '') {
+ return null
+ }
+
+ return matchSorter(symbols, input, {
+ keys: ['command', 'description'],
+ threshold: matchSorter.rankings.CONTAINS,
+ })
+ }, [input])
+
+ const inputRef = useRef(null)
+
+ // allow the input to be focused
+ const focusInput = useCallback(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [])
+
+ // focus the input when the symbol palette is opened
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [])
+
+ return (
+
+
+
+ )
+}
+SymbolPaletteContent.propTypes = {
+ handleSelect: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js
new file mode 100644
index 0000000000..9ffaf1db63
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js
@@ -0,0 +1,68 @@
+import React, { useEffect, useRef } from 'react'
+import { OverlayTrigger, Tooltip } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+
+export default function SymbolPaletteItem({
+ focused,
+ handleSelect,
+ handleKeyDown,
+ symbol,
+}) {
+ const buttonRef = useRef(null)
+
+ // call focus() on this item when appropriate
+ useEffect(() => {
+ if (
+ focused &&
+ buttonRef.current &&
+ document.activeElement?.closest('.symbol-palette-items')
+ ) {
+ buttonRef.current.focus()
+ }
+ }, [focused])
+
+ return (
+
+
+ {symbol.description}
+
+
+ {symbol.command}
+
+ {symbol.notes && (
+ {symbol.notes}
+ )}
+
+ }
+ >
+ handleSelect(symbol)}
+ onKeyDown={handleKeyDown}
+ tabIndex={focused ? 0 : -1}
+ ref={buttonRef}
+ role="option"
+ aria-selected={focused}
+ >
+ {symbol.character}
+
+
+ )
+}
+SymbolPaletteItem.propTypes = {
+ symbol: PropTypes.shape({
+ codepoint: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ command: PropTypes.string.isRequired,
+ character: PropTypes.string.isRequired,
+ notes: PropTypes.string,
+ }),
+ handleKeyDown: PropTypes.func.isRequired,
+ handleSelect: PropTypes.func.isRequired,
+ focused: PropTypes.bool,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js
new file mode 100644
index 0000000000..bb65da71db
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js
@@ -0,0 +1,86 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
+import SymbolPaletteItem from './symbol-palette-item'
+
+export default function SymbolPaletteItems({
+ items,
+ handleSelect,
+ focusInput,
+}) {
+ const [focusedIndex, setFocusedIndex] = useState(0)
+
+ // reset the focused item when the list of items changes
+ useEffect(() => {
+ setFocusedIndex(0)
+ }, [items])
+
+ // navigate through items with left and right arrows
+ const handleKeyDown = useCallback(
+ event => {
+ if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
+ return
+ }
+
+ switch (event.key) {
+ // focus previous item
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ setFocusedIndex(index => (index > 0 ? index - 1 : items.length - 1))
+ break
+
+ // focus next item
+ case 'ArrowRight':
+ case 'ArrowDown':
+ setFocusedIndex(index => (index < items.length - 1 ? index + 1 : 0))
+ break
+
+ // focus first item
+ case 'Home':
+ setFocusedIndex(0)
+ break
+
+ // focus last item
+ case 'End':
+ setFocusedIndex(items.length - 1)
+ break
+
+ // allow the default action
+ case 'Enter':
+ case ' ':
+ break
+
+ // any other key returns focus to the input
+ default:
+ focusInput()
+ break
+ }
+ },
+ [focusInput, items.length]
+ )
+
+ return (
+
+ {items.map((symbol, index) => (
+ {
+ handleSelect(symbol)
+ setFocusedIndex(index)
+ }}
+ handleKeyDown={handleKeyDown}
+ focused={index === focusedIndex}
+ />
+ ))}
+
+ )
+}
+SymbolPaletteItems.propTypes = {
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ codepoint: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+ handleSelect: PropTypes.func.isRequired,
+ focusInput: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js
new file mode 100644
index 0000000000..140abc476c
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js
@@ -0,0 +1,36 @@
+import React, { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import useDebounce from '../hooks/use-debounce'
+
+export default function SymbolPaletteSearch({ setInput, inputRef }) {
+ const [localInput, setLocalInput] = useState('')
+
+ // debounce the search input until a typing delay
+ const debouncedLocalInput = useDebounce(localInput, 250)
+
+ useEffect(() => {
+ setInput(debouncedLocalInput)
+ }, [debouncedLocalInput, setInput])
+
+ const { t } = useTranslation()
+
+ return (
+ {
+ setLocalInput(event.target.value)
+ }}
+ />
+ )
+}
+SymbolPaletteSearch.propTypes = {
+ setInput: PropTypes.func.isRequired,
+ inputRef: PropTypes.object.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js
new file mode 100644
index 0000000000..a060ff8e79
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js
@@ -0,0 +1,24 @@
+import React from 'react'
+import { TabList, Tab } from '@reach/tabs'
+import PropTypes from 'prop-types'
+
+export default function SymbolPaletteTabs({ categories, disabled }) {
+ return (
+
+ {categories.map(category => (
+
+ {category.label}
+
+ ))}
+
+ )
+}
+SymbolPaletteTabs.propTypes = {
+ categories: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+ disabled: PropTypes.bool,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js
new file mode 100644
index 0000000000..cf9da84f13
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js
@@ -0,0 +1,15 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import SymbolPaletteContent from './symbol-palette-content'
+
+export default function SymbolPalette({ show, handleSelect }) {
+ if (!show) {
+ return null
+ }
+
+ return
+}
+SymbolPalette.propTypes = {
+ show: PropTypes.bool,
+ handleSelect: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/symbol-palette/data/symbols.json b/services/web/frontend/js/features/symbol-palette/data/symbols.json
new file mode 100644
index 0000000000..ac32904147
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/data/symbols.json
@@ -0,0 +1,821 @@
+[
+ {
+ "category": "Greek",
+ "command": "\\alpha",
+ "codepoint": "U+1D6FC",
+ "description": "lowercase Greek letter alpha",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\beta",
+ "codepoint": "U+1D6FD",
+ "description": "lowercase Greek letter beta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\gamma",
+ "codepoint": "U+1D6FE",
+ "description": "lowercase Greek letter gamma",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\delta",
+ "codepoint": "U+1D6FF",
+ "description": "lowercase Greek letter delta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varepsilon",
+ "codepoint": "U+1D700",
+ "description": "lowercase Greek letter epsilon, varepsilon",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\epsilon",
+ "codepoint": "U+1D716",
+ "description": "lowercase Greek letter epsilon lunate",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\zeta",
+ "codepoint": "U+1D701",
+ "description": "lowercase Greek letter zeta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\eta",
+ "codepoint": "U+1D702",
+ "description": "lowercase Greek letter eta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\vartheta",
+ "codepoint": "U+1D717",
+ "description": "lowercase Greek letter curly theta, vartheta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\theta",
+ "codepoint": "U+1D703",
+ "description": "lowercase Greek letter theta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\iota",
+ "codepoint": "U+1D704",
+ "description": "lowercase Greek letter iota",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\kappa",
+ "codepoint": "U+1D705",
+ "description": "lowercase Greek letter kappa",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\lambda",
+ "codepoint": "U+1D706",
+ "description": "lowercase Greek letter lambda",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\mu",
+ "codepoint": "U+1D707",
+ "description": "lowercase Greek letter mu",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\nu",
+ "codepoint": "U+1D708",
+ "description": "lowercase Greek letter nu",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\xi",
+ "codepoint": "U+1D709",
+ "description": "lowercase Greek letter xi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\pi",
+ "codepoint": "U+1D70B",
+ "description": "lowercase Greek letter pi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varrho",
+ "codepoint": "U+1D71A",
+ "description": "lowercase Greek letter varrho",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\rho",
+ "codepoint": "U+1D70C",
+ "description": "lowercase Greek letter rho",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\sigma",
+ "codepoint": "U+1D70E",
+ "description": "lowercase Greek letter sigma",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varsigma",
+ "codepoint": "U+1D70D",
+ "description": "lowercase Greek letter final sigma, varsigma",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\tau",
+ "codepoint": "U+1D70F",
+ "description": "lowercase Greek letter tau",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\upsilon",
+ "codepoint": "U+1D710",
+ "description": "lowercase Greek letter upsilon",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\phi",
+ "codepoint": "U+1D719",
+ "description": "lowercase Greek letter phi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\varphi",
+ "codepoint": "U+1D711",
+ "description": "lowercase Greek letter varphi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\chi",
+ "codepoint": "U+1D712",
+ "description": "lowercase Greek letter chi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\psi",
+ "codepoint": "U+1D713",
+ "description": "lowercase Greek letter psi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\omega",
+ "codepoint": "U+1D714",
+ "description": "lowercase Greek letter omega",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Gamma",
+ "codepoint": "U+00393",
+ "description": "uppercase Greek letter Gamma",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Delta",
+ "codepoint": "U+00394",
+ "description": "uppercase Greek letter Delta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Theta",
+ "codepoint": "U+00398",
+ "description": "uppercase Greek letter Theta",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Lambda",
+ "codepoint": "U+0039B",
+ "description": "uppercase Greek letter Lambda",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Xi",
+ "codepoint": "U+0039E",
+ "description": "uppercase Greek letter Xi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Pi",
+ "codepoint": "U+003A0",
+ "description": "uppercase Greek letter Pi",
+ "notes": "Use \\prod for the product."
+ },
+ {
+ "category": "Greek",
+ "command": "\\Sigma",
+ "codepoint": "U+003A3",
+ "description": "uppercase Greek letter Sigma",
+ "notes": "Use \\sum for the sum."
+ },
+ {
+ "category": "Greek",
+ "command": "\\Upsilon",
+ "codepoint": "U+003A5",
+ "description": "uppercase Greek letter Upsilon",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Phi",
+ "codepoint": "U+003A6",
+ "description": "uppercase Greek letter Phi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Psi",
+ "codepoint": "U+003A8",
+ "description": "uppercase Greek letter Psi",
+ "notes": ""
+ },
+ {
+ "category": "Greek",
+ "command": "\\Omega",
+ "codepoint": "U+003A9",
+ "description": "uppercase Greek letter Omega",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\neq",
+ "codepoint": "U+02260",
+ "description": "not equal",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\leq",
+ "codepoint": "U+02264",
+ "description": "less than or equal",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\geq",
+ "codepoint": "U+02265",
+ "description": "greater than or equal",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\ll",
+ "codepoint": "U+0226A",
+ "description": "much less than",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\gg",
+ "codepoint": "U+0226B",
+ "description": "much greater than",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\prec",
+ "codepoint": "U+0227A",
+ "description": "precedes",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\succ",
+ "codepoint": "U+0227B",
+ "description": "succeeds",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\in",
+ "codepoint": "U+02208",
+ "description": "set membership",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\notin",
+ "codepoint": "U+02209",
+ "description": "negated set membership",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\ni",
+ "codepoint": "U+0220B",
+ "description": "contains",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\subset",
+ "codepoint": "U+02282",
+ "description": "subset",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\subseteq",
+ "codepoint": "U+02286",
+ "description": "subset or equals",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\supset",
+ "codepoint": "U+02283",
+ "description": "superset",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\simeq",
+ "codepoint": "U+02243",
+ "description": "similar",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\approx",
+ "codepoint": "U+02248",
+ "description": "approximate",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\equiv",
+ "codepoint": "U+02261",
+ "description": "identical with",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\cong",
+ "codepoint": "U+02245",
+ "description": "congruent with",
+ "notes": ""
+ },
+ {
+ "category": "Relations",
+ "command": "\\mid",
+ "codepoint": "U+02223",
+ "description": "mid, divides, vertical bar, modulus, absolute value",
+ "notes": "Use \\lvert...\\rvert for the absolute value."
+ },
+ {
+ "category": "Relations",
+ "command": "\\nmid",
+ "codepoint": "U+02224",
+ "description": "negated mid, not divides",
+ "notes": "Requires \\usepackage{amssymb}."
+ },
+ {
+ "category": "Relations",
+ "command": "\\parallel",
+ "codepoint": "U+02225",
+ "description": "parallel, double vertical bar, norm",
+ "notes": "Use \\lVert...\\rVert for the norm."
+ },
+ {
+ "category": "Relations",
+ "command": "\\perp",
+ "codepoint": "U+027C2",
+ "description": "perpendicular",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\times",
+ "codepoint": "U+000D7",
+ "description": "cross product, multiplication",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\div",
+ "codepoint": "U+000F7",
+ "description": "division",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cap",
+ "codepoint": "U+02229",
+ "description": "intersection",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cup",
+ "codepoint": "U+0222A",
+ "description": "union",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cdot",
+ "codepoint": "U+022C5",
+ "description": "dot product, multiplication",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\cdots",
+ "codepoint": "U+022EF",
+ "description": "centered dots",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\bullet",
+ "codepoint": "U+02219",
+ "description": "bullet",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\circ",
+ "codepoint": "U+025E6",
+ "description": "circle",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\wedge",
+ "codepoint": "U+02227",
+ "description": "wedge, logical and",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\vee",
+ "codepoint": "U+02228",
+ "description": "vee, logical or",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\setminus",
+ "codepoint": "U+0005C",
+ "description": "set minus, backslash",
+ "notes": "Use \\backslash for a backslash."
+ },
+ {
+ "category": "Operators",
+ "command": "\\oplus",
+ "codepoint": "U+02295",
+ "description": "plus sign in circle",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\otimes",
+ "codepoint": "U+02297",
+ "description": "multiply sign in circle",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\sum",
+ "codepoint": "U+02211",
+ "description": "summation operator",
+ "notes": "Use \\Sigma for the letter Sigma."
+ },
+ {
+ "category": "Operators",
+ "command": "\\prod",
+ "codepoint": "U+0220F",
+ "description": "product operator",
+ "notes": "Use \\Pi for the letter Pi."
+ },
+ {
+ "category": "Operators",
+ "command": "\\bigcap",
+ "codepoint": "U+022C2",
+ "description": "intersection operator",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\bigcup",
+ "codepoint": "U+022C3",
+ "description": "union operator",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\int",
+ "codepoint": "U+0222B",
+ "description": "integral operator",
+ "notes": ""
+ },
+ {
+ "category": "Operators",
+ "command": "\\iint",
+ "codepoint": "U+0222C",
+ "description": "double integral operator",
+ "notes": "Requires \\usepackage{amsmath}."
+ },
+ {
+ "category": "Operators",
+ "command": "\\iiint",
+ "codepoint": "U+0222D",
+ "description": "triple integral operator",
+ "notes": "Requires \\usepackage{amsmath}."
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftarrow",
+ "codepoint": "U+02190",
+ "description": "leftward arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightarrow",
+ "codepoint": "U+02192",
+ "description": "rightward arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftrightarrow",
+ "codepoint": "U+02194",
+ "description": "left and right arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\uparrow",
+ "codepoint": "U+02191",
+ "description": "upward arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\downarrow",
+ "codepoint": "U+02193",
+ "description": "downward arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\Leftarrow",
+ "codepoint": "U+021D0",
+ "description": "is implied by",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\Rightarrow",
+ "codepoint": "U+021D2",
+ "description": "implies",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\Leftrightarrow",
+ "codepoint": "U+021D4",
+ "description": "left and right double arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\mapsto",
+ "codepoint": "U+021A6",
+ "description": "maps to, rightward",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\nearrow",
+ "codepoint": "U+02197",
+ "description": "ne pointing arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\searrow",
+ "codepoint": "U+02198",
+ "description": "se pointing arrow",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightleftharpoons",
+ "codepoint": "U+021CC",
+ "description": "right harpoon over left",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftharpoonup",
+ "codepoint": "U+021BC",
+ "description": "left harpoon up",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightharpoonup",
+ "codepoint": "U+021C0",
+ "description": "right harpoon up",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\leftharpoondown",
+ "codepoint": "U+021BD",
+ "description": "left harpoon down",
+ "notes": ""
+ },
+ {
+ "category": "Arrows",
+ "command": "\\rightharpoondown",
+ "codepoint": "U+021C1",
+ "description": "right harpoon down",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\infty",
+ "codepoint": "U+0221E",
+ "description": "infinity",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\partial",
+ "codepoint": "U+1D715",
+ "description": "partial differential",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\nabla",
+ "codepoint": "U+02207",
+ "description": "nabla, del, hamilton operator",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\varnothing",
+ "codepoint": "U+02300",
+ "description": "empty set",
+ "notes": "Requires \\usepackage{amssymb}."
+ },
+ {
+ "category": "Misc",
+ "command": "\\forall",
+ "codepoint": "U+02200",
+ "description": "for all",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\exists",
+ "codepoint": "U+02203",
+ "description": "there exists",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\neg",
+ "codepoint": "U+000AC",
+ "description": "not sign",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\Re",
+ "codepoint": "U+0211C",
+ "description": "real part",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\Im",
+ "codepoint": "U+02111",
+ "description": "imaginary part",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\Box",
+ "codepoint": "U+025A1",
+ "description": "square",
+ "notes": "Requires \\usepackage{amssymb}."
+ },
+ {
+ "category": "Misc",
+ "command": "\\triangle",
+ "codepoint": "U+025B3",
+ "description": "trangle",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\aleph",
+ "codepoint": "U+02135",
+ "description": "Hebrew letter aleph",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\wp",
+ "codepoint": "U+02118",
+ "description": "Weierstrass letter p",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\#",
+ "codepoint": "U+00023",
+ "description": "number sign, hashtag",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\$",
+ "codepoint": "U+00024",
+ "description": "dollar sign",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\%",
+ "codepoint": "U+00025",
+ "description": "percent sign",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\&",
+ "codepoint": "U+00026",
+ "description": "et sign, and, ampersand",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\{",
+ "codepoint": "U+0007B",
+ "description": "left curly brace",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\}",
+ "codepoint": "U+0007D",
+ "description": "right curly brace",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\langle",
+ "codepoint": "U+027E8",
+ "description": "left angle bracket, bra",
+ "notes": ""
+ },
+ {
+ "category": "Misc",
+ "command": "\\rangle",
+ "codepoint": "U+027E9",
+ "description": "right angle bracket, ket",
+ "notes": ""
+ }
+]
diff --git a/services/web/frontend/js/features/symbol-palette/hooks/use-debounce.js b/services/web/frontend/js/features/symbol-palette/hooks/use-debounce.js
new file mode 100644
index 0000000000..497117cdfa
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/hooks/use-debounce.js
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react'
+
+export default function useDebounce(value, delay = 0) {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => {
+ window.clearTimeout(timer)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
diff --git a/services/web/frontend/js/features/symbol-palette/utils/categories.js b/services/web/frontend/js/features/symbol-palette/utils/categories.js
new file mode 100644
index 0000000000..383f08cf78
--- /dev/null
+++ b/services/web/frontend/js/features/symbol-palette/utils/categories.js
@@ -0,0 +1,45 @@
+import symbols from '../data/symbols.json'
+
+export function createCategories(t) {
+ return [
+ {
+ id: 'Greek',
+ label: t('category_greek'),
+ },
+ {
+ id: 'Arrows',
+ label: t('category_arrows'),
+ },
+ {
+ id: 'Operators',
+ label: t('category_operators'),
+ },
+ {
+ id: 'Relations',
+ label: t('category_relations'),
+ },
+ {
+ id: 'Misc',
+ label: t('category_misc'),
+ },
+ ]
+}
+
+export function buildCategorisedSymbols(categories) {
+ const output = {}
+
+ for (const category of categories) {
+ output[category.id] = []
+ }
+
+ for (const item of symbols) {
+ if (item.category in output) {
+ item.character = String.fromCodePoint(
+ parseInt(item.codepoint.replace(/^U\+0*/, ''), 16)
+ )
+ output[item.category].push(item)
+ }
+ }
+
+ return output
+}
diff --git a/services/web/frontend/stories/symbol-palette.stories.js b/services/web/frontend/stories/symbol-palette.stories.js
new file mode 100644
index 0000000000..609e050684
--- /dev/null
+++ b/services/web/frontend/stories/symbol-palette.stories.js
@@ -0,0 +1,18 @@
+import React from 'react'
+
+import SymbolPalette from '../js/features/symbol-palette/components/symbol-palette'
+
+export const Interactive = args => {
+ return
+}
+
+export default {
+ title: 'Symbol Palette',
+ component: SymbolPalette,
+ args: {
+ show: true,
+ },
+ argTypes: {
+ handleSelect: { action: 'handleSelect' },
+ },
+}
diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less
index 6168da159a..d6d13b98b5 100644
--- a/services/web/frontend/stylesheets/app/editor.less
+++ b/services/web/frontend/stylesheets/app/editor.less
@@ -15,6 +15,7 @@
@import './editor/publish-modal.less';
@import './editor/outline.less';
@import './editor/logs.less';
+@import './editor/symbol-palette.less';
@ui-layout-toggler-def-height: 50px;
@ui-resizer-size: 7px;
diff --git a/services/web/frontend/stylesheets/app/editor/symbol-palette.less b/services/web/frontend/stylesheets/app/editor/symbol-palette.less
new file mode 100644
index 0000000000..96c2b80c26
--- /dev/null
+++ b/services/web/frontend/stylesheets/app/editor/symbol-palette.less
@@ -0,0 +1,75 @@
+.symbol-palette {
+ display: flex;
+ flex-direction: column;
+ background: @symbol-palette-bg;
+ color: @symbol-palette-color;
+ max-width: 600px;
+}
+
+.symbol-palette-header {
+ flex-shrink: 0;
+ display: flex;
+ justify-content: space-between;
+ font-family: @font-family-sans-serif;
+ font-size: 16px;
+ background: @symbol-palette-header-background;
+}
+
+.symbol-palette-header [data-reach-tab] {
+ border-bottom: none;
+}
+
+.symbol-palette-header [data-reach-tab][data-selected] {
+ background: @symbol-palette-selected-tab-bg;
+}
+
+.symbol-palette-body {
+ flex: 1;
+}
+
+.symbol-palette-items {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 5px;
+}
+
+.symbol-palette-item {
+ font-family: 'Stix Two Math', serif;
+ font-size: 24px;
+ line-height: 42px;
+ height: 42px;
+ width: 42px;
+ margin: 5px;
+ color: @symbol-palette-item-color;
+ background: @symbol-palette-item-bg;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.symbol-palette-item-command {
+ font-family: monospace;
+ font-weight: bold;
+}
+
+.symbol-palette-item-notes {
+ margin-top: 5px;
+}
+
+.symbol-palette-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px;
+}
+
+.symbol-palette-search {
+ color: @symbol-palette-search-color;
+ border-radius: 21px;
+ border: none;
+ padding: 2px 10px;
+ margin: 5px;
+ line-height: 1;
+}
diff --git a/services/web/frontend/stylesheets/core/ol-light-variables.less b/services/web/frontend/stylesheets/core/ol-light-variables.less
index d21e8e3d67..78d3b6bb03 100644
--- a/services/web/frontend/stylesheets/core/ol-light-variables.less
+++ b/services/web/frontend/stylesheets/core/ol-light-variables.less
@@ -127,3 +127,12 @@
@chat-new-message-textarea-bg: #fff;
@chat-new-message-textarea-color: @ol-blue-gray-6;
@chat-new-message-border-color: @ol-blue-gray-1;
+
+// Symbol Palette
+@symbol-palette-bg: #fff;
+@symbol-palette-color: @ol-blue-gray-3;
+@symbol-palette-header-background: #fff;
+@symbol-palette-item-bg: @gray-lighter;
+@symbol-palette-item-color: @ol-blue-gray-5;
+@symbol-palette-search-color: @ol-blue-gray-4;
+@symbol-palette-selected-tab-bg: @gray-lighter;
diff --git a/services/web/frontend/stylesheets/core/variables.less b/services/web/frontend/stylesheets/core/variables.less
index a6a749a349..42d4e2ecce 100644
--- a/services/web/frontend/stylesheets/core/variables.less
+++ b/services/web/frontend/stylesheets/core/variables.less
@@ -1112,3 +1112,12 @@
// Input suggestions
@input-suggestion-v-offset: 4px;
+
+// Symbol Palette
+@symbol-palette-bg: @ol-blue-gray-4;
+@symbol-palette-color: #fff;
+@symbol-palette-header-background: @ol-blue-gray-5;
+@symbol-palette-item-bg: @ol-blue-gray-5;
+@symbol-palette-item-color: #fff;
+@symbol-palette-search-color: @ol-blue-gray-4;
+@symbol-palette-selected-tab-bg: @ol-blue-gray-4;
diff --git a/services/web/frontend/stylesheets/style.less b/services/web/frontend/stylesheets/style.less
index 0170e18687..453129f9b6 100644
--- a/services/web/frontend/stylesheets/style.less
+++ b/services/web/frontend/stylesheets/style.less
@@ -1,6 +1,7 @@
@import (less) '../fonts/lato.css';
@import (less) '../fonts/merriweather.css';
@import (less) '../fonts/source-code-pro.css';
+@import (less) '../fonts/stix-two-math.css';
@is-overleaf-light: false;
@show-rich-text: true;
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index a4ae06ffda..f7443453be 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1429,6 +1429,13 @@
"hotkey_toggle_review_panel": "Toggle review panel",
"hotkey_toggle_track_changes": "Toggle track changes",
"hotkey_add_a_comment": "Add a comment",
+ "category_greek": "Greek",
+ "category_arrows": "Arrows",
+ "category_operators": "Operators",
+ "category_relations": "Relations",
+ "category_misc": "Misc",
+ "no_symbols_found": "No symbols found",
+ "search": "Search",
"also": "Also",
"add_email": "Add Email"
}
diff --git a/services/web/package-lock.json b/services/web/package-lock.json
index 0f4740abca..c76e68e981 100644
--- a/services/web/package-lock.json
+++ b/services/web/package-lock.json
@@ -4419,6 +4419,38 @@
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
},
+ "@reach/auto-id": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@reach/auto-id/-/auto-id-0.15.0.tgz",
+ "integrity": "sha512-iACaCcZeqAhDf4OOwJpmHHS/LaRj9z3Ip8JmlhpCrFWV2YOIiiZk42amlBZX6CKH66Md+eriYZQk3TyAjk6Oxg==",
+ "requires": {
+ "@reach/utils": "0.15.0",
+ "tslib": "^2.1.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
+ "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w=="
+ }
+ }
+ },
+ "@reach/descendants": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@reach/descendants/-/descendants-0.15.0.tgz",
+ "integrity": "sha512-MGs+7sGjx07sm29Wc2d/Ya72qwatNVHofnYwwHMT6LImv99kE5TQMCgkMSDeRwqQxHURmcjb4I7Tab+ahvCGiw==",
+ "requires": {
+ "@reach/utils": "0.15.0",
+ "tslib": "^2.1.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
+ "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w=="
+ }
+ }
+ },
"@reach/router": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@reach/router/-/router-1.3.4.tgz",
@@ -4431,6 +4463,41 @@
"react-lifecycles-compat": "^3.0.4"
}
},
+ "@reach/tabs": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@reach/tabs/-/tabs-0.15.0.tgz",
+ "integrity": "sha512-W3FZw0DMGChwIRDBZYCw66NdiGkyapYs6xjOqQV2Ph7DMaMQ0lYck5bn40EtVi3O5x+8cAKmV+ubbHVUh+WSig==",
+ "requires": {
+ "@reach/auto-id": "0.15.0",
+ "@reach/descendants": "0.15.0",
+ "@reach/utils": "0.15.0",
+ "prop-types": "^15.7.2",
+ "tslib": "^2.1.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
+ "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w=="
+ }
+ }
+ },
+ "@reach/utils": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@reach/utils/-/utils-0.15.0.tgz",
+ "integrity": "sha512-JHHN7T5ucFiuQbqkgv8ECbRWKfRiJxrO/xHR3fHf+f2C7mVs/KkJHhYtovS1iEapR4silygX9PY0+QUmHPOTYw==",
+ "requires": {
+ "tiny-warning": "^1.0.3",
+ "tslib": "^2.1.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
+ "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w=="
+ }
+ }
+ },
"@react-dnd/asap": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz",
@@ -18220,7 +18287,7 @@
"functional-red-black-tree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
- "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+ "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
"dev": true
},
"functions-have-names": {
@@ -23121,7 +23188,7 @@
"microtime-nodejs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
- "integrity": "sha1-iFlASvLipGKhXJzWvyxORo2r2+g="
+ "integrity": "sha512-SthP/4JW6HUIZfgM0nadNtwKm/WMH0+z1i4RsPDnud+UasjoABzSkCk3eMhIRzipgwPhkdAYpTI69X4II4j1pA=="
},
"miller-rabin": {
"version": "4.0.1",
@@ -24128,7 +24195,7 @@
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"ncp": {
@@ -29783,7 +29850,7 @@
"require-like": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz",
- "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==",
+ "integrity": "sha1-rW8wwTvs15cBDEaK+ndcDAprR/o=",
"dev": true
},
"require-main-filename": {
@@ -33480,6 +33547,11 @@
"dev": true,
"optional": true
},
+ "tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
+ },
"tlds": {
"version": "1.210.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.210.0.tgz",
diff --git a/services/web/package.json b/services/web/package.json
index b5e7a3f13c..56922a3ac1 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -54,6 +54,7 @@
"@pollyjs/adapter-node-http": "^4.2.1",
"@pollyjs/core": "^4.2.1",
"@pollyjs/persister-fs": "^4.2.1",
+ "@reach/tabs": "^0.15.0",
"@sentry/browser": "^6.3.5",
"@uppy/core": "^1.15.0",
"@uppy/dashboard": "^1.11.0",
diff --git a/services/web/test/frontend/features/symbol-palette/components/symbol-palette.test.js b/services/web/test/frontend/features/symbol-palette/components/symbol-palette.test.js
new file mode 100644
index 0000000000..af662f5b5d
--- /dev/null
+++ b/services/web/test/frontend/features/symbol-palette/components/symbol-palette.test.js
@@ -0,0 +1,102 @@
+import { expect } from 'chai'
+import sinon from 'sinon'
+import React from 'react'
+import { screen, render, fireEvent, waitFor } from '@testing-library/react'
+import SymbolPalette from '../../../../../frontend/js/features/symbol-palette/components/symbol-palette'
+
+// eslint-disable-next-line mocha/no-skipped-tests
+describe.skip('symbol palette', function () {
+ // let clock
+ //
+ // beforeEach(function () {
+ // clock = sinon.useFakeTimers({
+ // toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
+ // })
+ // })
+ //
+ // afterEach(function () {
+ // clock.restore()
+ // })
+
+ it('handles keyboard interaction', async function () {
+ this.timeout(10000)
+
+ const handleSelect = sinon.stub()
+
+ const { container } = render(
+
+ )
+
+ // check the number of tabs
+ const tabs = await screen.findAllByRole('tab')
+ expect(tabs).to.have.length(5)
+
+ let selectedTab
+ let symbols
+
+ // the first tab should be selected
+ selectedTab = await screen.getByRole('tab', { selected: true })
+ expect(selectedTab.textContent).to.equal('Greek')
+ symbols = await screen.findAllByRole('option')
+ expect(symbols).to.have.length(39)
+
+ // click to select the third tab
+ tabs[2].click()
+ selectedTab = await screen.getByRole('tab', { selected: true })
+ expect(selectedTab.textContent).to.equal('Operators')
+ symbols = await screen.findAllByRole('option')
+ expect(symbols).to.have.length(20)
+
+ // press the left arrow to select the second tab
+ fireEvent.keyDown(selectedTab, { key: 'ArrowLeft' })
+ selectedTab = await screen.getByRole('tab', { selected: true })
+ expect(selectedTab.textContent).to.equal('Arrows')
+ symbols = await screen.findAllByRole('option')
+ expect(symbols).to.have.length(16)
+
+ // select the search input
+ const input = await screen.getByRole('searchbox')
+ input.click()
+
+ // type in the search input
+ fireEvent.change(input, { target: { value: 'pi' } })
+
+ // make sure all scheduled microtasks have executed
+ // clock.runAll()
+
+ // wait for the symbols to be filtered
+ await waitFor(async () => {
+ symbols = await screen.findAllByRole('option')
+ console.log(symbols.length)
+ expect(symbols).to.have.length(2)
+ })
+
+ // press Tab to select the symbols
+ fireEvent.keyDown(container, { key: 'Tab' })
+
+ // get the selected symbol
+ let selectedSymbol
+
+ selectedSymbol = await screen.getByRole('option', { selected: true })
+ expect(selectedSymbol.textContent).to.equal('𝜋')
+
+ // move to the next symbol
+ fireEvent.keyDown(selectedSymbol, { key: 'ArrowRight' })
+
+ // wait for the symbol to be selected
+ selectedSymbol = await screen.getByRole('option', { selected: true })
+ expect(selectedSymbol.textContent).to.equal('Π')
+
+ // click on the selected symbol
+ selectedSymbol.click()
+
+ expect(handleSelect).to.have.been.calledWith({
+ category: 'Greek',
+ character: 'Π',
+ codepoint: 'U+003A0',
+ command: '\\Pi',
+ description: 'uppercase Greek letter Pi',
+ notes: 'Use \\prod for the product.',
+ })
+ })
+})