From 04419cbad7d41d2b654d6f129ebaac31d43e8532 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 1 May 2026 06:18:44 -0500 Subject: [PATCH 1/2] Add a toolbar. This make the toolbar from the PG `mqeditor` an internal toolbar for the library. This makes some things cleaner. In particular, all of the options that were added to make the toolbar work externally are no longer needed (blurWithCursor, textFieldEnter, and textFieldExit) and were removed. The things those options were used for can be dealt with internally. Note that Bootstrap need to be loaded in the page since the toolbar uses Bootstrap tooltips, popovers, and buttons. Also the following screen reader issues that were fixed. * The result of typing an auto command was not being voiced. * If a toolbar button that inserts a LaTeX command is used its result was not voiced. * When a select occurs clear the textarea value in the next tick after setting it and selecting the textarea contents. This prevents screen readers from announcing its contents in the case that selection occurs when the input is focused. Also remove the browser highlight styles for selections. The browser styles conflict with the other styles in some browsers and don't look good. (I noticed that this was done in the Desmos code as well.) --- .stylelintrc.js | 2 - docs/Config.md | 68 +++-- package-lock.json | 43 ++++ package.json | 4 + public/input-test.css | 48 ++++ public/input-test.html | 301 +--------------------- public/input-test.js | 260 +++++++++++++++++++ public/visual-test.html | 2 +- rollup.config.mjs | 15 +- src/abstractFields.ts | 1 + src/commands/mathElements.ts | 4 +- src/commands/textElements.ts | 4 +- src/controller.ts | 2 + src/css/main.less | 1 + src/css/selections.less | 4 +- src/css/toolbar.less | 72 ++++++ src/options.ts | 71 +++++- src/services/focusBlur.ts | 2 +- src/services/saneKeyboardEvents.util.ts | 1 + src/services/textarea.ts | 13 + src/toolbar.ts | 326 ++++++++++++++++++++++++ test/focusBlur.test.ts | 2 +- webpack.config.js | 12 +- 23 files changed, 929 insertions(+), 329 deletions(-) create mode 100644 public/input-test.css create mode 100644 public/input-test.js create mode 100644 src/css/toolbar.less create mode 100644 src/toolbar.ts diff --git a/.stylelintrc.js b/.stylelintrc.js index 938dd24e..1deb4220 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,5 +1,3 @@ -/* eslint-env node */ - module.exports = { extends: ['stylelint-config-html', 'stylelint-config-standard'], plugins: [], diff --git a/docs/Config.md b/docs/Config.md index ef032947..6499a780 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -154,14 +154,60 @@ Despite that, the math field can still be focused when selected by a mouse. Static math fields default to `tabbable: false`, Editable math fields default to `tabbable: true`. -### blurWithCursor +### useToolbar -This is a method with the signature `(e: FocusEvent, mq?: AbstractMathQuill) => boolean`. If provided, this method will -be called anytime an editable math field loses focus (for example if the "Tab" key is pressed or the window loses -focus). If the method returns true, then the field will be blurred with the cursor left visible. This means that if -there is a selection in the field, it will not be cleared but will be given an inactive gray styling, and if there is -not a selection then the cursor will remain in the field but will stop blinking. This gives the appearance of no longer -being active but gives indicators as to where the cursor is. This is useful for implementing a toolbar. +If `useToolbar` is true then a toolbar will be shown when an editable field is focused. This is false by default. Note +that this must be set when the editable field is initialized, and cannot be changed afterward. Note that in order to use +the toolbar, the Bootstrap must be loaded in the page since the toolbar uses Bootstrap tooltips, popovers, and buttons. + +### toolbarButtons + +These are the buttons that will be shown in the toolbar. Each button is defined by an `id`, `latex` command, `tooltip`, +and `icon` all of which are strings. The `id` is an identifier for the button and must be unique. The `latex` command +is the command that will be injected into the editable field when the button is clicked. The `tooltip` is a brief +description of the button. It should include the equivalent keyboard characters that can be typed to inject the command +into the editable field without using the toolbar button. It will be shown when the button has focus or the mouse hovers +over the button. The `icon` is a latex input sequence that will be shown for the button. It must be valid latex that is +supported by MathQuill, and will be inserted into the button as a static math field. + +The default buttons are as follows. + +```js +[ + { id: 'frac', latex: '/', tooltip: 'fraction (/)', icon: '\\frac{\\text{ }}{\\text{ }}' }, + { id: 'abs', latex: '|', tooltip: 'absolute value (|)', icon: '|\\text{ }|' }, + { id: 'sqrt', latex: '\\sqrt', tooltip: 'square root (sqrt)', icon: '\\sqrt{\\text{ }}' }, + { id: 'nthroot', latex: '\\root', tooltip: 'nth root (root)', icon: '\\sqrt[\\text{ }]{\\text{ }}' }, + { id: 'exponent', latex: '^', tooltip: 'exponent (^)', icon: '\\text{ }^\\text{ }' }, + { id: 'infty', latex: '\\infty', tooltip: 'infinity (inf)', icon: '\\infty' }, + { id: 'pi', latex: '\\pi', tooltip: 'pi (pi)', icon: '\\pi' }, + { id: 'vert', latex: '\\vert', tooltip: 'such that (vert)', icon: '|' }, + { id: 'cup', latex: '\\cup', tooltip: 'union (union)', icon: '\\cup' }, + { id: 'text', latex: '\\text', tooltip: 'text mode (")', icon: 'Tt' } +]; +``` + +Note that buttons can be added and removed with the `addToolbarButtons` and `removeToolbarButtons` methods of the options +interface. For example, + +```js +mathField.options.addToolbarButtons( + { + id: 'subscript', + latex: '_', + tooltip: 'subscript (_)', + icon: '\\text{ }_\\text{ }' + }, + 5 +); +``` + +will add a subscript button in the fifth position of the toolbar (after the exponent button with the default buttons), +and `mathField.options.removeToolbarButtons('subscript')` will remove it. A button can also be added after an existing +button by passing the id of the button to insert after instead of a number. For example, with +`mathField.options.addToolbarButtons({ … }, 'exponent');` will insert the button after the exponent button. Multiple +buttons can be added or removed at once by passing an array of buttons or ids to the `addToolbarButtons` or +`removeToolbarButtons` methods, respectively. ## Handlers @@ -200,14 +246,6 @@ Called whenever Enter is pressed. This is called when the contents of the field might have been changed. This will be called with any edit, such as something being typed, deleted, or written with the API. Note that this may be called when nothing has actually changed. -### textBlockEnter(mathField) - -This is called whenever a text block is started. - -### textBlockExit(mathField) - -This is called whenver a text block is ended. - ## Changing Colors To change the foreground color, set both `color` and the `border-color` because some MathQuill symbols are implemented diff --git a/package-lock.json b/package-lock.json index cd185d65..5f9b78ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "@openwebwork/mathquill", "version": "0.11.2", "license": "MPL-2.0", + "dependencies": { + "bootstrap": "^5.3.8" + }, "devDependencies": { "@awmottaz/prettier-plugin-void-html": "^2.1.0", "@eslint/js": "^10.0.1", "@rollup/plugin-typescript": "^12.3.0", "@stylistic/eslint-plugin": "^5.10.0", + "@types/bootstrap": "^5.2.10", "@types/mocha": "^10.0.10", "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.4", @@ -1279,6 +1283,16 @@ "node": ">=20.0.0" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-typescript": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", @@ -1780,6 +1794,16 @@ "@types/node": "*" } }, + "node_modules/@types/bootstrap": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz", + "integrity": "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.2" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2817,6 +2841,25 @@ "dev": true, "license": "ISC" }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", diff --git a/package.json b/package.json index 50b35a60..c5bd0c57 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,15 @@ "format": "prettier --write .", "format:check": "prettier --check ." }, + "dependencies": { + "bootstrap": "^5.3.8" + }, "devDependencies": { "@awmottaz/prettier-plugin-void-html": "^2.1.0", "@eslint/js": "^10.0.1", "@rollup/plugin-typescript": "^12.3.0", "@stylistic/eslint-plugin": "^5.10.0", + "@types/bootstrap": "^5.2.10", "@types/mocha": "^10.0.10", "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.4", diff --git a/public/input-test.css b/public/input-test.css new file mode 100644 index 00000000..ea6a19ec --- /dev/null +++ b/public/input-test.css @@ -0,0 +1,48 @@ +.rule-container { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 20px 20px 0.5rem; +} + +.options-container { + display: flex; + gap: 0.25rem; + flex-direction: column; + margin: 20px 20px 0.5rem; +} + +.auto-options { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-width: 180px; +} + +input[type='text'] { + padding: 4px 6px 2px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: white; + font-size: 16px; + font-weight: normal; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 22px; +} + +input[type='text']:focus { + border-color: rgb(82 168 236 / 80%); + outline: 0; + box-shadow: + inset 0 1px 1px rgb(0 0 0 / 7.5%), + 0 0 0 0.2rem rgb(82 168 236 / 60%); +} + +span[id^='mq-answer'] { + direction: ltr; + padding: 4px 5px 2px; + border-radius: 4px !important; + background-color: white; + margin-right: 0; + margin-left: 0; +} diff --git a/public/input-test.html b/public/input-test.html index 941c0a1d..07f8a4ce 100644 --- a/public/input-test.html +++ b/public/input-test.html @@ -5,296 +5,21 @@ - + - - - + + + + - -
- - - -
- -
- - +
+
+ + + +
+
+
diff --git a/public/input-test.js b/public/input-test.js new file mode 100644 index 00000000..5386a5f8 --- /dev/null +++ b/public/input-test.js @@ -0,0 +1,260 @@ +/* global MathQuill */ +(() => { + const MQ = MathQuill.getInterface(); + const textInput = document.getElementById('AnSwEr0001'); + const latexInput = document.getElementById('MaThQuIlL_AnSwEr0001'); + const answerQuill = document.getElementById('mq-answer-AnSwEr0001'); + + const cfg = { + enableSpaceNavigation: true, + leftRightIntoCmdGoes: 'up', + restrictMismatchedBrackets: true, + sumStartsWithNEquals: true, + supSubsRequireOperand: true, + autoSubscriptNumerals: false, + typingSlashWritesDivisionSymbol: false, + typingAsteriskWritesTimesSymbol: false, + autoCommands: + 'pi sqrt root vert inf union abs deg AA angstrom ln log exp ' + + ['sin', 'cos', 'tan', 'sec', 'csc', 'cot'] + .reduce((a, t) => a.concat([t, `arc${t}`, `a${t}`]), []) + .join(' '), + rootsAreExponents: true, + logsChangeBase: true, + useToolbar: true + }; + + const mathField = MQ.MathField(answerQuill, { + ...cfg, + maxDepth: 10, + handlers: { + edit: (mq) => { + if (mq.text() !== '') { + textInput.value = mq.text(); + latexInput.value = mq.latex(); + } else { + textInput.value = ''; + latexInput.value = ''; + } + } + } + }); + mathField.latex(latexInput.value); + mathField.moveToLeftEnd(); + mathField.blur(); + + mathField.options.addToolbarButtons( + { + id: 'subscript', + latex: '_', + tooltip: 'subscript (_)', + icon: '\\text{ }_\\text{ }' + }, + 'exponent' + ); + + const optionsContainer = document.querySelector('.options-container'); + + for (const option in cfg) { + const optionDiv = document.createElement('div'); + optionDiv.classList.add('form-check'); + if (typeof cfg[option] === 'boolean') { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = option; + input.classList.add('form-check-input'); + if (cfg[option]) input.checked = true; + const label = document.createElement('label'); + label.setAttribute('for', option); + label.textContent = `Enable ${option}`; + label.classList.add('form-check-label'); + optionDiv.append(input, label); + optionsContainer.append(optionDiv); + input.addEventListener('change', () => { + mathField.options[option] = input.checked; + mathField.reflow(); + }); + } + } + + // Special case for leftRightIntoCmdGoes + { + const optionDiv = document.createElement('div'); + optionDiv.classList.add('d-flex', 'gap-3'); + const inputGroupLabel = document.createElement('span'); + inputGroupLabel.textContent = 'leftRightIntoCmdGoes: '; + const radioGroupContainer = document.createElement('div'); + const upInputContainer = document.createElement('div'); + upInputContainer.classList.add('form-check', 'form-check-inline'); + const upInput = document.createElement('input'); + upInput.type = 'radio'; + upInput.name = 'leftRightIntoCmdGoes'; + upInput.id = 'leftRightIntoCmdGoes_up'; + upInput.checked = true; + upInput.classList.add('form-check-input'); + const upLabel = document.createElement('label'); + upLabel.setAttribute('for', 'leftRightIntoCmdGoes_up'); + upLabel.textContent = 'up'; + upLabel.classList.add('form-check-label'); + upInputContainer.append(upInput, upLabel); + const downInputContainer = document.createElement('div'); + downInputContainer.classList.add('form-check', 'form-check-inline'); + const downInput = document.createElement('input'); + downInput.type = 'radio'; + downInput.name = 'leftRightIntoCmdGoes'; + downInput.id = 'leftRightIntoCmdGoes_down'; + downInput.classList.add('form-check-input'); + const downLabel = document.createElement('label'); + downLabel.setAttribute('for', 'leftRightIntoCmdGoes_down'); + downLabel.textContent = 'down'; + downLabel.classList.add('form-check-label'); + downInputContainer.append(downInput, downLabel); + radioGroupContainer.append(upInputContainer, downInputContainer); + optionDiv.append(inputGroupLabel, radioGroupContainer); + optionsContainer.append(optionDiv); + upInput.addEventListener('click', () => (mathField.options.leftRightIntoCmdGoes = 'up')); + downInput.addEventListener('click', () => (mathField.options.leftRightIntoCmdGoes = 'down')); + } + + // Special case for autoCommands + { + const optionDiv = document.createElement('div'); + optionDiv.classList.add('auto-options'); + const optionLabel = document.createElement('label'); + optionLabel.textContent = 'autoCommands:'; + optionLabel.setAttribute('for', 'currentAutoCommands'); + const optionSelect = document.createElement('select'); + optionSelect.id = 'currentAutoCommands'; + optionSelect.name = 'currentAutoCommands'; + optionSelect.multiple = 'true'; + optionSelect.size = '10'; + optionSelect.classList.add('form-select'); + + for (const cmd of Object.keys(mathField.options.autoCommands).sort()) { + if (cmd === '_maxLength') continue; + const cmdOption = document.createElement('option'); + cmdOption.value = cmd; + cmdOption.textContent = cmd; + optionSelect.add(cmdOption); + } + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.textContent = 'Delete Selected'; + deleteButton.classList.add('btn', 'btn-primary'); + deleteButton.addEventListener('click', () => { + for (const cmd of Array.from(optionSelect.selectedOptions)) { + mathField.options.removeAutoCommands(cmd.value); + cmd.remove(); + } + }); + + const optionInput = document.createElement('input'); + optionInput.type = 'text'; + optionInput.name = 'add-auto-command'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.textContent = 'Add Command'; + addButton.classList.add('btn', 'btn-primary'); + addButton.addEventListener('click', () => { + try { + mathField.options.addAutoCommands(optionInput.value); + } catch (e) { + alert(e); + return; + } + const cmdOption = document.createElement('option'); + cmdOption.value = optionInput.value.trim(); + cmdOption.textContent = optionInput.value.trim(); + let added = false; + for (const opt of optionSelect.options) { + if (cmdOption.value === opt.value) { + added = true; + alert('This command has already been added.'); + break; + } else if (cmdOption.value < opt.value) { + added = true; + optionSelect.add(cmdOption, opt); + break; + } + } + if (!added) optionSelect.add(cmdOption); + optionInput.value = ''; + }); + + optionDiv.append(optionLabel, optionSelect, deleteButton, optionInput, addButton); + optionsContainer.append(optionDiv); + } + + // Special case for autoOperatorNames + { + const optionDiv = document.createElement('div'); + optionDiv.classList.add('auto-options'); + const optionLabel = document.createElement('label'); + optionLabel.textContent = 'autoOperatorNames:'; + optionLabel.setAttribute('for', 'currentAutoOperatorNames'); + const optionSelect = document.createElement('select'); + optionSelect.id = 'currentAutoOperatorNames'; + optionSelect.name = 'currentAutoOperatorNames'; + optionSelect.multiple = 'true'; + optionSelect.size = '10'; + optionSelect.classList.add('form-select'); + + for (const cmd of Object.keys(mathField.options.autoOperatorNames).sort()) { + if (cmd === '_maxLength') continue; + const cmdOption = document.createElement('option'); + cmdOption.value = cmd; + cmdOption.textContent = cmd; + optionSelect.add(cmdOption); + } + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.textContent = 'Delete Selected'; + deleteButton.classList.add('btn', 'btn-primary'); + deleteButton.addEventListener('click', () => { + for (const cmd of Array.from(optionSelect.selectedOptions)) { + mathField.options.removeAutoOperatorNames(cmd.value); + cmd.remove(); + } + }); + + const optionInput = document.createElement('input'); + optionInput.type = 'text'; + optionInput.name = 'add-auto-operator-name'; + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.textContent = 'Add Operator Name'; + addButton.classList.add('btn', 'btn-primary'); + addButton.addEventListener('click', () => { + try { + mathField.options.addAutoOperatorNames(optionInput.value); + } catch (e) { + alert(e); + return; + } + const cmdOption = document.createElement('option'); + cmdOption.value = optionInput.value.trim(); + cmdOption.textContent = optionInput.value.trim(); + let added = false; + for (const opt of optionSelect.options) { + if (cmdOption.value === opt.value) { + added = true; + alert('This operator name has already been added.'); + break; + } else if (cmdOption.value < opt.value) { + added = true; + optionSelect.add(cmdOption, opt); + break; + } + } + if (!added) optionSelect.add(cmdOption); + optionInput.value = ''; + }); + + optionDiv.append(optionLabel, optionSelect, deleteButton, optionInput, addButton); + optionsContainer.append(optionDiv); + } +})(); diff --git a/public/visual-test.html b/public/visual-test.html index 95d0b060..f310de53 100644 --- a/public/visual-test.html +++ b/public/visual-test.html @@ -64,7 +64,7 @@ color: white; } - .different-bgcolor.mq-editable-field .cursor { + .different-bgcolor.mq-editable-field .mq-cursor { border-color: white; } diff --git a/rollup.config.mjs b/rollup.config.mjs index 5b5bd444..19cfef80 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -5,10 +5,21 @@ export default [ { input: 'src/publicapi.ts', output: [ - { file: 'dist/index.cjs', format: 'cjs' }, - { file: 'dist/index.mjs', format: 'es' } + { + dir: 'dist', + format: 'cjs', + entryFileNames: 'index.cjs', + chunkFileNames: '[name].cjs' + }, + { + dir: 'dist', + format: 'es', + entryFileNames: 'index.mjs', + chunkFileNames: '[name].mjs' + } ], plugins: [typescript({ tsconfig: 'tsconfig-lib.json' })], + external: ['bootstrap'], onLog(level, log, handler) { // The circular dependencies that are detected don't matter once bundled. So hide the warnings about them. if (log.code === 'CIRCULAR_DEPENDENCY') return; diff --git a/src/abstractFields.ts b/src/abstractFields.ts index c39b6a74..fbfe77f6 100644 --- a/src/abstractFields.ts +++ b/src/abstractFields.ts @@ -164,6 +164,7 @@ export class EditableField extends AbstractMathQuill { if (cursor.selection) newCmd.replaces(cursor.replaceSelection()); if (cursor.parent?.prepareCommandInsertion(cursor, newCmd)) { newCmd.createLeftOf(cursor.show()); + this.__controller.aria.alert(newCmd.mathspeak({ createdLeftOf: cursor })); this.__controller.scrollHoriz(); } } else { diff --git a/src/commands/mathElements.ts b/src/commands/mathElements.ts index adc31485..7c464b64 100644 --- a/src/commands/mathElements.ts +++ b/src/commands/mathElements.ts @@ -584,7 +584,9 @@ export class Letter extends Variable { for (i = 1, l = this; i < str.length; ++i, l = l?.left); new Fragment(l, this).remove(); cursor.left = l?.left; - new LatexCmds[str](str).createLeftOf(cursor); + const cmd = new LatexCmds[str](str); + cmd.createLeftOf(cursor); + setTimeout(() => cursor.controller.aria.alert(cmd.mathspeak({ createdLeftOf: cursor }))); return; } str = str.slice(1); diff --git a/src/commands/textElements.ts b/src/commands/textElements.ts index 24b4e1e0..d54c0f8c 100644 --- a/src/commands/textElements.ts +++ b/src/commands/textElements.ts @@ -237,7 +237,7 @@ export class TextBlock extends BlockFocusBlur(deleteSelectTowardsMixin(TNode)) { this.fuseChildren(); } - this.getController()?.handle('textBlockExit'); + this.getController()?.toolbar?.enableButtons(); } fuseChildren() { @@ -260,7 +260,7 @@ export class TextBlock extends BlockFocusBlur(deleteSelectTowardsMixin(TNode)) { focus() { super.focus(); - this.getController()?.handle('textBlockEnter'); + this.getController()?.toolbar?.disableButtons(); } } diff --git a/src/controller.ts b/src/controller.ts index 2493d797..635a036d 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -13,6 +13,7 @@ import { MouseEventController } from 'services/mouse'; import { FocusBlurEvents } from 'services/focusBlur'; import { ExportText } from 'services/exportText'; import { TextAreaController } from 'services/textarea'; +import type { MathQuillToolbar } from 'src/toolbar'; import { Aria } from 'services/aria'; export class ControllerBase { @@ -27,6 +28,7 @@ export class ControllerBase { blurred?: boolean; textareaSpan?: HTMLSpanElement; textarea?: HTMLTextAreaElement; + toolbar?: MathQuillToolbar; mathspeakSpan?: HTMLElement; mathspeakId?: string | undefined; aria: Aria; diff --git a/src/css/main.less b/src/css/main.less index 08599cb7..8804efd9 100644 --- a/src/css/main.less +++ b/src/css/main.less @@ -7,3 +7,4 @@ @import 'selections.less'; @import 'textarea.less'; @import 'matrixed.less'; +@import 'toolbar.less'; diff --git a/src/css/selections.less b/src/css/selections.less index 35f75318..354fe1e7 100644 --- a/src/css/selections.less +++ b/src/css/selections.less @@ -14,9 +14,7 @@ & .mq-non-leaf, & .mq-scaled { background: #b4d5fe !important; - background: Highlight !important; - color: HighlightText; - border-color: HighlightText; + color: black; } .mq-matrixed { diff --git a/src/css/toolbar.less b/src/css/toolbar.less new file mode 100644 index 00000000..3e73c406 --- /dev/null +++ b/src/css/toolbar.less @@ -0,0 +1,72 @@ +.quill-toolbar { + position: absolute; + font-size: 0.75em; + direction: ltr; + display: flex; + flex-direction: column; + justify-content: start; + box-sizing: border-box; + border-radius: 4px; + border: 2px solid darkgray; + background-color: white; + right: 10px; + z-index: 1001; + overflow: hidden auto; + scrollbar-width: thin; + opacity: 1; + transition: opacity 500ms ease; + + .symbol-button { + box-sizing: border-box; + text-align: center; + flex-shrink: 0; + padding: 3px; + margin: 2px; + display: block; + width: 45px; + height: 45px; + border-radius: 4px; + background-image: linear-gradient(180deg, rgb(255 255 255 / 15%), rgb(255 255 255 / 0%)); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 15%), + 0 1px 1px rgb(0 0 0 / 7.5%); + + &:focus { + z-index: 9999; + } + + span[id^='icon-']:hover { + cursor: pointer; + } + + &:not([id^='text-mq-answer']) .mq-text-mode { + height: 10px; + width: 8px; + transform: translateY(2px); + background-color: skyblue !important; + } + + .mq-nthroot, + .mq-sup, + .mq-sub { + & > .mq-text-mode { + height: 6px; + width: 6px; + } + } + + .mq-sup > .mq-text-mode { + transform: translateY(2px); + } + + .mq-sub > .mq-text-mode { + transform: translateY(0); + } + + .mq-supsub { + height: 6px; + width: 6px; + margin-left: 2px; + } + } +} diff --git a/src/options.ts b/src/options.ts index 18407d4b..a169aa69 100644 --- a/src/options.ts +++ b/src/options.ts @@ -9,8 +9,6 @@ export type DirectionHandler = (dir: Direction, mq?: AbstractMathQuill) => void; export interface Handlers { enter?: Handler; edit?: Handler; - textBlockEnter?: Handler; - textBlockExit?: Handler; moveOutOf?: DirectionHandler; deleteOutOf?: DirectionHandler; selectOutOf?: DirectionHandler; @@ -18,6 +16,13 @@ export interface Handlers { downOutOf?: Handler; } +export interface ToolbarButton { + id: string; + latex: string; + tooltip: string; + icon: string; +} + export interface InputOptions { mouseEvents?: boolean; autoCommands?: string; @@ -43,8 +48,9 @@ export interface InputOptions { overrideTypedText?: (text: string) => void; overrideKeystroke?: (key: string, event: KeyboardEvent) => void; ignoreNextMousedown?: (e?: MouseEvent) => boolean; - blurWithCursor?: (e: FocusEvent, mq?: AbstractMathQuill) => boolean; tabbable?: boolean; + useToolbar?: boolean; + toolbarButtons?: ToolbarButton[]; } interface NamesWLength { @@ -352,8 +358,6 @@ export class Options { ignoreNextMousedown: (e?: MouseEvent) => boolean = () => false; - blurWithCursor?: (e: FocusEvent, mq?: AbstractMathQuill) => boolean; - static #tabbable: boolean | undefined; #_tabbable?: boolean; get tabbable(): boolean | undefined { @@ -363,4 +367,61 @@ export class Options { if (this instanceof Options) this.#_tabbable = tabbable; else Options.#tabbable = tabbable; } + + static #useToolbar = false; + #_useToolbar?: boolean; + get useToolbar() { + return this.#_useToolbar ?? Options.#useToolbar; + } + set useToolbar(useToolbar) { + if (this instanceof Options) this.#_useToolbar = useToolbar; + else Options.#useToolbar = useToolbar; + } + + // The set of toolbar buttons that will be available in the toolbar. + static #toolbarButtons: ToolbarButton[] = [ + { id: 'frac', latex: '/', tooltip: 'fraction (/)', icon: '\\frac{\\text{ }}{\\text{ }}' }, + { id: 'abs', latex: '|', tooltip: 'absolute value (|)', icon: '|\\text{ }|' }, + { id: 'sqrt', latex: '\\sqrt', tooltip: 'square root (sqrt)', icon: '\\sqrt{\\text{ }}' }, + { id: 'nthroot', latex: '\\root', tooltip: 'nth root (root)', icon: '\\sqrt[\\text{ }]{\\text{ }}' }, + { id: 'exponent', latex: '^', tooltip: 'exponent (^)', icon: '\\text{ }^\\text{ }' }, + { id: 'infty', latex: '\\infty', tooltip: 'infinity (inf)', icon: '\\infty' }, + { id: 'pi', latex: '\\pi', tooltip: 'pi (pi)', icon: '\\pi' }, + { id: 'vert', latex: '\\vert', tooltip: 'such that (vert)', icon: '|' }, + { id: 'cup', latex: '\\cup', tooltip: 'union (union)', icon: '\\cup' }, + { id: 'text', latex: '\\text', tooltip: 'text mode (")', icon: 'Tt' } + ]; + #_toolbarButtons?: ToolbarButton[]; + get toolbarButtons(): ToolbarButton[] { + return this.#_toolbarButtons ?? Options.#toolbarButtons; + } + set toolbarButtons(buttons: ToolbarButton[]) { + if (Array.isArray(buttons)) { + if (this instanceof Options) { + this.#_toolbarButtons = structuredClone(buttons); + } else Options.#toolbarButtons = structuredClone(buttons); + return; + } + } + + addToolbarButtons(buttons: ToolbarButton | ToolbarButton[], position: string | number = -1) { + if (!this.#_toolbarButtons) this.toolbarButtons = Options.#toolbarButtons; + if (!this.#_toolbarButtons) throw new Error('toolbarButtons setter not working'); + const newButtons = Array.isArray(buttons) ? buttons : [buttons]; + if (typeof position === 'string') { + const previousButton = this.#_toolbarButtons.findIndex((b) => b.id === position); + if (previousButton !== -1) this.#_toolbarButtons.splice(previousButton + 1, 0, ...newButtons); + } else { + this.#_toolbarButtons.splice(position, 0, ...newButtons); + } + } + + removeToolbarButtons(buttonIds: string | string[]) { + if (!this.#_toolbarButtons) this.toolbarButtons = Options.#toolbarButtons; + if (!this.#_toolbarButtons) throw new Error('toolbarButtons setter not working'); + for (const buttonId of buttonIds instanceof Array ? buttonIds : [buttonIds]) { + const existingButtonIndex = this.#_toolbarButtons.findIndex((b) => b.id === buttonId); + if (existingButtonIndex !== -1) this.#_toolbarButtons.splice(existingButtonIndex, 1); + } + } } diff --git a/src/services/focusBlur.ts b/src/services/focusBlur.ts index dd0f4ecb..22e44421 100644 --- a/src/services/focusBlur.ts +++ b/src/services/focusBlur.ts @@ -32,7 +32,7 @@ export const FocusBlurEvents = >(Base: this.blurHandler = (e) => { this.blurred = true; - this.blurredWithCursor = this.options.blurWithCursor?.(e, this.apiClass) ?? false; + this.blurredWithCursor = this.toolbar?.isBeingFocused(e) ?? false; this.container.classList.remove('mq-focused'); if (this.blurredWithCursor) { if (this.cursor.selection) this.cursor.selection.elements.addClass('mq-blur'); diff --git a/src/services/saneKeyboardEvents.util.ts b/src/services/saneKeyboardEvents.util.ts index c74ace60..720f7a97 100644 --- a/src/services/saneKeyboardEvents.util.ts +++ b/src/services/saneKeyboardEvents.util.ts @@ -42,6 +42,7 @@ export const saneKeyboardEvents = (() => { const select = (text: string) => { textarea.value = text; if (text && textarea instanceof HTMLTextAreaElement) textarea.select(); + setTimeout(() => (textarea.value = '')); }; const handleKey = (key: string, e: KeyboardEvent) => { diff --git a/src/services/textarea.ts b/src/services/textarea.ts index 1f61571d..c0e108cd 100644 --- a/src/services/textarea.ts +++ b/src/services/textarea.ts @@ -103,6 +103,16 @@ export const TextAreaController = < this.selectFn = select; } this.container.prepend(this.textareaSpan as HTMLElement); + if (this.options.useToolbar && this.textarea) { + import(/* webpackChunkName: "toolbar" */ 'src/toolbar') + .then(({ MathQuillToolbar }) => { + if (this.textarea) + this.toolbar = new MathQuillToolbar(this as unknown as Controller, this.textarea); + }) + .catch(() => { + /* ignore */ + }); + } this.addEditableFocusBlurEvents(); this.updateMathspeak(); } @@ -114,6 +124,9 @@ export const TextAreaController = < }; this.textareaSpan?.remove(); + this.toolbar?.unbind(); + delete this.toolbar; + this.unbindEditableFocusBlurEvents(); this.blurred = true; diff --git a/src/toolbar.ts b/src/toolbar.ts new file mode 100644 index 00000000..2280ca8f --- /dev/null +++ b/src/toolbar.ts @@ -0,0 +1,326 @@ +import { Tooltip, Dropdown } from 'bootstrap'; +import { Controller } from 'src/controller'; +import { Options } from 'src/options'; +import { EditableField } from 'src/abstractFields'; +import { StaticMath } from 'commands/math'; + +export class MathQuillToolbar { + private enabled: boolean; + private element?: HTMLDivElement; + private tooltips: Tooltip[] = []; + private contextMenuElement?: HTMLDivElement; + + constructor( + private controller: Controller, + private textarea: HTMLTextAreaElement + ) { + this.enabled = (localStorage.getItem('MQEditorToolbarEnabled') ?? 'true') === 'true'; + textarea.addEventListener('focusin', this.insert); + textarea.addEventListener('focusout', this.removeUnlessFocused); + this.controller.container.addEventListener('contextmenu', this.contextMenu); + } + + unbind() { + this.textarea.removeEventListener('focusin', this.insert); + this.textarea.removeEventListener('focusout', this.removeUnlessFocused); + this.controller.container.removeEventListener('contextmenu', this.contextMenu); + } + + isBeingFocused(e: FocusEvent) { + return ( + this.enabled && + !!this.element && + e.relatedTarget instanceof HTMLElement && + this.element.contains(e.relatedTarget) + ); + } + + disableButtons() { + this.element?.querySelectorAll('button').forEach((button) => (button.disabled = true)); + } + + enableButtons() { + this.element?.querySelectorAll('button').forEach((button) => (button.disabled = false)); + } + + private insert = () => { + if (!this.enabled || this.element) return; + + this.element = document.createElement('div'); + this.element.tabIndex = -1; + this.element.classList.add('quill-toolbar'); + this.element.style.opacity = '0'; + + this.element.addEventListener('focusout', (e: FocusEvent) => { + if ( + !document.hasFocus() || + (e.relatedTarget instanceof HTMLElement && + (this.element?.contains(e.relatedTarget) || e.relatedTarget === this.textarea)) + ) + return; + + this.remove(); + }); + + window.addEventListener('focus', this.removeOnWindowRefocus); + + for (const buttonData of this.controller.options.toolbarButtons) { + const button = document.createElement('button'); + button.type = 'button'; + button.id = `${buttonData.id}-mq-answer-AnSwEr0001`; + button.classList.add('symbol-button', 'btn', 'btn-dark'); + button.dataset.latex = buttonData.latex; + button.dataset.bsToggle = 'tooltip'; + button.title = buttonData.tooltip; + const icon = document.createElement('span'); + icon.id = `icon-${buttonData.id}-mq-answer-AnSwEr0001`; + icon.textContent = buttonData.icon; + icon.setAttribute('aria-hidden', 'true'); + button.append(icon); + this.element.append(button); + + const staticMathController = new Controller(new StaticMath.RootBlock(), icon, new Options()); + staticMathController.KIND_OF_MQ = 'StaticMath'; + new StaticMath(staticMathController).config({ mouseEvents: false, tabbable: false }).__mathquillify(); + + this.tooltips.push(new Tooltip(button, { placement: 'left' })); + + button.addEventListener('click', () => { + this.textarea.focus(); + if (this.controller.apiClass instanceof EditableField) + this.controller.apiClass.cmd(button.dataset.latex ?? ''); + }); + } + + this.element.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const nextFocusable = this.getNextFocusableElement(this.element?.lastElementChild); + this.remove(); + nextFocusable?.focus(); + } + }); + + window.addEventListener('resize', this.setPosition); + this.setPosition(); + + this.controller.container.after(this.element); + setTimeout(() => { + if (this.element) this.element.style.opacity = '1'; + }, 0); + }; + + private removeUnlessFocused = (e: FocusEvent) => { + if (!document.hasFocus() || (e.relatedTarget instanceof HTMLElement && this.element?.contains(e.relatedTarget))) + return; + + this.remove(); + }; + + private remove() { + if (this.element) { + const toolbar = this.element; + delete this.element; + toolbar.style.opacity = '0'; + window.removeEventListener('resize', this.setPosition); + window.removeEventListener('focus', this.removeOnWindowRefocus); + for (const tooltip of this.tooltips) { + tooltip.dispose(); + } + this.tooltips.length = 0; + toolbar.addEventListener( + 'transitionend', + () => { + toolbar.remove(); + }, + { once: true } + ); + toolbar.addEventListener( + 'transitioncancel', + () => { + toolbar.remove(); + }, + { once: true } + ); + if (this.enabled && document.activeElement !== this.textarea) { + if (document.activeElement !== this.textarea) this.textarea.dispatchEvent(new FocusEvent('blur')); + else this.textarea.blur(); + } + } + } + + private removeOnWindowRefocus = () => { + if ( + document.activeElement && + !document.activeElement.closest('.quill-toolbar') && + !document.activeElement.classList.contains('symbol-button') && + document.activeElement !== this.textarea + ) + this.remove(); + }; + + private getNextFocusableElement(currentElement?: Element | null) { + if (!(currentElement instanceof HTMLElement)) return; + const focusableElements = Array.from( + document.querySelectorAll( + 'a[href]:not([tabindex="-1"]),' + + 'button:not([tabindex="-1"]),' + + 'input:not([tabindex="-1"]),' + + 'textarea:not([tabindex="-1"]),' + + 'select:not([tabindex="-1"]),' + + 'details:not([tabindex="-1"]),' + + '[tabindex]:not([tabindex="-1"])' + ) + ); + + const currentIndex = focusableElements.indexOf(currentElement); + if (currentIndex === -1) return; + + for (const focusableElement of focusableElements.slice(currentIndex + 1)) { + if (!(focusableElement as HTMLInputElement).disabled && focusableElement.offsetParent !== null) + return focusableElement; + } + } + + private setPosition = () => { + if (!this.element) return; + + // Note that this must be kept in sync with css. Currently each symbol button has a fixed height (due + // to flex-shrink being 0) of 45px plus a 1px padding on the top and bottom plus a 1px margin on the top + // and bottom, giving a 49px total height for each symbol button . Also, the toolbar itself has a 2px + // border on the top and bottom, hence 4px is added to the end. These computations take into account + // that box-sizing is border-box. + const toolbarHeight = 49 * this.controller.options.toolbarButtons.length + 4; + + const pageHeight = (() => { + const documentElHeight = document.documentElement.getBoundingClientRect().height; + if (window.innerHeight > documentElHeight) return window.innerHeight; + return documentElHeight; + })(); + + // Different positioning is needed when contained in a relatively positioned parent. + const relativeParent = (() => { + let parent = this.controller.container.parentElement; + while (parent && parent !== document.documentElement) { + const positionType = window.getComputedStyle(parent).position; + if (positionType === 'relative') return parent; + // If a fixed parent is encountered before a relative parent is encountered, + // that negates relative positioning. + if (positionType === 'fixed') return; + parent = parent.parentElement; + } + })(); + + if (relativeParent) { + // If contained in a relatively positioned parent, the toolbar needs + // to be positioned relative to that parent. + const pageWidth = (() => { + const documentElWidth = document.documentElement.getBoundingClientRect().width; + if (window.innerWidth > documentElWidth) return window.innerWidth; + return documentElWidth; + })(); + + const parentRect = relativeParent.getBoundingClientRect(); + this.element.style.right = `${(window.scrollX + parentRect.right + 10 - pageWidth).toString()}px`; + + const elRect = this.controller.container.getBoundingClientRect(); + + if (window.scrollY + elRect.top + elRect.height / 2 < toolbarHeight / 2) { + this.element.style.top = `-${(window.scrollY + parentRect.top).toString()}px`; + this.element.style.bottom = + toolbarHeight > pageHeight + ? `${(window.scrollY + parentRect.bottom - pageHeight).toString()}px` + : ''; + } else if (window.scrollY + elRect.top + elRect.height / 2 + toolbarHeight / 2 > pageHeight) { + this.element.style.top = ''; + this.element.style.bottom = `${(window.scrollY + parentRect.bottom - pageHeight).toString()}px`; + } else { + this.element.style.top = `${( + elRect.top + + elRect.height / 2 - + toolbarHeight / 2 - + parentRect.top + ).toString()}px`; + this.element.style.bottom = ''; + } + } else { + // If not in a relatively positioned parent, the toolbar is positioned absolutely on the page. + if (toolbarHeight > pageHeight) { + this.element.style.top = '0'; + this.element.style.height = '100%'; + } else { + const elRect = this.controller.container.getBoundingClientRect(); + const top = window.scrollY + elRect.bottom - elRect.height / 2 - toolbarHeight / 2; + const bottom = top + toolbarHeight; + this.element.style.top = `${(top < 0 + ? 0 + : bottom > pageHeight + ? pageHeight - toolbarHeight + : top + ).toString()}px`; + this.element.style.height = ''; + } + } + }; + + private contextMenu = (e: PointerEvent) => { + e.preventDefault(); + if (this.contextMenuElement) return; + + this.contextMenuElement = document.createElement('div'); + this.contextMenuElement.classList.add('dropdown', 'd-inline-block'); + this.contextMenuElement.style.position = 'absolute'; + this.controller.container.after(this.contextMenuElement); + + const hiddenLink = document.createElement('a'); + hiddenLink.classList.add('dropdown-toggle', 'd-none'); + hiddenLink.dataset.bsToggle = 'dropdown'; + hiddenLink.href = '#'; + this.contextMenuElement.append(hiddenLink); + + const menuEl = document.createElement('ul'); + menuEl.classList.add('dropdown-menu'); + menuEl.style.minWidth = 'unset'; + const li = document.createElement('li'); + menuEl.append(li); + const action = document.createElement('a'); + action.classList.add('dropdown-item'); + action.href = '#'; + action.textContent = this.enabled ? 'Disable Toolbar' : 'Enable Toolbar'; + li.append(action); + this.contextMenuElement.append(menuEl); + + const menu = new Dropdown(hiddenLink, { + reference: this.controller.container, + offset: [this.controller.container.offsetWidth, 0] + }); + menu.show(); + + hiddenLink.addEventListener('hidden.bs.dropdown', () => { + menu.dispose(); + menuEl.remove(); + this.contextMenuElement?.remove(); + delete this.contextMenuElement; + }); + + action.addEventListener( + 'click', + (e) => { + e.preventDefault(); + this.enabled = !this.enabled; + localStorage.setItem('MQEditorToolbarEnabled', this.enabled ? 'true' : 'false'); + if (!this.enabled && this.element) this.remove(); + // Bootstrap tries to focus the triggering element after hiding the menu. However, the menu gets + // disposed of and the hidden link which is the triggering element removed too quickly in the + // hidden.bs.dropdown event, and that causes an exception. So ignore that exception so that the + // answerQuill textarea is focused instead. + try { + menu.hide(); + } catch { + /* ignore */ + } + this.textarea.focus(); + }, + { once: true } + ); + }; +} diff --git a/test/focusBlur.test.ts b/test/focusBlur.test.ts index ae99b34a..b6228060 100644 --- a/test/focusBlur.test.ts +++ b/test/focusBlur.test.ts @@ -106,7 +106,7 @@ suite('focusBlur', function () { }, 100); } else if (document.visibilityState === 'visible') { setTimeout(() => { - assert.equal(textarea?.value, 'f'); + assert.equal(textarea?.value, ''); mq.focus(); assertHasFocus(mq, 'mq'); diff --git a/webpack.config.js b/webpack.config.js index 97fecf1a..91a494fb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,3 @@ -/* eslint-env node */ - const path = require('path'); const webpack = require('webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); @@ -18,6 +16,7 @@ module.exports = (_env, argv) => { output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js', + chunkFilename: '[name]-[chunkhash].js', clean: true }, resolve: { @@ -31,6 +30,7 @@ module.exports = (_env, argv) => { }, extensions: ['', '.ts', '.js'] }, + externals: { bootstrap: 'bootstrap' }, module: { rules: [ { @@ -108,7 +108,6 @@ module.exports = (_env, argv) => { }; if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console console.log('Using development mode.'); config.devtool = 'source-map'; @@ -119,15 +118,12 @@ module.exports = (_env, argv) => { port: 9292, static: [ path.join(__dirname, 'public'), - { - directory: path.join(__dirname, 'node_modules/mocha'), - publicPath: '/mocha' - } + { directory: path.join(__dirname, 'node_modules/mocha'), publicPath: '/mocha' }, + { directory: path.join(__dirname, 'node_modules/bootstrap/dist'), publicPath: '/bootstrap' } ], watchFiles: ['public/**/*'] }; } else { - // eslint-disable-next-line no-console console.log('Using production mode.'); } From 5a242613b4633956a4ca807d7a9665904a66bd84 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 2 May 2026 14:20:30 -0500 Subject: [PATCH 2/2] Convert less to scss. This is just something I have been wanting to do for a while. I like Dart Sass and scss so much better than less. It is the more popular library, and is generally more versatile. Also add links to all testing pages on the index page of the developement server. The `README.md` says they are there. --- .stylelintrc.js | 24 - package-lock.json | 747 +++++++++++++------ package.json | 13 +- public/index.html | 74 +- src/css/_fonts.scss | 3 + src/css/{mixins/display.less => aria.scss} | 4 - src/css/{editable.less => editable.scss} | 13 +- src/css/font.less | 15 - src/css/font.scss | 19 + src/css/main.less | 10 - src/css/main.scss | 8 + src/css/{math.less => math.scss} | 33 +- src/css/{matrixed.less => matrixed.scss} | 4 +- src/css/mixins/css3.less | 19 - src/css/mixins/fonts.less | 3 - src/css/{selections.less => selections.scss} | 0 src/css/{textarea.less => textarea.scss} | 11 +- src/css/{toolbar.less => toolbar.scss} | 0 src/index.ts | 2 +- src/styles.d.ts | 1 + stylelint.config.mjs | 19 + webpack.config.js | 13 +- 22 files changed, 666 insertions(+), 369 deletions(-) delete mode 100644 .stylelintrc.js create mode 100644 src/css/_fonts.scss rename src/css/{mixins/display.less => aria.scss} (85%) rename src/css/{editable.less => editable.scss} (90%) delete mode 100644 src/css/font.less create mode 100644 src/css/font.scss delete mode 100644 src/css/main.less create mode 100644 src/css/main.scss rename src/css/{math.less => math.scss} (94%) rename src/css/{matrixed.less => matrixed.scss} (69%) delete mode 100644 src/css/mixins/css3.less delete mode 100644 src/css/mixins/fonts.less rename src/css/{selections.less => selections.scss} (100%) rename src/css/{textarea.less => textarea.scss} (79%) rename src/css/{toolbar.less => toolbar.scss} (100%) create mode 100644 src/styles.d.ts create mode 100644 stylelint.config.mjs diff --git a/.stylelintrc.js b/.stylelintrc.js deleted file mode 100644 index 1deb4220..00000000 --- a/.stylelintrc.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = { - extends: ['stylelint-config-html', 'stylelint-config-standard'], - plugins: [], - ignoreFiles: ['node_modules/**', 'dist/**', 'build/**'], - rules: { - 'at-rule-no-unknown': null, - 'rule-empty-line-before': [ - 'always', - { - except: ['first-nested', 'after-single-line-comment'] - } - ], - 'no-descending-specificity': null, - 'no-invalid-position-at-import-rule': null, - 'import-notation': 'string', - 'declaration-property-value-no-unknown': null - }, - overrides: [ - { - files: ['**/*.less'], - customSyntax: 'postcss-less' - } - ] -}; diff --git a/package-lock.json b/package-lock.json index 5f9b78ec..b6ee5aa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openwebwork/mathquill", - "version": "0.11.2", + "version": "0.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openwebwork/mathquill", - "version": "0.11.2", + "version": "0.11.3", "license": "MPL-2.0", "dependencies": { "bootstrap": "^5.3.8" @@ -26,17 +26,18 @@ "eslint-plugin-mocha": "^11.2.0", "eslint-webpack-plugin": "^6.0.0", "globals": "^17.5.0", - "less": "^4.6.4", - "less-loader": "^12.3.2", "mini-css-extract-plugin": "^2.10.2", "mocha": "^11.7.5", - "postcss-less": "^6.0.0", "prettier": "^3.8.3", "rollup": "^4.60.2", "rollup-plugin-dts": "^6.4.1", + "sass": "^1.99.0", + "sass-loader": "^16.0.7", "stylelint": "^17.9.0", "stylelint-config-html": "^1.1.0", + "stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-standard": "^40.0.0", + "stylelint-config-standard-scss": "^17.0.0", "stylelint-webpack-plugin": "^5.1.0", "ts-loader": "^9.5.7", "typescript": "^6.0.3", @@ -1124,6 +1125,334 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@peculiar/asn1-cms": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", @@ -3410,22 +3739,6 @@ "dev": true, "license": "MIT" }, - "node_modules/copy-anything": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", - "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-what": "^4.1.8" - }, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/copy-webpack-plugin": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", @@ -3877,6 +4190,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -4068,20 +4392,6 @@ "node": ">=4" } }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5285,20 +5595,6 @@ "node": ">=10.18" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -5322,19 +5618,12 @@ "node": ">= 4" } }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, - "license": "MIT", - "optional": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.1", @@ -5601,19 +5890,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-what": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -5768,6 +6044,13 @@ "node": ">=0.10.0" } }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, "node_modules/launch-editor": { "version": "2.13.2", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", @@ -5779,59 +6062,6 @@ "shell-quote": "^1.8.3" } }, - "node_modules/less": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz", - "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "copy-anything": "^3.0.5", - "parse-node-version": "^1.0.1" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" - } - }, - "node_modules/less-loader": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.3.2.tgz", - "integrity": "sha512-uLV5c702ff2jBvO7qewpkLRzkh/I9QW07ur2NKkv8TVTrtX2lrKjEbEU/LLXAn7cgpCIBbkfyUm4qYXCQs5/+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", - "less": "^3.5.0 || ^4.0.0", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5954,32 +6184,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6342,24 +6546,6 @@ "dev": true, "license": "MIT" }, - "node_modules/needle": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", - "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -6377,6 +6563,14 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", @@ -6579,16 +6773,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6680,17 +6864,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -6932,18 +7105,12 @@ "node": "^12 || >=14" } }, - "node_modules/postcss-less": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-6.0.0.tgz", - "integrity": "sha512-FPX16mQLyEjLzEuuJtxA8X3ejDLNGGEG503d2YGZR5Ask1SpDN8KmZUMpzCvyalWRywAn1n1VOA5dcqfCLo5rg==", + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "postcss": "^8.3.5" - } + "license": "MIT" }, "node_modules/postcss-merge-longhand": { "version": "7.0.6", @@ -7307,6 +7474,13 @@ "postcss": "^8.5.10" } }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss-safe-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", @@ -7325,6 +7499,33 @@ "postcss": "^8.3.3" } }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, "node_modules/postcss-selector-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", @@ -7436,14 +7637,6 @@ "node": ">= 0.10" } }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7858,6 +8051,68 @@ "dev": true, "license": "MIT" }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.7.tgz", + "integrity": "sha512-w6q+fRHourZ+e+xA1kcsF27iGM6jdB8teexYCfdUw0sYgcDNeZESnDNT9sUmmPm3ooziwUJXGwZJSTF3kOdBfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -8522,6 +8777,30 @@ "stylelint": "^17.0.0" } }, + "node_modules/stylelint-config-recommended-scss": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-17.0.1.tgz", + "integrity": "sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-scss": "^4.0.9", + "stylelint-config-recommended": "^18.0.0", + "stylelint-scss": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "postcss": "^8.3.3", + "stylelint": "^17.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + } + } + }, "node_modules/stylelint-config-standard": { "version": "40.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz", @@ -8548,6 +8827,52 @@ "stylelint": "^17.0.0" } }, + "node_modules/stylelint-config-standard-scss": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-17.0.0.tgz", + "integrity": "sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stylelint-config-recommended-scss": "^17.0.0", + "stylelint-config-standard": "^40.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "postcss": "^8.3.3", + "stylelint": "^17.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + } + } + }, + "node_modules/stylelint-scss": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.0.0.tgz", + "integrity": "sha512-H88kCC+6Vtzj76NsC8rv6x/LW8slBzIbyeSjsKVlS+4qaEJoDrcJR4L+8JdrR2ORdTscrBzYWiiT2jq6leYR1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.1", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.37.0", + "mdn-data": "^2.25.0", + "postcss-media-query-parser": "^0.2.3", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-selector-parser": "^7.1.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "stylelint": "^16.8.2 || ^17.0.0" + } + }, "node_modules/stylelint-webpack-plugin": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/stylelint-webpack-plugin/-/stylelint-webpack-plugin-5.1.0.tgz", diff --git a/package.json b/package.json index c5bd0c57..07b455a9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@openwebwork/mathquill", "description": "Easily type math in your webapp", - "version": "0.11.2", + "version": "0.11.3", "license": "MPL-2.0", "repository": { "type": "git", @@ -26,8 +26,8 @@ "serve": "webpack serve --mode development", "lint:eslint": "eslint ./src ./test --fix", "lint:eslint:check": "eslint ./src ./test", - "lint:stylelint": "stylelint \"./src/**/*.less\" \"./public/**/*.css\" \"./public/**/*.html\" --fix", - "lint:stylelint:check": "stylelint \"./src/**/*.less\" \"./public/**/*.css\" \"./public/**/*.html\"", + "lint:stylelint": "stylelint \"./src/**/*.scss\" \"./public/**/*.css\" \"./public/**/*.html\" --fix", + "lint:stylelint:check": "stylelint \"./src/**/*.scss\" \"./public/**/*.css\" \"./public/**/*.html\"", "lint": "npm run lint:eslint && npm run lint:stylelint", "lint:check": "npm run lint:eslint:check && npm run lint:stylelint:check", "format": "prettier --write .", @@ -51,17 +51,18 @@ "eslint-plugin-mocha": "^11.2.0", "eslint-webpack-plugin": "^6.0.0", "globals": "^17.5.0", - "less": "^4.6.4", - "less-loader": "^12.3.2", "mini-css-extract-plugin": "^2.10.2", "mocha": "^11.7.5", - "postcss-less": "^6.0.0", "prettier": "^3.8.3", "rollup": "^4.60.2", "rollup-plugin-dts": "^6.4.1", + "sass": "^1.99.0", + "sass-loader": "^16.0.7", "stylelint": "^17.9.0", "stylelint-config-html": "^1.1.0", + "stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-standard": "^40.0.0", + "stylelint-config-standard-scss": "^17.0.0", "stylelint-webpack-plugin": "^5.1.0", "ts-loader": "^9.5.7", "typescript": "^6.0.3", diff --git a/public/index.html b/public/index.html index fe044ab3..88a1599b 100644 --- a/public/index.html +++ b/public/index.html @@ -14,42 +14,52 @@ -

Static math span: x = \frac{ -b \pm \sqrt{b^2-4ac} }{ 2a }

-

Editable math field: x^2

-

LaTeX of what you typed: x^2

-

Text of what you typed: x^2

-

- MathQuill’s Getting Started Guide -

+
+

Static math span: x = \frac{ -b \pm \sqrt{b^2-4ac} }{ 2a }

+

Editable math field: x^2

+

LaTeX of what you typed: x^2

+

Text of what you typed: x^2

+

+ + MathQuill’s Getting Started Guide + +

+

Available development testing pages:

+ + + ); + +
diff --git a/src/css/_fonts.scss b/src/css/_fonts.scss new file mode 100644 index 00000000..8dd049fa --- /dev/null +++ b/src/css/_fonts.scss @@ -0,0 +1,3 @@ +$symbola: 'Symbola', 'Times New Roman', serif; +$times: 'Times New Roman', 'Symbola', serif; +$mono: 'Courier New', monospace; diff --git a/src/css/mixins/display.less b/src/css/aria.scss similarity index 85% rename from src/css/mixins/display.less rename to src/css/aria.scss index b305f5dc..d8467265 100644 --- a/src/css/mixins/display.less +++ b/src/css/aria.scss @@ -1,7 +1,3 @@ -.inline-block () { - display: inline-block; -} - .mq-aria-alert { position: absolute; left: -1000px; diff --git a/src/css/editable.less b/src/css/editable.scss similarity index 90% rename from src/css/editable.less rename to src/css/editable.scss index a5350929..3834f613 100644 --- a/src/css/editable.less +++ b/src/css/editable.scss @@ -1,5 +1,7 @@ +@use 'fonts'; + .mq-editable-field { - .inline-block(); + display: inline-block; .mq-cursor { border-left: 1px solid black; @@ -7,7 +9,7 @@ position: relative; z-index: 1; padding: 0; - .inline-block(); + display: inline-block; &.mq-blink { visibility: hidden; @@ -22,8 +24,9 @@ border: 1px solid gray; &.mq-focused { - .box-shadow(~'#8bd 0 0 0 0.2rem, inset #6ae 0 0 2px 0'); - + box-shadow: + #8bd 0 0 0 0.2rem, + inset #6ae 0 0 2px 0; border-color: #709ac0cc; border-radius: 1px; } @@ -37,7 +40,7 @@ // command input with .mq-latex-command-input { color: inherit; - font-family: @mono; + font-family: fonts.$mono; border: 1px solid gray; padding-right: 1px; margin-right: 1px; diff --git a/src/css/font.less b/src/css/font.less deleted file mode 100644 index bb1687d5..00000000 --- a/src/css/font.less +++ /dev/null @@ -1,15 +0,0 @@ -@omit-font-face: ~''; -.font-face(); -.font-face() when not (@omit-font-face) { - @font-face { - font-family: Symbola; - src: url('fonts/Symbola.eot'); - src: - local('Symbola Regular'), - local('Symbola'), - url('fonts/Symbola.woff2') format('woff2'), - url('fonts/Symbola.woff') format('woff'), - url('fonts/Symbola.ttf') format('truetype'), - url('fonts/Symbola.svg#Symbola') format('svg'); - } -} diff --git a/src/css/font.scss b/src/css/font.scss new file mode 100644 index 00000000..caa45a96 --- /dev/null +++ b/src/css/font.scss @@ -0,0 +1,19 @@ +$omit-font-face: false !default; + +@mixin font-face { + @if not $omit-font-face { + @font-face { + font-family: Symbola; + src: url('fonts/Symbola.eot'); + src: + local('Symbola Regular'), + local('Symbola'), + url('fonts/Symbola.woff2') format('woff2'), + url('fonts/Symbola.woff') format('woff'), + url('fonts/Symbola.ttf') format('truetype'), + url('fonts/Symbola.svg#Symbola') format('svg'); + } + } +} + +@include font-face; diff --git a/src/css/main.less b/src/css/main.less deleted file mode 100644 index 8804efd9..00000000 --- a/src/css/main.less +++ /dev/null @@ -1,10 +0,0 @@ -@import './mixins/fonts'; -@import './mixins/css3'; -@import './mixins/display'; -@import 'font.less'; -@import 'editable.less'; -@import 'math.less'; -@import 'selections.less'; -@import 'textarea.less'; -@import 'matrixed.less'; -@import 'toolbar.less'; diff --git a/src/css/main.scss b/src/css/main.scss new file mode 100644 index 00000000..cfc5ae2e --- /dev/null +++ b/src/css/main.scss @@ -0,0 +1,8 @@ +@use 'aria'; +@use 'font'; +@use 'editable'; +@use 'math'; +@use 'selections'; +@use 'textarea'; +@use 'matrixed'; +@use 'toolbar'; diff --git a/src/css/math.less b/src/css/math.scss similarity index 94% rename from src/css/math.less rename to src/css/math.scss index e1cb8352..3f357136 100644 --- a/src/css/math.less +++ b/src/css/math.scss @@ -1,11 +1,11 @@ +@use 'fonts'; + .mq-root-block, .mq-math-mode .mq-root-block { - .inline-block(); - + display: inline-block; width: 100%; padding: 2px; - .box-sizing(border-box); - + box-sizing: border-box; white-space: nowrap; overflow: hidden; vertical-align: middle; @@ -17,19 +17,18 @@ font-style: normal; font-size: 115%; line-height: 1; - - .inline-block(); + display: inline-block; .mq-non-leaf, .mq-scaled { - .inline-block(); + display: inline-block; } // TODO: dasherize non-symbola var, .mq-text-mode, .mq-non-symbola { - font-family: @times; + font-family: fonts.$times; line-height: 0.9; } @@ -51,8 +50,7 @@ margin: 0; padding: 0; border-color: black; - .user-select(none); - + user-select: none; box-sizing: border-box; } @@ -82,7 +80,7 @@ } .mq-font { - font: 1em @times; + font: 1em fonts.$times; * { font-family: inherit; @@ -118,8 +116,7 @@ .mq-int { > big { display: inline-block; - .transform(scaleX(0.7)); - + transform: scaleX(0.7); vertical-align: -0.16em; } @@ -162,11 +159,11 @@ //// // operators - @operator-padding: 0.2em; + $operator-padding: 0.2em; .mq-binary-operator { - padding: 0 @operator-padding; - .inline-block(); + padding: 0 $operator-padding; + display: inline-block; } // ^, _ @@ -383,7 +380,7 @@ &, .mq-editable-field { cursor: text; - font-family: @symbola; + font-family: fonts.$symbola; } .mq-overarc { @@ -439,7 +436,7 @@ font-size: 0.5em; line-height: 0em; content: '\27A4'; - visibility: visible; //must override .mq-editable-field.mq-empty:after + visibility: visible; // must override .mq-editable-field.mq-empty:after text-align: right; } } diff --git a/src/css/matrixed.less b/src/css/matrixed.scss similarity index 69% rename from src/css/matrixed.less rename to src/css/matrixed.scss index f4bd49e4..c37aaa19 100644 --- a/src/css/matrixed.less +++ b/src/css/matrixed.scss @@ -1,9 +1,7 @@ -@import './mixins/display'; - .mq-math-mode { .mq-matrixed { background: white; - .inline-block(); + display: inline-block; } .mq-matrixed-container { diff --git a/src/css/mixins/css3.less b/src/css/mixins/css3.less deleted file mode 100644 index fe5443f6..00000000 --- a/src/css/mixins/css3.less +++ /dev/null @@ -1,19 +0,0 @@ -.transform-origin (...) { - transform-origin: @arguments; -} - -.transform (...) { - transform: @arguments; -} - -.user-select (...) { - user-select: @arguments; -} - -.box-shadow (...) { - box-shadow: @arguments; -} - -.box-sizing (...) { - box-sizing: @arguments; -} diff --git a/src/css/mixins/fonts.less b/src/css/mixins/fonts.less deleted file mode 100644 index ef4e9001..00000000 --- a/src/css/mixins/fonts.less +++ /dev/null @@ -1,3 +0,0 @@ -@symbola: Symbola, 'Times New Roman', serif; -@times: 'Times New Roman', Symbola, serif; -@mono: 'Courier New', monospace; diff --git a/src/css/selections.less b/src/css/selections.scss similarity index 100% rename from src/css/selections.less rename to src/css/selections.scss diff --git a/src/css/textarea.less b/src/css/textarea.scss similarity index 79% rename from src/css/textarea.less rename to src/css/textarea.scss index b9516483..58398cd2 100644 --- a/src/css/textarea.less +++ b/src/css/textarea.scss @@ -1,22 +1,17 @@ -@import './mixins/css3'; - .mq-editable-field, .mq-math-mode { .mq-textarea { position: relative; // TODO: why is this here? - .user-select(text); + user-select: text; } .mq-textarea *, .mq-selectable { - .user-select(text); - + user-select: text; position: absolute; // the only way to hide the textarea *and* the - - .transform(scale(0)); // the only way to hide the blinking blue cursor in iOS 8 #584 - + transform: scale(0); // the only way to hide the blinking blue cursor in iOS 8 #584 resize: none; // hotfix: https://code.google.com/p/chromium/issues/detail?id=355199#c1 width: 1px; // don't "stick out" invisibly from a math field, height: 1px; // can affect ancestor's .scroll{Width,Height} diff --git a/src/css/toolbar.less b/src/css/toolbar.scss similarity index 100% rename from src/css/toolbar.less rename to src/css/toolbar.scss diff --git a/src/index.ts b/src/index.ts index 14c246d4..2dcd2787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import mathQuill from 'src/publicapi'; import { MQ_VERSION } from 'src/version'; -import 'css/main.less'; +import 'css/main.scss'; mathQuill.VERSION = MQ_VERSION; diff --git a/src/styles.d.ts b/src/styles.d.ts new file mode 100644 index 00000000..d5cf927a --- /dev/null +++ b/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.scss'; diff --git a/stylelint.config.mjs b/stylelint.config.mjs new file mode 100644 index 00000000..3f68cb07 --- /dev/null +++ b/stylelint.config.mjs @@ -0,0 +1,19 @@ +export default { + extends: ['stylelint-config-standard', 'stylelint-config-html', 'stylelint-config-standard-scss'], + plugins: ['stylelint-scss'], + ignoreFiles: ['node_modules/**', 'dist/**'], + rules: { + 'at-rule-no-unknown': null, + 'rule-empty-line-before': ['always', { except: ['first-nested', 'after-single-line-comment'] }], + 'no-descending-specificity': null, + 'no-invalid-position-at-import-rule': null, + 'import-notation': 'string', + 'declaration-property-value-no-unknown': null + }, + overrides: [ + { + files: ['**/*.html'], + customSyntax: 'postcss-html' + } + ] +}; diff --git a/webpack.config.js b/webpack.config.js index 91a494fb..b964612b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -55,20 +55,13 @@ module.exports = (_env, argv) => { use: [MiniCssExtractPlugin.loader, 'css-loader'] }, { - test: /\.less$/i, + test: /\.s[ac]ss$/i, use: [ MiniCssExtractPlugin.loader, 'css-loader', { - loader: 'less-loader', - options: { - lessOptions: { - modifyVars: { - 'omit-font-face': - typeof process.env.OMIT_FONT_FACE === 'undefined' ? false : true - } - } - } + loader: 'sass-loader', + options: { additionalData: `$omit-font-face: ${process.env.OMIT_FONT_FACE !== undefined};` } } ] },