Commit fb95af45 authored by Robert Knight's avatar Robert Knight

Add missing types to MarkdownEditor

 - Add missing types in MarkdownEditor implementation

 - Simplify `onEditText` prop to take a single `text` string argument
   instead of an object with a `text` property
parent f9bb654a
...@@ -107,7 +107,8 @@ function AnnotationEditor({ ...@@ -107,7 +107,8 @@ function AnnotationEditor({
); );
const onEditText = useCallback( const onEditText = useCallback(
({ text }) => { /** @param {string} text */
text => {
store.createDraft(draft.annotation, { ...draft, text }); store.createDraft(draft.annotation, { ...draft, text });
}, },
[draft, store] [draft, store]
......
...@@ -98,7 +98,7 @@ describe('AnnotationEditor', () => { ...@@ -98,7 +98,7 @@ describe('AnnotationEditor', () => {
const editor = wrapper.find('MarkdownEditor'); const editor = wrapper.find('MarkdownEditor');
act(() => { act(() => {
editor.props().onEditText({ text: 'updated text' }); editor.props().onEditText('updated text');
}); });
const call = fakeStore.createDraft.getCall(0); const call = fakeStore.createDraft.getCall(0);
......
...@@ -19,39 +19,63 @@ import { isMacOS } from '../../shared/user-agent'; ...@@ -19,39 +19,63 @@ import { isMacOS } from '../../shared/user-agent';
import MarkdownView from './MarkdownView'; import MarkdownView from './MarkdownView';
/**
* @template T
* @typedef {import('preact').RefObject<T>} Ref
*/
/** /**
* @typedef {import("@hypothesis/frontend-shared/lib/components/Link").LinkProps} LinkProps * @typedef {import("@hypothesis/frontend-shared/lib/components/Link").LinkProps} LinkProps
* @typedef {import('preact').JSX.HTMLAttributes<HTMLTextAreaElement>} TextAreaAttributes * @typedef {import('preact').JSX.HTMLAttributes<HTMLTextAreaElement>} TextAreaAttributes
* @typedef {import('preact').Ref<HTMLTextAreaElement>} TextAreaRef * @typedef {import('../markdown-commands').EditorState} EditorState
*/
/**
* Toolbar commands that modify the editor state. This excludes the Help link
* and Preview buttons.
* *
* @typedef {'bold'|'italic'|'quote'|'link'|'image'|'math'|'numlist'|'list'|'preview'|'help'} ButtonID * @typedef {'bold'|
* 'image'|
* 'italic'|
* 'link'|
* 'list' |
* 'math'|
* 'numlist'|
* 'quote'
* } Command
*/ */
// Mapping of toolbar command name to key for Ctrl+<key> keyboard shortcuts. /**
// The shortcuts are taken from Stack Overflow's editor. * Mapping of toolbar command name to key for Ctrl+<key> keyboard shortcuts.
* The shortcuts are taken from Stack Overflow's editor.
*
* @type {Record<Command, string>}
*/
const SHORTCUT_KEYS = { const SHORTCUT_KEYS = {
bold: 'b', bold: 'b',
image: 'g',
italic: 'i', italic: 'i',
link: 'l', link: 'l',
quote: 'q',
image: 'g',
numlist: 'o',
list: 'u', list: 'u',
math: 'm',
numlist: 'o',
quote: 'q',
}; };
/** /**
* Apply a toolbar command to an editor input field. * Apply a toolbar command to an editor input field.
* *
* @param {string} command * @param {Command} command
* @param {HTMLInputElement|HTMLTextAreaElement} inputEl * @param {HTMLInputElement|HTMLTextAreaElement} inputEl
*/ */
function handleToolbarCommand(command, inputEl) { function handleToolbarCommand(command, inputEl) {
/** @param {(prevState: EditorState) => EditorState} newStateFn */
const update = newStateFn => { const update = newStateFn => {
// Apply the toolbar command to the current state of the input field. // Apply the toolbar command to the current state of the input field.
const newState = newStateFn({ const newState = newStateFn({
text: inputEl.value, text: inputEl.value,
selectionStart: inputEl.selectionStart, selectionStart: /** @type {number} */ (inputEl.selectionStart),
selectionEnd: inputEl.selectionEnd, selectionEnd: /** @type {number} */ (inputEl.selectionEnd),
}); });
// Update the input field to match the new state. // Update the input field to match the new state.
...@@ -63,6 +87,7 @@ function handleToolbarCommand(command, inputEl) { ...@@ -63,6 +87,7 @@ function handleToolbarCommand(command, inputEl) {
inputEl.focus(); inputEl.focus();
}; };
/** @param {EditorState} state */
const insertMath = state => { const insertMath = state => {
const before = state.text.slice(0, state.selectionStart); const before = state.text.slice(0, state.selectionStart);
if ( if (
...@@ -131,7 +156,7 @@ function IconLink({ classes, icon, linkRef, ...restProps }) { ...@@ -131,7 +156,7 @@ function IconLink({ classes, icon, linkRef, ...restProps }) {
/** /**
* @typedef ToolbarButtonProps * @typedef ToolbarButtonProps
* @prop {object} buttonRef * @prop {import('preact').Ref<HTMLButtonElement>} buttonRef
* @prop {boolean} [disabled] * @prop {boolean} [disabled]
* @prop {string} [iconName] * @prop {string} [iconName]
* @prop {string} [label] * @prop {string} [label]
...@@ -192,8 +217,7 @@ function ToolbarButton({ ...@@ -192,8 +217,7 @@ function ToolbarButton({
} }
/** /**
* * @param {TextAreaAttributes & { classes?: string, containerRef?: Ref<HTMLTextAreaElement> }} props
* @param {TextAreaAttributes & { classes?: string, containerRef?: TextAreaRef }} props
*/ */
function TextArea({ classes, containerRef, ...restProps }) { function TextArea({ classes, containerRef, ...restProps }) {
return ( return (
...@@ -213,8 +237,8 @@ function TextArea({ classes, containerRef, ...restProps }) { ...@@ -213,8 +237,8 @@ function TextArea({ classes, containerRef, ...restProps }) {
/** /**
* @typedef ToolbarProps * @typedef ToolbarProps
* @prop {boolean} isPreviewing - `true` if the editor's "Preview" mode is active. * @prop {boolean} isPreviewing - `true` if the editor's "Preview" mode is active.
* @prop {(a: ButtonID) => any} onCommand - Callback invoked with the selected command when a toolbar button is clicked. * @prop {(a: Command) => void} onCommand - Callback invoked when a toolbar button is clicked.
* @prop {() => any} onTogglePreview - Callback invoked when the "Preview" toggle button is clicked. * @prop {() => void} onTogglePreview - Callback invoked when the "Preview" toggle button is clicked.
*/ */
/** /**
...@@ -249,7 +273,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -249,7 +273,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
// is set to 0, and all other elements are set to -1. // is set to 0, and all other elements are set to -1.
const [rovingElement, setRovingElement] = useState(0); const [rovingElement, setRovingElement] = useState(0);
// An array of refs /** @type {Ref<HTMLElement>[]} */
const buttonRefs = useRef([]).current; const buttonRefs = useRef([]).current;
if (buttonRefs.length === 0) { if (buttonRefs.length === 0) {
// Initialize buttonRefs on first render only // Initialize buttonRefs on first render only
...@@ -258,6 +282,15 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -258,6 +282,15 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
} }
} }
/**
* @template {HTMLElement} ElementType
* @param {keyof buttonIds} buttonId
* @return {Ref<ElementType>}
*/
function getRef(buttonId) {
return /** @type {Ref<ElementType>} */ (buttonRefs[buttonIds[buttonId]]);
}
/** /**
* Sets the element to be both focused and the active roving index. * Sets the element to be both focused and the active roving index.
* *
...@@ -265,7 +298,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -265,7 +298,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
*/ */
const setFocusedElement = index => { const setFocusedElement = index => {
setRovingElement(index); setRovingElement(index);
buttonRefs[index].current.focus(); /** @type {HTMLElement} */ (buttonRefs[index].current).focus();
}; };
/** /**
...@@ -348,7 +381,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -348,7 +381,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-bold" iconName="format-bold"
onClick={() => onCommand('bold')} onClick={() => onCommand('bold')}
shortcutKey={SHORTCUT_KEYS.bold} shortcutKey={SHORTCUT_KEYS.bold}
buttonRef={buttonRefs[buttonIds.bold]} buttonRef={getRef('bold')}
tabIndex={getTabIndex(buttonIds.bold)} tabIndex={getTabIndex(buttonIds.bold)}
title="Bold" title="Bold"
/> />
...@@ -357,7 +390,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -357,7 +390,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-italic" iconName="format-italic"
onClick={() => onCommand('italic')} onClick={() => onCommand('italic')}
shortcutKey={SHORTCUT_KEYS.italic} shortcutKey={SHORTCUT_KEYS.italic}
buttonRef={buttonRefs[buttonIds.italic]} buttonRef={getRef('italic')}
tabIndex={getTabIndex(buttonIds.italic)} tabIndex={getTabIndex(buttonIds.italic)}
title="Italic" title="Italic"
/> />
...@@ -366,7 +399,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -366,7 +399,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-quote" iconName="format-quote"
onClick={() => onCommand('quote')} onClick={() => onCommand('quote')}
shortcutKey={SHORTCUT_KEYS.quote} shortcutKey={SHORTCUT_KEYS.quote}
buttonRef={buttonRefs[buttonIds.quote]} buttonRef={getRef('quote')}
tabIndex={getTabIndex(buttonIds.quote)} tabIndex={getTabIndex(buttonIds.quote)}
title="Quote" title="Quote"
/> />
...@@ -375,7 +408,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -375,7 +408,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="link" iconName="link"
onClick={() => onCommand('link')} onClick={() => onCommand('link')}
shortcutKey={SHORTCUT_KEYS.link} shortcutKey={SHORTCUT_KEYS.link}
buttonRef={buttonRefs[buttonIds.link]} buttonRef={getRef('link')}
tabIndex={getTabIndex(buttonIds.link)} tabIndex={getTabIndex(buttonIds.link)}
title="Insert link" title="Insert link"
/> />
...@@ -384,7 +417,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -384,7 +417,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="image" iconName="image"
onClick={() => onCommand('image')} onClick={() => onCommand('image')}
shortcutKey={SHORTCUT_KEYS.image} shortcutKey={SHORTCUT_KEYS.image}
buttonRef={buttonRefs[buttonIds.image]} buttonRef={getRef('image')}
tabIndex={getTabIndex(buttonIds.image)} tabIndex={getTabIndex(buttonIds.image)}
title="Insert image" title="Insert image"
/> />
...@@ -392,7 +425,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -392,7 +425,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
disabled={isPreviewing} disabled={isPreviewing}
iconName="format-functions" iconName="format-functions"
onClick={() => onCommand('math')} onClick={() => onCommand('math')}
buttonRef={buttonRefs[buttonIds.math]} buttonRef={getRef('math')}
tabIndex={getTabIndex(buttonIds.math)} tabIndex={getTabIndex(buttonIds.math)}
title="Insert math (LaTeX is supported)" title="Insert math (LaTeX is supported)"
/> />
...@@ -401,7 +434,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -401,7 +434,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-list-numbered" iconName="format-list-numbered"
onClick={() => onCommand('numlist')} onClick={() => onCommand('numlist')}
shortcutKey={SHORTCUT_KEYS.numlist} shortcutKey={SHORTCUT_KEYS.numlist}
buttonRef={buttonRefs[buttonIds.numlist]} buttonRef={getRef('numlist')}
tabIndex={getTabIndex(buttonIds.numlist)} tabIndex={getTabIndex(buttonIds.numlist)}
title="Numbered list" title="Numbered list"
/> />
...@@ -410,7 +443,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -410,7 +443,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-list-unordered" iconName="format-list-unordered"
onClick={() => onCommand('list')} onClick={() => onCommand('list')}
shortcutKey={SHORTCUT_KEYS.list} shortcutKey={SHORTCUT_KEYS.list}
buttonRef={buttonRefs[buttonIds.list]} buttonRef={getRef('list')}
tabIndex={getTabIndex(buttonIds.list)} tabIndex={getTabIndex(buttonIds.list)}
title="Bulleted list" title="Bulleted list"
/> />
...@@ -424,7 +457,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -424,7 +457,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
href="https://web.hypothes.is/help/formatting-annotations-with-markdown/" href="https://web.hypothes.is/help/formatting-annotations-with-markdown/"
icon="help" icon="help"
target="_blank" target="_blank"
linkRef={buttonRefs[buttonIds.help]} linkRef={getRef('help')}
tabIndex={getTabIndex(buttonIds.help)} tabIndex={getTabIndex(buttonIds.help)}
title="Formatting help" title="Formatting help"
aria-label="Formatting help" aria-label="Formatting help"
...@@ -432,7 +465,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -432,7 +465,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
<ToolbarButton <ToolbarButton
label={isPreviewing ? 'Write' : 'Preview'} label={isPreviewing ? 'Write' : 'Preview'}
onClick={onTogglePreview} onClick={onTogglePreview}
buttonRef={buttonRefs[buttonIds.preview]} buttonRef={getRef('preview')}
tabIndex={getTabIndex(buttonIds.preview)} tabIndex={getTabIndex(buttonIds.preview)}
/> />
</div> </div>
...@@ -446,7 +479,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -446,7 +479,7 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
* @prop {Record<string,string>} [textStyle] - * @prop {Record<string,string>} [textStyle] -
* Additional CSS properties to apply to the input field and rendered preview * Additional CSS properties to apply to the input field and rendered preview
* @prop {string} [text] - The markdown text to edit. * @prop {string} [text] - The markdown text to edit.
* @prop {({text: string}) => void} [onEditText] * @prop {(text: string) => void} [onEditText]
* - Callback invoked with `{ text }` object when user edits text. * - Callback invoked with `{ text }` object when user edits text.
* TODO: Simplify this callback to take just a string rather than an object once the * TODO: Simplify this callback to take just a string rather than an object once the
* parent component is converted to Preact. * parent component is converted to Preact.
...@@ -476,13 +509,16 @@ export default function MarkdownEditor({ ...@@ -476,13 +509,16 @@ export default function MarkdownEditor({
}, [preview]); }, [preview]);
const togglePreview = () => setPreview(!preview); const togglePreview = () => setPreview(!preview);
/** @param {Command} command */
const handleCommand = command => { const handleCommand = command => {
if (input.current) { if (input.current) {
handleToolbarCommand(command, input.current); handleToolbarCommand(command, input.current);
onEditText({ text: input.current.value }); onEditText(input.current.value);
} }
}; };
/** @param {KeyboardEvent} event */
const handleKeyDown = event => { const handleKeyDown = event => {
if (!event.ctrlKey && !event.metaKey) { if (!event.ctrlKey && !event.metaKey) {
return; return;
...@@ -492,7 +528,7 @@ export default function MarkdownEditor({ ...@@ -492,7 +528,7 @@ export default function MarkdownEditor({
if (key === normalizeKeyName(event.key)) { if (key === normalizeKeyName(event.key)) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
handleCommand(command); handleCommand(/** @type {Command} */ (command));
} }
} }
}; };
...@@ -524,11 +560,9 @@ export default function MarkdownEditor({ ...@@ -524,11 +560,9 @@ export default function MarkdownEditor({
containerRef={input} containerRef={input}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onInput={e => { onInput={e =>
onEditText({ onEditText(/** @type {HTMLTextAreaElement} */ (e.target).value)
text: /** @type {HTMLTextAreaElement} */ (e.target).value, }
});
}}
value={text} value={text}
style={textStyle} style={textStyle}
/> />
......
...@@ -119,9 +119,7 @@ describe('MarkdownEditor', () => { ...@@ -119,9 +119,7 @@ describe('MarkdownEditor', () => {
button.simulate('click'); button.simulate('click');
assert.calledWith(onEditText, { assert.calledWith(onEditText, 'formatted text');
text: 'formatted text',
});
const [formatFunction, ...args] = effect; const [formatFunction, ...args] = effect;
assert.calledWith( assert.calledWith(
formatFunction, formatFunction,
...@@ -184,9 +182,7 @@ describe('MarkdownEditor', () => { ...@@ -184,9 +182,7 @@ describe('MarkdownEditor', () => {
key: keyEvent.key, key: keyEvent.key,
}); });
assert.calledWith(onEditText, { assert.calledWith(onEditText, 'formatted text');
text: 'formatted text',
});
const [formatFunction, ...args] = effect; const [formatFunction, ...args] = effect;
assert.calledWith( assert.calledWith(
formatFunction, formatFunction,
...@@ -235,9 +231,7 @@ describe('MarkdownEditor', () => { ...@@ -235,9 +231,7 @@ describe('MarkdownEditor', () => {
const input = wrapper.find('textarea').getDOMNode(); const input = wrapper.find('textarea').getDOMNode();
input.value = 'changed'; input.value = 'changed';
wrapper.find('textarea').simulate('input'); wrapper.find('textarea').simulate('input');
assert.calledWith(onEditText, { assert.calledWith(onEditText, 'changed');
text: 'changed',
});
}); });
it('enters preview mode when Preview button is clicked', () => { it('enters preview mode when Preview button is clicked', () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment