From 4acb77f3720071aaf6e51eea5c013c359017897c Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Wed, 11 Dec 2024 03:55:41 +0100 Subject: [PATCH 01/27] Enable Symbol Palette --- services/web/config/settings.defaults.js | 3 +- .../components/symbol-palette-body.js | 61 ++ .../components/symbol-palette-close-button.js | 18 + .../components/symbol-palette-content.js | 94 ++ .../components/symbol-palette-info-link.js | 29 + .../components/symbol-palette-item.js | 67 ++ .../components/symbol-palette-items.js | 86 ++ .../components/symbol-palette-search.js | 44 + .../components/symbol-palette-tabs.js | 22 + .../components/symbol-palette.js | 8 + .../features/symbol-palette/data/symbols.json | 872 ++++++++++++++++++ .../symbol-palette/utils/categories.js | 44 + services/web/modules/symbol-palette/index.mjs | 2 + services/web/package.json | 1 + 14 files changed, 1350 insertions(+), 1 deletion(-) create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js create mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette.js create mode 100644 services/web/frontend/js/features/symbol-palette/data/symbols.json create mode 100644 services/web/frontend/js/features/symbol-palette/utils/categories.js create mode 100644 services/web/modules/symbol-palette/index.mjs diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index bd0730d5d0..bbfcc1acd3 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -979,7 +979,7 @@ module.exports = { pdfPreviewPromotions: [], diagnosticActions: [], sourceEditorCompletionSources: [], - sourceEditorSymbolPalette: [], + sourceEditorSymbolPalette: ['@/features/symbol-palette/components/symbol-palette'], sourceEditorToolbarComponents: [], mainEditorLayoutModals: [], langFeedbackLinkingWidgets: [], @@ -1030,6 +1030,7 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'symbol-palette', ], viewIncludes: {}, 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..c4f47e325d --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-body.js @@ -0,0 +1,61 @@ +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() + + // searching with matches: show the matched symbols + // searching with no matches: show a message + // note: include empty tab panels so that aria-controls on tabs can still reference the panel ids + if (filteredSymbols) { + return ( + <> + {filteredSymbols.length ? ( + + ) : ( +
{t('no_symbols_found')}
+ )} + + + {categories.map(category => ( + + ))} + + + ) + } + + // not searching: show the symbols grouped by category + return ( + + {categories.map(category => ( + + + + ))} + + ) +} +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-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js new file mode 100644 index 0000000000..c472c31586 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -0,0 +1,18 @@ +import { Button } from 'react-bootstrap' +import { useEditorContext } from '../../../shared/context/editor-context' + +export default function SymbolPaletteCloseButton() { + const { toggleSymbolPalette } = useEditorContext() + + return ( + + ) +} + 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..8537e14585 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-content.js @@ -0,0 +1,94 @@ +import { Tabs } from '@reach/tabs' +import { 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 SymbolPaletteBody from './symbol-palette-body' +import SymbolPaletteTabs from './symbol-palette-tabs' +// import SymbolPaletteInfoLink from './symbol-palette-info-link' +import SymbolPaletteCloseButton from './symbol-palette-close-button' + +import '@reach/tabs/styles.css' + +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 + } + + const words = input.trim().split(/\s+/) + + return words.reduceRight( + (symbols, word) => + matchSorter(symbols, word, { + keys: ['command', 'description', 'character', 'aliases'], + threshold: matchSorter.rankings.CONTAINS, + }), + symbols + ) + }, [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 ( + +
+
+
+ +
+ {/* Useless button (uncomment if you see any sense in it) */} + {/* */} + +
+
+ +
+
+ +
+
+
+ ) +} +SymbolPaletteContent.propTypes = { + handleSelect: PropTypes.func.isRequired, +} diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js new file mode 100644 index 0000000000..ba56cf2b10 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js @@ -0,0 +1,29 @@ +import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +export default function SymbolPaletteInfoLink() { + const { t } = useTranslation() + + return ( + + {t('find_out_more_about_latex_symbols')} + + } + > + + + ) +} 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..a892f33cf8 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-item.js @@ -0,0 +1,67 @@ +import { 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}
+ )} + + } + > + +
+ ) +} +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..44835261f5 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-items.js @@ -0,0 +1,86 @@ +import { 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..cf5a1eb2a7 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-search.js @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import { FormControl } from 'react-bootstrap' +import useDebounce from '../../../shared/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() + + const inputRefCallback = useCallback( + element => { + inputRef.current = element + }, + [inputRef] + ) + + 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..d53cd93ac0 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-tabs.js @@ -0,0 +1,22 @@ +import { TabList, Tab } from '@reach/tabs' +import PropTypes from 'prop-types' + +export default function SymbolPaletteTabs({ categories }) { + return ( + + {categories.map(category => ( + + {category.label} + + ))} + + ) +} +SymbolPaletteTabs.propTypes = { + categories: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + }) + ).isRequired, +} 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..2f1cc5e8c8 --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette.js @@ -0,0 +1,8 @@ +import SymbolPaletteContent from './symbol-palette-content' + +export default function SymbolPalette() { + const handleSelect = (symbol) => { + window.dispatchEvent(new CustomEvent('editor:insert-symbol', { detail: symbol })) + } + return +} 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..af160b3eed --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/data/symbols.json @@ -0,0 +1,872 @@ +[ + { + "category": "Greek", + "command": "\\alpha", + "codepoint": "U+1D6FC", + "description": "Lowercase Greek letter alpha", + "aliases": ["a", "α"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\beta", + "codepoint": "U+1D6FD", + "description": "Lowercase Greek letter beta", + "aliases": ["b", "β"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\gamma", + "codepoint": "U+1D6FE", + "description": "Lowercase Greek letter gamma", + "aliases": ["γ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\delta", + "codepoint": "U+1D6FF", + "description": "Lowercase Greek letter delta", + "aliases": ["δ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varepsilon", + "codepoint": "U+1D700", + "description": "Lowercase Greek letter epsilon, varepsilon", + "aliases": ["ε"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\epsilon", + "codepoint": "U+1D716", + "description": "Lowercase Greek letter epsilon lunate", + "aliases": ["ε"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\zeta", + "codepoint": "U+1D701", + "description": "Lowercase Greek letter zeta", + "aliases": ["ζ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\eta", + "codepoint": "U+1D702", + "description": "Lowercase Greek letter eta", + "aliases": ["η"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\vartheta", + "codepoint": "U+1D717", + "description": "Lowercase Greek letter curly theta, vartheta", + "aliases": ["θ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\theta", + "codepoint": "U+1D703", + "description": "Lowercase Greek letter theta", + "aliases": ["θ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\iota", + "codepoint": "U+1D704", + "description": "Lowercase Greek letter iota", + "aliases": ["ι"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\kappa", + "codepoint": "U+1D705", + "description": "Lowercase Greek letter kappa", + "aliases": ["κ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\lambda", + "codepoint": "U+1D706", + "description": "Lowercase Greek letter lambda", + "aliases": ["λ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\mu", + "codepoint": "U+1D707", + "description": "Lowercase Greek letter mu", + "aliases": ["μ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\nu", + "codepoint": "U+1D708", + "description": "Lowercase Greek letter nu", + "aliases": ["ν"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\xi", + "codepoint": "U+1D709", + "description": "Lowercase Greek letter xi", + "aliases": ["ξ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\pi", + "codepoint": "U+1D70B", + "description": "Lowercase Greek letter pi", + "aliases": ["π"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varrho", + "codepoint": "U+1D71A", + "description": "Lowercase Greek letter rho, varrho", + "aliases": ["ρ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\rho", + "codepoint": "U+1D70C", + "description": "Lowercase Greek letter rho", + "aliases": ["ρ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\sigma", + "codepoint": "U+1D70E", + "description": "Lowercase Greek letter sigma", + "aliases": ["σ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varsigma", + "codepoint": "U+1D70D", + "description": "Lowercase Greek letter final sigma, varsigma", + "aliases": ["ς"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\tau", + "codepoint": "U+1D70F", + "description": "Lowercase Greek letter tau", + "aliases": ["τ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\upsilon", + "codepoint": "U+1D710", + "description": "Lowercase Greek letter upsilon", + "aliases": ["υ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\phi", + "codepoint": "U+1D719", + "description": "Lowercase Greek letter phi", + "aliases": ["φ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\varphi", + "codepoint": "U+1D711", + "description": "Lowercase Greek letter phi, varphi", + "aliases": ["φ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\chi", + "codepoint": "U+1D712", + "description": "Lowercase Greek letter chi", + "aliases": ["χ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\psi", + "codepoint": "U+1D713", + "description": "Lowercase Greek letter psi", + "aliases": ["ψ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\omega", + "codepoint": "U+1D714", + "description": "Lowercase Greek letter omega", + "aliases": ["ω"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Gamma", + "codepoint": "U+00393", + "description": "Uppercase Greek letter Gamma", + "aliases": ["Γ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Delta", + "codepoint": "U+00394", + "description": "Uppercase Greek letter Delta", + "aliases": ["Δ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Theta", + "codepoint": "U+00398", + "description": "Uppercase Greek letter Theta", + "aliases": ["Θ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Lambda", + "codepoint": "U+0039B", + "description": "Uppercase Greek letter Lambda", + "aliases": ["Λ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Xi", + "codepoint": "U+0039E", + "description": "Uppercase Greek letter Xi", + "aliases": ["Ξ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Pi", + "codepoint": "U+003A0", + "description": "Uppercase Greek letter Pi", + "aliases": ["Π"], + "notes": "Use \\prod for the product." + }, + { + "category": "Greek", + "command": "\\Sigma", + "codepoint": "U+003A3", + "description": "Uppercase Greek letter Sigma", + "aliases": ["Σ"], + "notes": "Use \\sum for the sum." + }, + { + "category": "Greek", + "command": "\\Upsilon", + "codepoint": "U+003A5", + "description": "Uppercase Greek letter Upsilon", + "aliases": ["Υ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Phi", + "codepoint": "U+003A6", + "description": "Uppercase Greek letter Phi", + "aliases": ["Φ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Psi", + "codepoint": "U+003A8", + "description": "Uppercase Greek letter Psi", + "aliases": ["Ψ"], + "notes": "" + }, + { + "category": "Greek", + "command": "\\Omega", + "codepoint": "U+003A9", + "description": "Uppercase Greek letter Omega", + "aliases": ["Ω"], + "notes": "" + }, + { + "category": "Relations", + "command": "\\neq", + "codepoint": "U+02260", + "description": "Not equal", + "aliases": ["!="], + "notes": "" + }, + { + "category": "Relations", + "command": "\\leq", + "codepoint": "U+02264", + "description": "Less than or equal", + "aliases": ["<="], + "notes": "" + }, + { + "category": "Relations", + "command": "\\geq", + "codepoint": "U+02265", + "description": "Greater than or equal", + "aliases": [">="], + "notes": "" + }, + { + "category": "Relations", + "command": "\\ll", + "codepoint": "U+0226A", + "description": "Much less than", + "aliases": ["<<"], + "notes": "" + }, + { + "category": "Relations", + "command": "\\gg", + "codepoint": "U+0226B", + "description": "Much greater than", + "aliases": [">>"], + "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", + "aliases": ["x"], + "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", + "aliases": ["<-"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\rightarrow", + "codepoint": "U+02192", + "description": "Rightward arrow", + "aliases": ["->"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\leftrightarrow", + "codepoint": "U+02194", + "description": "Left and right arrow", + "aliases": ["<->"], + "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", + "aliases": ["<="], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\Rightarrow", + "codepoint": "U+021D2", + "description": "Implies", + "aliases": ["=>"], + "notes": "" + }, + { + "category": "Arrows", + "command": "\\Leftrightarrow", + "codepoint": "U+021D4", + "description": "Left and right double arrow", + "aliases": ["<=>"], + "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": "Triangle", + "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/utils/categories.js b/services/web/frontend/js/features/symbol-palette/utils/categories.js new file mode 100644 index 0000000000..872534771f --- /dev/null +++ b/services/web/frontend/js/features/symbol-palette/utils/categories.js @@ -0,0 +1,44 @@ +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/modules/symbol-palette/index.mjs b/services/web/modules/symbol-palette/index.mjs new file mode 100644 index 0000000000..3a412c2eec --- /dev/null +++ b/services/web/modules/symbol-palette/index.mjs @@ -0,0 +1,2 @@ +import logger from '@overleaf/logger' +logger.debug({}, 'Enable Symbol Palette') diff --git a/services/web/package.json b/services/web/package.json index b0cee1af06..cd02c42e32 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -213,6 +213,7 @@ "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", "@prettier/plugin-pug": "^3.4.0", + "@reach/tabs": "0.18.0", "@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", From f3ee25e1c7bcccc8305061d41a2f4c109947beb0 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sat, 8 Mar 2025 18:23:19 +0100 Subject: [PATCH 02/27] Fix glitches in symbol palette after switching to Bootstrap 5 --- .../components/symbol-palette-close-button.js | 8 ++--- .../components/symbol-palette-content.js | 5 +--- .../components/symbol-palette-info-link.js | 29 ------------------- .../components/symbol-palette-item.js | 2 +- .../bootstrap-5/modules/symbol-palette.scss | 4 +-- 5 files changed, 7 insertions(+), 41 deletions(-) delete mode 100644 services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js index c472c31586..6c776d1e24 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -5,14 +5,12 @@ export default function SymbolPaletteCloseButton() { const { toggleSymbolPalette } = useEditorContext() return ( +
+
) } - 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 index 8537e14585..a170987793 100644 --- 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 @@ -9,7 +9,6 @@ import { buildCategorisedSymbols, createCategories } from '../utils/categories' import SymbolPaletteSearch from './symbol-palette-search' import SymbolPaletteBody from './symbol-palette-body' import SymbolPaletteTabs from './symbol-palette-tabs' -// import SymbolPaletteInfoLink from './symbol-palette-info-link' import SymbolPaletteCloseButton from './symbol-palette-close-button' import '@reach/tabs/styles.css' @@ -68,9 +67,7 @@ export default function SymbolPaletteContent({ handleSelect }) {
-
- {/* Useless button (uncomment if you see any sense in it) */} - {/* */} +
diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js deleted file mode 100644 index ba56cf2b10..0000000000 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-info-link.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap' -import { useTranslation } from 'react-i18next' - -export default function SymbolPaletteInfoLink() { - const { t } = useTranslation() - - return ( - - {t('find_out_more_about_latex_symbols')} - - } - > - - - ) -} 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 index a892f33cf8..e1fca8c434 100644 --- 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 @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { OverlayTrigger, Tooltip } from 'react-bootstrap' +import { OverlayTrigger, Tooltip } from 'react-bootstrap-5' import PropTypes from 'prop-types' export default function SymbolPaletteItem({ diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss index a0a1d4b716..03c55419bf 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss @@ -163,8 +163,8 @@ filter: var(--bs-btn-close-white-filter); } - margin-top: var(--spacing-04); - margin-left: var(--spacing-05); + margin-top: var(--spacing-05); + margin-left: var(--spacing-02); margin-right: var(--spacing-03); .symbol-palette-unavailable & { From 60cd553d10eb9784981e5cdecc85dc3b7e75f2fa Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Thu, 3 Apr 2025 23:26:54 +0200 Subject: [PATCH 03/27] Symbol palette: switch to 'OL' UI components and apply minor cosmetic changes --- .../components/symbol-palette-close-button.js | 13 ++++++---- .../components/symbol-palette-content.js | 5 ++-- .../components/symbol-palette-item.js | 25 +++++++++++-------- .../components/symbol-palette-search.js | 9 ++++--- .../bootstrap-5/modules/symbol-palette.scss | 6 ++--- 5 files changed, 32 insertions(+), 26 deletions(-) diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js index 6c776d1e24..839b5d1cd5 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -1,16 +1,19 @@ -import { Button } from 'react-bootstrap' import { useEditorContext } from '../../../shared/context/editor-context' +import { useTranslation } from 'react-i18next' export default function SymbolPaletteCloseButton() { const { toggleSymbolPalette } = useEditorContext() + const { t } = useTranslation() return ( -
- +
) } 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 index a170987793..cb5a9e3029 100644 --- 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 @@ -3,7 +3,6 @@ import { 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' @@ -67,11 +66,11 @@ export default function SymbolPaletteContent({ handleSelect }) {
-
+
+
-
+
{symbol.description}
-
{symbol.command}
+
+ {symbol.command} +
{symbol.notes && ( -
{symbol.notes}
+
+ {symbol.notes} +
)} - +
} + overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} > - + ) } + SymbolPaletteItem.propTypes = { symbol: PropTypes.shape({ codepoint: PropTypes.string.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 index cf5a1eb2a7..7d52a82874 100644 --- 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 @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' -import { FormControl } from 'react-bootstrap' +import OLFormControl from '@/features/ui/components/ol/ol-form-control' import useDebounce from '../../../shared/hooks/use-debounce' export default function SymbolPaletteSearch({ setInput, inputRef }) { @@ -24,10 +24,10 @@ export default function SymbolPaletteSearch({ setInput, inputRef }) { ) return ( - ) } + SymbolPaletteSearch.propTypes = { setInput: PropTypes.func.isRequired, inputRef: PropTypes.object.isRequired, -} +}; diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss index 03c55419bf..8afb7fe92b 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss @@ -154,6 +154,8 @@ .symbol-palette-close-button-outer { display: flex; + align-items: center; + margin-right: var(--spacing-01); } .symbol-palette-close-button { @@ -163,10 +165,6 @@ filter: var(--bs-btn-close-white-filter); } - margin-top: var(--spacing-05); - margin-left: var(--spacing-02); - margin-right: var(--spacing-03); - .symbol-palette-unavailable & { visibility: hidden; } From 7278004919f9ee6c3b0f46cba9c47418a36cbd08 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sun, 4 May 2025 16:55:45 +0200 Subject: [PATCH 04/27] Symbol Palette: get rid of @reach/tabs --- .../components/symbol-palette-body.js | 41 ++++++--- .../components/symbol-palette-close-button.js | 2 +- .../components/symbol-palette-content.js | 20 +++-- .../components/symbol-palette-search.js | 2 +- .../components/symbol-palette-tabs.js | 84 +++++++++++++++---- .../bootstrap-5/modules/symbol-palette.scss | 2 +- services/web/package.json | 1 - 7 files changed, 112 insertions(+), 40 deletions(-) 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 index c4f47e325d..3f9eb7fc5f 100644 --- 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 @@ -1,4 +1,3 @@ -import { TabPanels, TabPanel } from '@reach/tabs' import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' import SymbolPaletteItems from './symbol-palette-items' @@ -9,6 +8,7 @@ export default function SymbolPaletteBody({ filteredSymbols, handleSelect, focusInput, + activeCategoryId, }) { const { t } = useTranslation() @@ -17,7 +17,7 @@ export default function SymbolPaletteBody({ // note: include empty tab panels so that aria-controls on tabs can still reference the panel ids if (filteredSymbols) { return ( - <> +
{filteredSymbols.length ? ( {t('no_symbols_found')}
)} - - {categories.map(category => ( - - ))} - - + {categories.map(category => ( + ) } // not searching: show the symbols grouped by category return ( - - {categories.map(category => ( - +
+ {categories.map((category) => ( + ))} - +
) + + } SymbolPaletteBody.propTypes = { categories: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -58,4 +72,5 @@ SymbolPaletteBody.propTypes = { filteredSymbols: PropTypes.arrayOf(PropTypes.object), handleSelect: PropTypes.func.isRequired, focusInput: PropTypes.func.isRequired, + activeCategoryId: PropTypes.string.isRequired, } diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js index 839b5d1cd5..dbde7ca775 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -11,7 +11,7 @@ export default function SymbolPaletteCloseButton() { type="button" className="btn-close symbol-palette-close-button" onClick={toggleSymbolPalette} - aria-label={t('clear_search')} + aria-label={t('close')} >
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 index cb5a9e3029..b395cac53d 100644 --- 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 @@ -1,4 +1,3 @@ -import { Tabs } from '@reach/tabs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' @@ -10,8 +9,6 @@ import SymbolPaletteBody from './symbol-palette-body' import SymbolPaletteTabs from './symbol-palette-tabs' import SymbolPaletteCloseButton from './symbol-palette-close-button' -import '@reach/tabs/styles.css' - export default function SymbolPaletteContent({ handleSelect }) { const [input, setInput] = useState('') @@ -19,6 +16,7 @@ export default function SymbolPaletteContent({ handleSelect }) { // build the list of categories with translated labels const categories = useMemo(() => createCategories(t), [t]) + const [activeCategoryId, setActiveCategoryId] = useState(categories[0]?.id) // group the symbols by category const categorisedSymbols = useMemo( @@ -59,18 +57,23 @@ export default function SymbolPaletteContent({ handleSelect }) { inputRef.current.focus() } }, []) - return ( - +
- +
-
+
+ +
- +
) } SymbolPaletteContent.propTypes = { 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 index 7d52a82874..75d01f0119 100644 --- 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 @@ -42,4 +42,4 @@ export default function SymbolPaletteSearch({ setInput, inputRef }) { 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 index d53cd93ac0..80f0421f27 100644 --- 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 @@ -1,22 +1,76 @@ -import { TabList, Tab } from '@reach/tabs' import PropTypes from 'prop-types' +import { useState, useRef } from 'react' + + +export default function SymbolPaletteTabs({ + categories, + activeCategoryId, + setActiveCategoryId, +}) { + + const buttonRefs = useRef([]) + const focusTab = (index) => { + setActiveCategoryId(categories[index].id) + buttonRefs.current[index]?.focus() + } + + const handleKeyDown = (e, index) => { + switch (e.key) { + case 'ArrowRight': + focusTab((index + 1) % categories.length) + break + case 'ArrowLeft': + focusTab((index - 1 + categories.length) % categories.length) + break + case 'Home': + case 'PageUp': + focusTab(0) + break + case 'End': + case 'PageDown': + focusTab(categories.length - 1) + break + default: + break + } + } -export default function SymbolPaletteTabs({ categories }) { return ( - - {categories.map(category => ( - - {category.label} - - ))} - +
+ {categories.map((category, index) => { + const selected = activeCategoryId === category.id + return ( + + ) + })} +
) } + SymbolPaletteTabs.propTypes = { - categories: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - }) - ).isRequired, + categories: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + })).isRequired, + activeCategoryId: PropTypes.string.isRequired, + setActiveCategoryId: PropTypes.func.isRequired, } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss index 8afb7fe92b..9aefcb4084 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss @@ -155,7 +155,7 @@ .symbol-palette-close-button-outer { display: flex; align-items: center; - margin-right: var(--spacing-01); + margin-right: var(--spacing-05); } .symbol-palette-close-button { diff --git a/services/web/package.json b/services/web/package.json index cd02c42e32..b0cee1af06 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -213,7 +213,6 @@ "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", "@prettier/plugin-pug": "^3.4.0", - "@reach/tabs": "0.18.0", "@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", From e8b4b2c6d3c1f9329e549c1ab20c5539b1383fb1 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 5 May 2025 03:27:23 +0200 Subject: [PATCH 05/27] Symbol Palette: improve keyboard input experience --- .../components/symbol-palette-close-button.js | 12 ++- .../components/symbol-palette-item.js | 22 ++++-- .../components/symbol-palette-items.js | 78 +++++++++++++------ 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js index dbde7ca775..0a2d3c02fc 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -1,19 +1,29 @@ import { useEditorContext } from '../../../shared/context/editor-context' import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' export default function SymbolPaletteCloseButton() { const { toggleSymbolPalette } = useEditorContext() const { t } = useTranslation() + const handleClick = () => { + toggleSymbolPalette() + window.dispatchEvent(new CustomEvent('editor:focus')) + } + return (
) } + +SymbolPaletteCloseButton.propTypes = { + focusInput: PropTypes.func, +} 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 index 1369534106..400da81b1e 100644 --- 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 @@ -1,16 +1,27 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, forwardRef } from 'react' import PropTypes from 'prop-types' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -export default function SymbolPaletteItem({ +const SymbolPaletteItem = forwardRef(function ({ focused, handleSelect, handleKeyDown, symbol, -}) { +}, ref) { const buttonRef = useRef(null) - // call focus() on this item when appropriate + // Forward internal ref to parent + useEffect(() => { + if (ref) { + if (typeof ref === 'function') { + ref(buttonRef.current) + } else { + ref.current = buttonRef.current + } + } + }, [ref]) + + // Focus the item when it becomes focused useEffect(() => { if ( focused && @@ -56,7 +67,7 @@ export default function SymbolPaletteItem({ ) -} +}) SymbolPaletteItem.propTypes = { symbol: PropTypes.shape({ @@ -70,3 +81,4 @@ SymbolPaletteItem.propTypes = { handleSelect: PropTypes.func.isRequired, focused: PropTypes.bool, } +export default SymbolPaletteItem 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 index 44835261f5..8d95439f5a 100644 --- 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 @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' +import { useEditorContext } from '../../../shared/context/editor-context' import SymbolPaletteItem from './symbol-palette-item' export default function SymbolPaletteItems({ @@ -8,54 +9,80 @@ export default function SymbolPaletteItems({ focusInput, }) { const [focusedIndex, setFocusedIndex] = useState(0) + const itemRefs = useRef([]) - // reset the focused item when the list of items changes useEffect(() => { + itemRefs.current = items.map((_, i) => itemRefs.current[i] || null) setFocusedIndex(0) }, [items]) - // navigate through items with left and right arrows + const getItemRects = () => { + return itemRefs.current.map(ref => ref?.getBoundingClientRect?.() ?? null) + } + const { toggleSymbolPalette } = useEditorContext() + const handleKeyDown = useCallback( event => { - if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) { - return - } + if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + + const rects = getItemRects() + const currentRect = rects[focusedIndex] + if (!currentRect) return + + let newIndex = focusedIndex switch (event.key) { - // focus previous item case 'ArrowLeft': - case 'ArrowUp': - setFocusedIndex(index => (index > 0 ? index - 1 : items.length - 1)) + newIndex = focusedIndex > 0 ? focusedIndex - 1 : items.length - 1 break - - // focus next item case 'ArrowRight': - case 'ArrowDown': - setFocusedIndex(index => (index < items.length - 1 ? index + 1 : 0)) + newIndex = focusedIndex < items.length - 1 ? focusedIndex + 1 : 0 break + case 'ArrowUp': + case 'ArrowDown': { + const direction = event.key === 'ArrowUp' ? -1 : 1 + const candidates = rects + .map((rect, i) => ({ rect, i })) + .filter(({ rect }, i) => + i !== focusedIndex && + rect && + Math.abs(rect.x - currentRect.x) < currentRect.width * 0.8 && + (direction === -1 ? rect.y < currentRect.y : rect.y > currentRect.y) + ) - // focus first item + if (candidates.length > 0) { + const closest = candidates.reduce((a, b) => + Math.abs(b.rect.y - currentRect.y) < Math.abs(a.rect.y - currentRect.y) ? b : a + ) + newIndex = closest.i + } + break + } case 'Home': - setFocusedIndex(0) + newIndex = 0 break - - // focus last item case 'End': - setFocusedIndex(items.length - 1) + newIndex = items.length - 1 break - - // allow the default action case 'Enter': case ' ': + handleSelect(items[focusedIndex]) + toggleSymbolPalette() + break + case 'Escape': + toggleSymbolPalette() + window.dispatchEvent(new CustomEvent('editor:focus')) break - // any other key returns focus to the input default: focusInput() - break + return } + + event.preventDefault() + setFocusedIndex(newIndex) }, - [focusInput, items.length] + [focusedIndex, items, focusInput, handleSelect] ) return ( @@ -70,11 +97,15 @@ export default function SymbolPaletteItems({ }} handleKeyDown={handleKeyDown} focused={index === focusedIndex} + ref={el => { + itemRefs.current[index] = el + }} /> ))}
) } + SymbolPaletteItems.propTypes = { items: PropTypes.arrayOf( PropTypes.shape({ @@ -84,3 +115,4 @@ SymbolPaletteItems.propTypes = { handleSelect: PropTypes.func.isRequired, focusInput: PropTypes.func.isRequired, } + From 1429e10f3c93212a30d1f650e9d6b03f8399e401 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 19 May 2025 17:42:42 +0200 Subject: [PATCH 06/27] Symbol Palette: make close button visible --- .../frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss index 9aefcb4084..83a84769db 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss @@ -162,6 +162,7 @@ --bs-btn-close-color: var(--symbol-palette-color); [data-theme='default'] & { + --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ffffff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e"); filter: var(--bs-btn-close-white-filter); } From 2e5ebb61e5b01e845df0e5e0510efa6a6990a73a Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 18 Jul 2025 00:34:25 +0200 Subject: [PATCH 07/27] `toggleSymbolPalette` now in `useEditorPropertiesContext` --- .../symbol-palette/components/symbol-palette-close-button.js | 4 ++-- .../symbol-palette/components/symbol-palette-items.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js index 0a2d3c02fc..98b4a447fb 100644 --- a/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js +++ b/services/web/frontend/js/features/symbol-palette/components/symbol-palette-close-button.js @@ -1,9 +1,9 @@ -import { useEditorContext } from '../../../shared/context/editor-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' export default function SymbolPaletteCloseButton() { - const { toggleSymbolPalette } = useEditorContext() + const { toggleSymbolPalette } = useEditorPropertiesContext() const { t } = useTranslation() const handleClick = () => { 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 index 8d95439f5a..ad8d004a09 100644 --- 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 @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' -import { useEditorContext } from '../../../shared/context/editor-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import SymbolPaletteItem from './symbol-palette-item' export default function SymbolPaletteItems({ @@ -19,7 +19,7 @@ export default function SymbolPaletteItems({ const getItemRects = () => { return itemRefs.current.map(ref => ref?.getBoundingClientRect?.() ?? null) } - const { toggleSymbolPalette } = useEditorContext() + const { toggleSymbolPalette } = useEditorPropertiesContext() const handleKeyDown = useCallback( event => { From 7ecee2e0aa6005190116c5e9633ddf2be15383b3 Mon Sep 17 00:00:00 2001 From: Christopher Hoskin <4855578+mans0954@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:01:53 +0100 Subject: [PATCH 08/27] Merge pull request #27255 from overleaf/revert-27252-revert-26843-csh-issue-26608-mongo8-dev-ci Revert "Revert "Upgrade the dev environment and CI to mongo 8"" GitOrigin-RevId: 5074b012504e65240017f1fde9b0d8d04c7b8b61 --- server-ce/test/docker-compose.yml | 2 +- services/chat/docker-compose.ci.yml | 2 +- services/chat/docker-compose.yml | 2 +- services/contacts/docker-compose.ci.yml | 2 +- services/contacts/docker-compose.yml | 2 +- services/docstore/docker-compose.ci.yml | 2 +- services/docstore/docker-compose.yml | 2 +- services/document-updater/docker-compose.ci.yml | 2 +- services/document-updater/docker-compose.yml | 2 +- services/history-v1/docker-compose.ci.yml | 2 +- services/history-v1/docker-compose.yml | 2 +- services/notifications/docker-compose.ci.yml | 2 +- services/notifications/docker-compose.yml | 2 +- services/project-history/docker-compose.ci.yml | 2 +- services/project-history/docker-compose.yml | 2 +- services/web/docker-compose.ci.yml | 2 +- services/web/docker-compose.yml | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/server-ce/test/docker-compose.yml b/server-ce/test/docker-compose.yml index 029b73fc62..1652baeae9 100644 --- a/server-ce/test/docker-compose.yml +++ b/server-ce/test/docker-compose.yml @@ -35,7 +35,7 @@ services: MAILTRAP_PASSWORD: 'password-for-mailtrap' mongo: - image: mongo:6.0 + image: mongo:8.0.11 command: '--replSet overleaf' volumes: - ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.ci.yml b/services/chat/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/chat/docker-compose.ci.yml +++ b/services/chat/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.yml b/services/chat/docker-compose.yml index ddc5f9e698..e7b8ce7385 100644 --- a/services/chat/docker-compose.yml +++ b/services/chat/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.ci.yml b/services/contacts/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/contacts/docker-compose.ci.yml +++ b/services/contacts/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.yml b/services/contacts/docker-compose.yml index 6c77ef5e31..474ea224f8 100644 --- a/services/contacts/docker-compose.yml +++ b/services/contacts/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.ci.yml b/services/docstore/docker-compose.ci.yml index 40decc4aea..cdb4783c5a 100644 --- a/services/docstore/docker-compose.ci.yml +++ b/services/docstore/docker-compose.ci.yml @@ -47,7 +47,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.yml b/services/docstore/docker-compose.yml index 8c11eb5a91..a9099c7e7b 100644 --- a/services/docstore/docker-compose.yml +++ b/services/docstore/docker-compose.yml @@ -49,7 +49,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index ca15f35fef..c6ec24a84b 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index cf7c9a2eb6..c1b23c11c5 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -57,7 +57,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index da664d6b30..cf6ec3357d 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -75,7 +75,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index 22b739abf9..3a33882d28 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -83,7 +83,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.ci.yml b/services/notifications/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/notifications/docker-compose.ci.yml +++ b/services/notifications/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.yml b/services/notifications/docker-compose.yml index 081bbfa002..e43e9aeef5 100644 --- a/services/notifications/docker-compose.yml +++ b/services/notifications/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index ca15f35fef..c6ec24a84b 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index eeca03de6e..dd3c6468fe 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -57,7 +57,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index 33b5a3ca2e..8376103315 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -95,7 +95,7 @@ services: image: redis:7.4.3 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 logging: driver: none command: --replSet overleaf diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 069c1e77de..e0a4a064c5 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -91,7 +91,7 @@ services: image: redis:7.4.3 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js From 5d79cf18c0b880e39a2679b58c66721c93bf25c5 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 17 Jul 2025 14:25:48 +0100 Subject: [PATCH 09/27] Define all initial roles GitOrigin-RevId: ad613bad4d8a47e327281e90b5475e989a3ccec4 --- services/web/types/admin-capabilities.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/web/types/admin-capabilities.ts b/services/web/types/admin-capabilities.ts index 7d87c77a15..0c98d7df04 100644 --- a/services/web/types/admin-capabilities.ts +++ b/services/web/types/admin-capabilities.ts @@ -1,3 +1,10 @@ export type AdminCapability = 'modify-user-email' | 'view-project' -export type AdminRole = 'engineering' +export type AdminRole = + | 'engagement' + | 'engineering' + | 'finance' + | 'product' + | 'sales' + | 'support' + | 'support_tier_1' From 868d562d96ba768b96bd2d0ec10591646a992fce Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 21 Jul 2025 11:53:05 +0200 Subject: [PATCH 10/27] Support password-fallbackPassword array in requireBasicAuth (#27237) GitOrigin-RevId: 33b15a05996bfa0190041f347772867a9667e2ca --- .../AuthenticationController.js | 17 +- .../AuthenticationControllerTests.js | 327 ++++++++++++++++++ 2 files changed, 343 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 7a97d2ac9c..99c418df1b 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -36,7 +36,22 @@ function send401WithChallenge(res) { function checkCredentials(userDetailsMap, user, password) { const expectedPassword = userDetailsMap.get(user) const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password - const isValid = userExists && tsscmp(expectedPassword, password) + + let isValid = false + if (userExists) { + if (Array.isArray(expectedPassword)) { + const isValidPrimary = Boolean( + expectedPassword[0] && tsscmp(expectedPassword[0], password) + ) + const isValidFallback = Boolean( + expectedPassword[1] && tsscmp(expectedPassword[1], password) + ) + isValid = isValidPrimary || isValidFallback + } else { + isValid = tsscmp(expectedPassword, password) + } + } + if (!isValid) { logger.err({ user }, 'invalid login details') } diff --git a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js index 0e4f675b1b..1fa3aba6a6 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js +++ b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js @@ -1500,4 +1500,331 @@ describe('AuthenticationController', function () { }) }) }) + + describe('checkCredentials', function () { + beforeEach(function () { + this.userDetailsMap = new Map() + this.logger.err = sinon.stub() + this.Metrics.inc = sinon.stub() + }) + + describe('with valid credentials', function () { + describe('single password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'correctpassword' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + + describe('array with primary password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'primary' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + + describe('array with fallback password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'fallback' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + }) + + describe('with invalid credentials', function () { + describe('unknown user', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'unknownuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'unknownuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + + describe('wrong password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'wrongpassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'fail', + } + ) + }) + }) + + describe('wrong password with array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'wrongpassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'fail', + } + ) + }) + }) + + describe('null user entry', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', null) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics for unknown user', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + + describe('empty primary password in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'fallback' + ) + }) + + it('should return true with fallback password', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + }) + + describe('empty fallback password in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', '']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'primary' + ) + }) + + it('should return true with primary password', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + }) + + describe('both passwords empty in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['', '']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + }) + + describe('empty single password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', '') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics for unknown user', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + }) + }) }) From d5b5710d018dea1f3ba5e84fd4869af18c99c756 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 21 Jul 2025 11:53:48 +0200 Subject: [PATCH 11/27] Add docModified hook in ds-mobile-app module (#27196) * Add docModified hook in ds-mobile-app module * use Object.entries when iterating over promises * avoid project lookup * update tests GitOrigin-RevId: 88676746f56558a97ce31010b57f5eeb254fefef --- .../Features/Documents/DocumentController.mjs | 4 ++++ .../web/app/src/infrastructure/Modules.js | 3 +-- .../src/Documents/DocumentController.test.mjs | 21 +++++++++++++++++++ services/web/types/web-module.ts | 5 ++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/Features/Documents/DocumentController.mjs b/services/web/app/src/Features/Documents/DocumentController.mjs index 6998c0b36a..9a16811894 100644 --- a/services/web/app/src/Features/Documents/DocumentController.mjs +++ b/services/web/app/src/Features/Documents/DocumentController.mjs @@ -7,6 +7,7 @@ import logger from '@overleaf/logger' import _ from 'lodash' import { plainTextResponse } from '../../infrastructure/Response.js' import { expressify } from '@overleaf/promise-utils' +import Modules from '../../infrastructure/Modules.js' async function getDocument(req, res) { const { Project_id: projectId, doc_id: docId } = req.params @@ -92,6 +93,9 @@ async function setDocument(req, res) { { docId, projectId }, 'finished receiving set document request from api (docupdater)' ) + + await Modules.promises.hooks.fire('docModified', projectId, docId) + res.json(result) } diff --git a/services/web/app/src/infrastructure/Modules.js b/services/web/app/src/infrastructure/Modules.js index 20975a3642..aea3aeb087 100644 --- a/services/web/app/src/infrastructure/Modules.js +++ b/services/web/app/src/infrastructure/Modules.js @@ -150,8 +150,7 @@ async function linkedFileAgentsIncludes() { async function attachHooks() { for (const module of await modules()) { const { promises, ...hooks } = module.hooks || {} - for (const hook in promises || {}) { - const method = promises[hook] + for (const [hook, method] of Object.entries(promises || {})) { attachHook(hook, method) } for (const hook in hooks || {}) { diff --git a/services/web/test/unit/src/Documents/DocumentController.test.mjs b/services/web/test/unit/src/Documents/DocumentController.test.mjs index e3fe3bdec2..b683cc5d14 100644 --- a/services/web/test/unit/src/Documents/DocumentController.test.mjs +++ b/services/web/test/unit/src/Documents/DocumentController.test.mjs @@ -87,6 +87,14 @@ describe('DocumentController', function () { }, } + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves(), + }, + }, + } + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ default: ctx.ProjectGetter, })) @@ -113,6 +121,10 @@ describe('DocumentController', function () { default: ctx.ChatApiHandler, })) + vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({ + default: ctx.Modules, + })) + ctx.DocumentController = (await import(MODULE_PATH)).default }) @@ -208,6 +220,15 @@ describe('DocumentController', function () { it('should return a successful response', function (ctx) { ctx.res.success.should.equal(true) }) + + it('should call the docModified hook', function (ctx) { + sinon.assert.calledWith( + ctx.Modules.promises.hooks.fire, + 'docModified', + ctx.project._id, + ctx.doc._id + ) + }) }) describe("when the document doesn't exist", function () { diff --git a/services/web/types/web-module.ts b/services/web/types/web-module.ts index 298f430df2..f6b59cdf6f 100644 --- a/services/web/types/web-module.ts +++ b/services/web/types/web-module.ts @@ -53,7 +53,10 @@ export type WebModule = { apply: (webRouter: any, privateApiRouter: any, publicApiRouter: any) => void } hooks?: { - [name: string]: (args: any[]) => void + promises?: { + [name: string]: (...args: any[]) => Promise + } + [name: string]: ((...args: any[]) => void) | any } middleware?: { [name: string]: RequestHandler From 0778bab9103c1441ba4101319d08e65490d7b2d5 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:50:29 +0100 Subject: [PATCH 12/27] Merge pull request #27254 from overleaf/td-project-dashboard-cookie-banner Implement React cookie banner on project dashboard GitOrigin-RevId: 95d2778d7ce7cb3054a06b06486b815a3453a623 --- services/web/app/views/_cookie_banner.pug | 8 +-- .../web/app/views/general/post-gateway.pug | 2 +- services/web/app/views/layout-marketing.pug | 2 +- services/web/app/views/layout-react.pug | 2 +- .../web/app/views/layout-website-redesign.pug | 2 +- .../project/editor/new_from_template.pug | 2 +- .../app/views/project/ide-react-detached.pug | 2 +- services/web/app/views/project/list-react.pug | 1 + .../app/views/project/token/access-react.pug | 2 +- .../views/project/token/sharing-updates.pug | 2 +- .../web/frontend/extracted-translations.json | 4 ++ .../js/features/cookie-banner/index.js | 53 ----------------- .../js/features/cookie-banner/index.ts | 32 ++++++++++ .../js/features/cookie-banner/utils.ts | 43 ++++++++++++++ .../components/project-list-ds-nav.tsx | 2 + .../components/project-list-root.tsx | 10 +++- .../js/shared/components/cookie-banner.tsx | 58 +++++++++++++++++++ .../pages/project-list-ds-nav.scss | 18 +++++- services/web/locales/en.json | 4 ++ services/web/types/window.ts | 1 + 20 files changed, 181 insertions(+), 69 deletions(-) delete mode 100644 services/web/frontend/js/features/cookie-banner/index.js create mode 100644 services/web/frontend/js/features/cookie-banner/index.ts create mode 100644 services/web/frontend/js/features/cookie-banner/utils.ts create mode 100644 services/web/frontend/js/shared/components/cookie-banner.tsx diff --git a/services/web/app/views/_cookie_banner.pug b/services/web/app/views/_cookie_banner.pug index 56974326cd..7cbc569bc1 100644 --- a/services/web/app/views/_cookie_banner.pug +++ b/services/web/app/views/_cookie_banner.pug @@ -1,13 +1,13 @@ -section.cookie-banner.hidden-print.hidden(aria-label='Cookie banner') - .cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our cookie policy. +section.cookie-banner.hidden-print.hidden(aria-label=translate('cookie_banner')) + .cookie-banner-content !{translate('cookie_banner_info', {}, [{ name: 'a', attrs: { href: '/legal#Cookies' }}])} .cookie-banner-actions button( type='button' class='btn btn-link btn-sm' data-ol-cookie-banner-set-consent='essential' - ) Essential cookies only + ) #{translate('essential_cookies_only')} button( type='button' class='btn btn-primary btn-sm' data-ol-cookie-banner-set-consent='all' - ) Accept all cookies + ) #{translate('accept_all_cookies')} diff --git a/services/web/app/views/general/post-gateway.pug b/services/web/app/views/general/post-gateway.pug index c6bbc92d01..86f379ac1b 100644 --- a/services/web/app/views/general/post-gateway.pug +++ b/services/web/app/views/general/post-gateway.pug @@ -4,7 +4,7 @@ block vars - var suppressNavbar = true - var suppressFooter = true - var suppressSkipToContent = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true block content .content.content-alt diff --git a/services/web/app/views/layout-marketing.pug b/services/web/app/views/layout-marketing.pug index b54c30f033..26e4eb539d 100644 --- a/services/web/app/views/layout-marketing.pug +++ b/services/web/app/views/layout-marketing.pug @@ -24,7 +24,7 @@ block body else include layout/fat-footer - if typeof suppressCookieBanner == 'undefined' + if typeof suppressPugCookieBanner == 'undefined' include _cookie_banner if bootstrapVersion === 5 diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug index 94ff3ba247..e9c4c932c4 100644 --- a/services/web/app/views/layout-react.pug +++ b/services/web/app/views/layout-react.pug @@ -69,5 +69,5 @@ block body else include layout/fat-footer-react-bootstrap-5 - if typeof suppressCookieBanner === 'undefined' + if typeof suppressPugCookieBanner === 'undefined' include _cookie_banner diff --git a/services/web/app/views/layout-website-redesign.pug b/services/web/app/views/layout-website-redesign.pug index 61ed83043b..aa7fea9f07 100644 --- a/services/web/app/views/layout-website-redesign.pug +++ b/services/web/app/views/layout-website-redesign.pug @@ -27,7 +27,7 @@ block body else include layout/fat-footer-website-redesign - if typeof suppressCookieBanner == 'undefined' + if typeof suppressPugCookieBanner == 'undefined' include _cookie_banner block contactModal diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index c84288a21a..a5dc3ff33c 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -2,7 +2,7 @@ extends ../../layout-marketing block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block content diff --git a/services/web/app/views/project/ide-react-detached.pug b/services/web/app/views/project/ide-react-detached.pug index ca1a178bbf..fa695b1af5 100644 --- a/services/web/app/views/project/ide-react-detached.pug +++ b/services/web/app/views/project/ide-react-detached.pug @@ -7,7 +7,7 @@ block vars - var suppressNavbar = true - var suppressFooter = true - var suppressSkipToContent = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - metadata.robotsNoindexNofollow = true block content diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index 78103e75a6..47bff344b6 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -7,6 +7,7 @@ block vars - const suppressNavContentLinks = true - const suppressNavbar = true - const suppressFooter = true + - const suppressPugCookieBanner = true block append meta meta( diff --git a/services/web/app/views/project/token/access-react.pug b/services/web/app/views/project/token/access-react.pug index 80b91f1a99..6c01ad15b1 100644 --- a/services/web/app/views/project/token/access-react.pug +++ b/services/web/app/views/project/token/access-react.pug @@ -5,7 +5,7 @@ block entrypointVar block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block append meta diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug index d1818be0af..2f67e5a3c1 100644 --- a/services/web/app/views/project/token/sharing-updates.pug +++ b/services/web/app/views/project/token/sharing-updates.pug @@ -5,7 +5,7 @@ block entrypointVar block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block append meta diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ef2a9c6a2c..2775c04601 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -35,6 +35,7 @@ "about_to_remove_user_preamble": "", "about_to_trash_projects": "", "abstract": "", + "accept_all_cookies": "", "accept_and_continue": "", "accept_change": "", "accept_change_error_description": "", @@ -332,6 +333,8 @@ "continue_to": "", "continue_using_free_features": "", "continue_with_free_plan": "", + "cookie_banner": "", + "cookie_banner_info": "", "copied": "", "copy": "", "copy_code": "", @@ -544,6 +547,7 @@ "error_opening_document_detail": "", "error_performing_request": "", "error_processing_file": "", + "essential_cookies_only": "", "example_project": "", "existing_plan_active_until_term_end": "", "expand": "", diff --git a/services/web/frontend/js/features/cookie-banner/index.js b/services/web/frontend/js/features/cookie-banner/index.js deleted file mode 100644 index 3d9b2b8d6c..0000000000 --- a/services/web/frontend/js/features/cookie-banner/index.js +++ /dev/null @@ -1,53 +0,0 @@ -import getMeta from '@/utils/meta' - -function loadGA() { - if (window.olLoadGA) { - window.olLoadGA() - } -} - -function setConsent(value) { - document.querySelector('.cookie-banner').classList.add('hidden') - const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain - const oneYearInSeconds = 60 * 60 * 24 * 365 - const cookieAttributes = - '; path=/' + - '; domain=' + - cookieDomain + - '; max-age=' + - oneYearInSeconds + - '; SameSite=Lax; Secure' - if (value === 'all') { - document.cookie = 'oa=1' + cookieAttributes - loadGA() - window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true })) - } else { - document.cookie = 'oa=0' + cookieAttributes - window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false })) - } -} - -if ( - getMeta('ol-ExposedSettings').gaToken || - getMeta('ol-ExposedSettings').gaTokenV4 || - getMeta('ol-ExposedSettings').propensityId || - getMeta('ol-ExposedSettings').hotjarId -) { - document - .querySelectorAll('[data-ol-cookie-banner-set-consent]') - .forEach(el => { - el.addEventListener('click', function (e) { - e.preventDefault() - const consentType = el.getAttribute('data-ol-cookie-banner-set-consent') - setConsent(consentType) - }) - }) - - const oaCookie = document.cookie.split('; ').find(c => c.startsWith('oa=')) - if (!oaCookie) { - const cookieBannerEl = document.querySelector('.cookie-banner') - if (cookieBannerEl) { - cookieBannerEl.classList.remove('hidden') - } - } -} diff --git a/services/web/frontend/js/features/cookie-banner/index.ts b/services/web/frontend/js/features/cookie-banner/index.ts new file mode 100644 index 0000000000..2ea97e875a --- /dev/null +++ b/services/web/frontend/js/features/cookie-banner/index.ts @@ -0,0 +1,32 @@ +import { + CookieConsentValue, + cookieBannerRequired, + hasMadeCookieChoice, + setConsent, +} from '@/features/cookie-banner/utils' + +function toggleCookieBanner(hidden: boolean) { + const cookieBannerEl = document.querySelector('.cookie-banner') + if (cookieBannerEl) { + cookieBannerEl.classList.toggle('hidden', hidden) + } +} + +if (cookieBannerRequired()) { + document + .querySelectorAll('[data-ol-cookie-banner-set-consent]') + .forEach(el => { + el.addEventListener('click', function (e) { + e.preventDefault() + toggleCookieBanner(true) + const consentType = el.getAttribute( + 'data-ol-cookie-banner-set-consent' + ) as CookieConsentValue | null + setConsent(consentType) + }) + }) + + if (!hasMadeCookieChoice()) { + toggleCookieBanner(false) + } +} diff --git a/services/web/frontend/js/features/cookie-banner/utils.ts b/services/web/frontend/js/features/cookie-banner/utils.ts new file mode 100644 index 0000000000..5c045d4e71 --- /dev/null +++ b/services/web/frontend/js/features/cookie-banner/utils.ts @@ -0,0 +1,43 @@ +import getMeta from '@/utils/meta' + +export type CookieConsentValue = 'all' | 'essential' + +function loadGA() { + if (window.olLoadGA) { + window.olLoadGA() + } +} + +export function setConsent(value: CookieConsentValue | null) { + const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain + const oneYearInSeconds = 60 * 60 * 24 * 365 + const cookieAttributes = + '; path=/' + + '; domain=' + + cookieDomain + + '; max-age=' + + oneYearInSeconds + + '; SameSite=Lax; Secure' + if (value === 'all') { + document.cookie = 'oa=1' + cookieAttributes + loadGA() + window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true })) + } else { + document.cookie = 'oa=0' + cookieAttributes + window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false })) + } +} + +export function cookieBannerRequired() { + const exposedSettings = getMeta('ol-ExposedSettings') + return Boolean( + exposedSettings.gaToken || + exposedSettings.gaTokenV4 || + exposedSettings.propensityId || + exposedSettings.hotjarId + ) +} + +export function hasMadeCookieChoice() { + return document.cookie.split('; ').some(c => c.startsWith('oa=')) +} diff --git a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx index 3d24f9845c..07319ffaf1 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx @@ -20,6 +20,7 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer' import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav' import SystemMessages from '@/shared/components/system-messages' import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import CookieBanner from '@/shared/components/cookie-banner' export function ProjectListDsNav() { const navbarProps = getMeta('ol-navbar') @@ -125,6 +126,7 @@ export function ProjectListDsNav() {