From 6102f0ed33302423c32570faa6a27acb52d52670 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 6 Aug 2024 15:16:43 +0200 Subject: [PATCH] Add sub and sup marks to editable text Also add keyboard shortcuts for marks REDMINE-20809 --- entry_types/scrolled/config/locales/de.yml | 4 - entry_types/scrolled/config/locales/en.yml | 4 - .../config/locales/new/sub_sup.de.yml | 10 ++ .../config/locales/new/sub_sup.en.yml | 10 ++ .../spec/frontend/EditableText-spec.js | 18 ++++ .../inlineEditing/EditableText/marks-spec.js | 100 ++++++++++++++++++ .../package/src/frontend/EditableText.js | 8 ++ .../EditableText/HoveringToolbar.js | 28 +++-- .../inlineEditing/EditableText/index.js | 13 ++- .../inlineEditing/EditableText/marks.js | 26 +++++ .../inlineEditing/EditableText/shortcuts.js | 35 ++++++ .../src/frontend/inlineEditing/images/sub.svg | 1 + .../src/frontend/inlineEditing/images/sup.svg | 1 + 13 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 entry_types/scrolled/config/locales/new/sub_sup.de.yml create mode 100644 entry_types/scrolled/config/locales/new/sub_sup.en.yml create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/marks-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/marks.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/shortcuts.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/images/sub.svg create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/images/sup.svg diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index e2988e76f..790684404 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1010,14 +1010,10 @@ de: open_in_new_tab_message: Öffnen im selben Tab ist im Editor deaktiviert. formats: block_quote: Blockzitat - bold: Fett bulleted_list: Auflistung heading: Überschrift - italic: Kursiv ordered_list: Aufzählung paragraph: Absatz - strikethrough: Durchgestrichen - underline: Unterstrichen insert_content_element: after: Neues Element unterhalb einfügen before: Neues Element oberhalb einfügen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index c6a458d2b..af40e865b 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -848,14 +848,10 @@ en: open_in_new_tab_message: Opening in same tab is disabled inside the editor formats: block_quote: Block quote - bold: Bold bulleted_list: Bulleted list heading: Heading - italic: Italic ordered_list: Ordered list paragraph: Paragraph - strikethrough: Strikethrough - underline: Underline insert_content_element: after: Insert new element below before: Insert new element above diff --git a/entry_types/scrolled/config/locales/new/sub_sup.de.yml b/entry_types/scrolled/config/locales/new/sub_sup.de.yml new file mode 100644 index 000000000..5d90b6235 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/sub_sup.de.yml @@ -0,0 +1,10 @@ +de: + pageflow_scrolled: + inline_editing: + formats: + bold: Fett (Strg+B) + italic: Kursiv (Strg+I) + strikethrough: Durchgestrichen (Strg+Umschalten+S) + underline: Unterstrichen (Strg+U) + sub: Tiefgestellt (Strg+;) + sup: Hochgestellt (Strg+,) diff --git a/entry_types/scrolled/config/locales/new/sub_sup.en.yml b/entry_types/scrolled/config/locales/new/sub_sup.en.yml new file mode 100644 index 000000000..8e689ae05 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/sub_sup.en.yml @@ -0,0 +1,10 @@ +en: + pageflow_scrolled: + inline_editing: + formats: + bold: Bold (Ctrl+B) + italic: Italic (Ctrl+I) + strikethrough: Strikethrough (Ctrl+Shift+S) + underline: Underline (Ctrl+U) + sub: Subscript (Ctrl+;) + sup: Superscript (Ctrl+,) diff --git a/entry_types/scrolled/package/spec/frontend/EditableText-spec.js b/entry_types/scrolled/package/spec/frontend/EditableText-spec.js index 2369ac4d4..b861e5a0f 100644 --- a/entry_types/scrolled/package/spec/frontend/EditableText-spec.js +++ b/entry_types/scrolled/package/spec/frontend/EditableText-spec.js @@ -258,6 +258,24 @@ describe('EditableText', () => { expect(container.querySelector('p')).toHaveTextContent('\uFEFF', {normalizeWhitespace: false}) }); + it('renders sub and sup text', () => { + const value = [{ + type: 'paragraph', + children: [ + {text: 'x'}, + {text: '3', sup: true}, + {text: ' and '}, + {text: 'CO'}, + {text: '2', sub: true} + ] + }]; + + const {container} = render(); + + expect(container.querySelector('sup')).toHaveTextContent('3') + expect(container.querySelector('sub')).toHaveTextContent('2') + }); + it('does not render zero width no break space between two formatted words', () => { const value = [{ type: 'paragraph', diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/marks-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/marks-spec.js new file mode 100644 index 000000000..fc426f739 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/marks-spec.js @@ -0,0 +1,100 @@ +/** @jsx jsx */ +import { + toggleMark, + isMarkActive +} from 'frontend/inlineEditing/EditableText/marks'; + +import {createHyperscript} from 'slate-hyperscript'; + +const h = createHyperscript({ + elements: { + paragraph: {type: 'paragraph'}, + }, +}); + +// Strip meta tags to make deep equality checks work +const jsx = (tagName, attributes, ...children) => { + delete attributes.__self; + delete attributes.__source; + return h(tagName, attributes, ...children); +} + +describe('isMarkActive', () => { + it('returns true if current node has mark', () => { + const editor = ( + + + Line 1 + + + ); + + expect(isMarkActive(editor, 'bold')).toEqual(true); + expect(isMarkActive(editor, 'italic')).toEqual(false); + }); +}); + +describe('toggleMark', () => { + it('adds mark', () => { + const editor = ( + + + Some text + + + ); + + toggleMark(editor, 'bold'); + + const output = ( + + + Some text + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('removes mark', () => { + const editor = ( + + + Some text + + + ); + + toggleMark(editor, 'bold'); + + const output = ( + + + Some text + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('treats sub and sup as mutually exclusive', () => { + const editor = ( + + + Some text + + + ); + + toggleMark(editor, 'sub'); + + const output = ( + + + Some text + + + ); + expect(editor.children).toEqual(output.children); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/EditableText.js b/entry_types/scrolled/package/src/frontend/EditableText.js index 169d0a44d..9099e42c0 100644 --- a/entry_types/scrolled/package/src/frontend/EditableText.js +++ b/entry_types/scrolled/package/src/frontend/EditableText.js @@ -148,5 +148,13 @@ export function renderLeaf({attributes, children, leaf}) { children = {children} } + if (leaf.sub) { + children = {children} + } + + if (leaf.sup) { + children = {children} + } + return {children} } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/HoveringToolbar.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/HoveringToolbar.js index d26e49d91..9433479e9 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/HoveringToolbar.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/HoveringToolbar.js @@ -5,6 +5,7 @@ import {ReactEditor, useSlate} from 'slate-react'; import {Toolbar} from '../Toolbar'; import {useI18n} from '../../i18n'; import {useSelectLinkDestination} from '../useSelectLinkDestination'; +import {isMarkActive, toggleMark} from './marks'; import styles from './index.module.css'; @@ -12,6 +13,8 @@ import BoldIcon from '../images/bold.svg'; import UnderlineIcon from '../images/underline.svg'; import ItalicIcon from '../images/italic.svg'; import StrikethroughIcon from '../images/strikethrough.svg'; +import SubIcon from '../images/sub.svg'; +import SupIcon from '../images/sup.svg'; import LinkIcon from '../images/link.svg'; export function HoveringToolbar({position}) { @@ -88,6 +91,16 @@ function renderToolbar(editor, t, selectLinkDestination) { text: t('pageflow_scrolled.inline_editing.formats.strikethrough'), icon: StrikethroughIcon }, + { + name: 'sub', + text: t('pageflow_scrolled.inline_editing.formats.sub'), + icon: SubIcon + }, + { + name: 'sup', + text: t('pageflow_scrolled.inline_editing.formats.sup'), + icon: SupIcon + }, { name: 'link', text: isButtonActive(editor, 'link') ? @@ -148,18 +161,3 @@ function isLinkActive(editor) { const [link] = Editor.nodes(editor, {match: n => n.type === 'link'}); return !!link; } - -function toggleMark(editor, format) { - const isActive = isMarkActive(editor, format) - - if (isActive) { - Editor.removeMark(editor, format) - } else { - Editor.addMark(editor, format, true) - } -} - -function isMarkActive(editor, format) { - const marks = Editor.marks(editor) - return marks ? marks[format] === true : false -} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js index d7fcb1704..e2382f640 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js @@ -1,4 +1,4 @@ -import React, {useMemo, useEffect} from 'react'; +import React, {useCallback, useMemo, useEffect} from 'react'; import classNames from 'classnames'; import {createEditor, Transforms, Node, Text as SlateText, Range} from 'slate'; import {Slate, Editable, withReact, ReactEditor} from 'slate-react'; @@ -32,6 +32,8 @@ import { renderLeafWithLineBreakDecoration } from './lineBreaks'; +import {useShortcutHandler} from './shortcuts'; + import styles from './index.module.css'; export const EditableText = React.memo(function EditableText({ @@ -54,7 +56,14 @@ export const EditableText = React.memo(function EditableText({ ), [selectionRect] ); + const handleLineBreaks = useLineBreakHandler(editor); + const handleShortcuts = useShortcutHandler(editor); + + const handleKeyDown = useCallback(event => { + handleLineBreaks(event); + handleShortcuts(event); + }, [handleLineBreaks, handleShortcuts]); useEffect(() => { if (autoFocus) { @@ -102,7 +111,7 @@ export const EditableText = React.memo(function EditableText({ diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/marks.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/marks.js new file mode 100644 index 000000000..623b76e49 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/marks.js @@ -0,0 +1,26 @@ +import {Editor} from 'slate'; + +const mutuallyExclusive = { + sup: 'sub', + sub: 'sup' +} + +export function toggleMark(editor, format) { + const isActive = isMarkActive(editor, format) + + if (isActive) { + Editor.removeMark(editor, format) + } else { + if (mutuallyExclusive[format] && + isMarkActive(editor, mutuallyExclusive[format])) { + Editor.removeMark(editor, mutuallyExclusive[format]); + } + + Editor.addMark(editor, format, true) + } +} + +export function isMarkActive(editor, format) { + const marks = Editor.marks(editor) + return marks ? marks[format] === true : false +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/shortcuts.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/shortcuts.js new file mode 100644 index 000000000..30a689b38 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/shortcuts.js @@ -0,0 +1,35 @@ +import {useCallback} from 'react'; +import {toggleMark} from './marks'; + +export function useShortcutHandler(editor) { + return useCallback(event => { + if (!event.ctrlKey) { + return; + } + + if (event.key === 'b') { + event.preventDefault() + toggleMark(editor, 'bold'); + } + else if (event.key === 'i') { + event.preventDefault() + toggleMark(editor, 'italic'); + } + else if (event.key === 'u') { + event.preventDefault() + toggleMark(editor, 'underline'); + } + else if (event.key === 'S') { + event.preventDefault() + toggleMark(editor, 'strikethrough'); + } + else if (event.key === ',') { + event.preventDefault() + toggleMark(editor, 'sup'); + } + else if (event.key === ';') { + event.preventDefault() + toggleMark(editor, 'sub'); + } + }, [editor]); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/images/sub.svg b/entry_types/scrolled/package/src/frontend/inlineEditing/images/sub.svg new file mode 100644 index 000000000..bde2f36a8 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/images/sub.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/images/sup.svg b/entry_types/scrolled/package/src/frontend/inlineEditing/images/sup.svg new file mode 100644 index 000000000..ea64259a0 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/images/sup.svg @@ -0,0 +1 @@ +