diff --git a/package-lock.json b/package-lock.json index d4aff9cb..1200c38b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,7 @@ "react-icons": "5.3.0", "react-markdown": "9.0.1", "react-syntax-highlighter": "15.5.0", + "react-use-event-hook": "0.9.6", "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "rimraf": "6.0.1", @@ -19968,6 +19969,15 @@ "react": ">= 0.14.0" } }, + "node_modules/react-use-event-hook": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/react-use-event-hook/-/react-use-event-hook-0.9.6.tgz", + "integrity": "sha512-wNchkFrrlz64QQOJOf5+dFjy+27bJEwtS1SgliRXVsU60wo3pSntNRlwDws2qPHwrAP/wlm5IyQHBlPCya0JIQ==", + "dev": true, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/read-package-json-fast": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", diff --git a/package.json b/package.json index f815f1f2..076614e9 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "react-icons": "5.3.0", "react-markdown": "9.0.1", "react-syntax-highlighter": "15.5.0", + "react-use-event-hook": "0.9.6", "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "rimraf": "6.0.1", diff --git a/src/extensions/rich-text/rich-text-code.ts b/src/extensions/rich-text/rich-text-code.ts index c97a6c80..3e5066a9 100644 --- a/src/extensions/rich-text/rich-text-code.ts +++ b/src/extensions/rich-text/rich-text-code.ts @@ -2,6 +2,13 @@ import { Code } from '@tiptap/extension-code' import { CODE_EXTENSION_PRIORITY } from '../../constants/extension-priorities' +import type { CodeOptions } from '@tiptap/extension-code' + +/** + * The options available to customize the `RichTextCode` extension. + */ +type RichTextCodeOptions = CodeOptions + /** * Custom extension that extends the built-in `Code` extension to allow all marks (e.g., Bold, * Italic, and Strikethrough) to coexist with the `Code` mark (as opposed to disallowing all any @@ -10,9 +17,11 @@ import { CODE_EXTENSION_PRIORITY } from '../../constants/extension-priorities' * @see https://tiptap.dev/api/schema#excludes * @see https://prosemirror.net/docs/ref/#model.MarkSpec.excludes */ -const RichTextCode = Code.extend({ +const RichTextCode = Code.extend({ priority: CODE_EXTENSION_PRIORITY, excludes: Code.name, }) export { RichTextCode } + +export type { RichTextCodeOptions } diff --git a/src/extensions/rich-text/rich-text-link.ts b/src/extensions/rich-text/rich-text-link.ts index 9702e55c..b9dca423 100644 --- a/src/extensions/rich-text/rich-text-link.ts +++ b/src/extensions/rich-text/rich-text-link.ts @@ -56,17 +56,22 @@ function linkPasteRule(config: Parameters[0]) { }) } +/** + * The options available to customize the `RichTextLink` extension. + */ +type RichTextLinkOptions = LinkOptions + /** * Custom extension that extends the built-in `Link` extension to add additional input/paste rules * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also * adds support for the `title` attribute. */ -const RichTextLink = Link.extend({ +const RichTextLink = Link.extend({ inclusive: false, addOptions() { return { ...this.parent?.(), - openOnClick: 'whenNotEditable' as LinkOptions['openOnClick'], + openOnClick: 'whenNotEditable', } }, addAttributes() { @@ -117,4 +122,4 @@ const RichTextLink = Link.extend({ export { RichTextLink } -export type { LinkOptions as RichTextLinkOptions } +export type { RichTextLinkOptions } diff --git a/src/extensions/rich-text/rich-text-strikethrough.ts b/src/extensions/rich-text/rich-text-strikethrough.ts index 7d4b6bed..237f28ef 100644 --- a/src/extensions/rich-text/rich-text-strikethrough.ts +++ b/src/extensions/rich-text/rich-text-strikethrough.ts @@ -2,10 +2,15 @@ import { Strike } from '@tiptap/extension-strike' import type { StrikeOptions } from '@tiptap/extension-strike' +/** + * The options available to customize the `RichTextStrikethrough` extension. + */ +type RichTextStrikethroughOptions = StrikeOptions + /** * Custom extension that extends the built-in `Strike` extension to overwrite the default keyboard. */ -const RichTextStrikethrough = Strike.extend({ +const RichTextStrikethrough = Strike.extend({ addKeyboardShortcuts() { return { 'Mod-Shift-x': () => this.editor.commands.toggleStrike(), @@ -15,4 +20,4 @@ const RichTextStrikethrough = Strike.extend({ export { RichTextStrikethrough } -export type { StrikeOptions as RichTextStrikethroughOptions } +export type { RichTextStrikethroughOptions } diff --git a/src/factories/create-suggestion-extension.ts b/src/factories/create-suggestion-extension.ts index ce778836..77093a02 100644 --- a/src/factories/create-suggestion-extension.ts +++ b/src/factories/create-suggestion-extension.ts @@ -10,13 +10,14 @@ import { canInsertSuggestion } from '../utilities/can-insert-suggestion' import type { SuggestionKeyDownProps as CoreSuggestionKeyDownProps, SuggestionOptions as CoreSuggestionOptions, + SuggestionProps as CoreSuggestionProps, } from '@tiptap/suggestion' import type { ConditionalKeys, RequireAtLeastOne } from 'type-fest' /** - * The properties that describe the suggestion node attributes. + * A type that describes the suggestion node attributes. */ -type SuggestionAttributes = { +type SuggestionNodeAttributes = { /** * The suggestion node unique identifier to be rendered by the editor as a `data-id` attribute. */ @@ -30,23 +31,23 @@ type SuggestionAttributes = { } /** - * The properties that describe the minimal props that an autocomplete dropdown must receive. + * A type that describes the minimal props that an autocomplete dropdown must receive. */ -type SuggestionRendererProps = { +type SuggestionRendererProps = { /** - * The function that must be invoked when a suggestion item is selected. + * The list of suggestion items to be rendered by the autocomplete dropdown. */ - command: (item: SuggestionItemType) => void + items: CoreSuggestionProps['items'] /** - * The list of suggestion items to be rendered by the autocomplete dropdown. + * The function that must be invoked when a suggestion item is selected. */ - items: SuggestionItemType[] + command: CoreSuggestionProps['command'] } /** * A type that describes the forwarded ref that an autocomplete dropdown must implement with - * `useImperativeHandle` to receive `keyDown` events from the render function. + * `useImperativeHandle` to handle `keydown` events in the dropdown render function. */ type SuggestionRendererRef = { onKeyDown: (props: CoreSuggestionKeyDownProps) => boolean @@ -55,11 +56,11 @@ type SuggestionRendererRef = { /** * The options available to customize the extension created by the factory function. */ -type SuggestionOptions = { +type SuggestionOptions = { /** * The character that triggers the autocomplete dropdown. */ - triggerChar: '@' | '#' | '+' + triggerChar: string /** * Allows or disallows spaces in suggested items. @@ -79,46 +80,46 @@ type SuggestionOptions = { /** * Define how the suggestion item `aria-label` attribute should be rendered. */ - renderAriaLabel?: (attrs: SuggestionAttributes) => string + renderAriaLabel?: (attrs: SuggestionNodeAttributes) => string /** * A render function for the autocomplete dropdown. */ - dropdownRenderFn?: CoreSuggestionOptions['render'] + dropdownRenderFn?: CoreSuggestionOptions['render'] /** * The event handler that is fired when the search string has changed. */ onSearchChange?: ( query: string, - storage: SuggestionStorage, - ) => SuggestionItemType[] | Promise + storage: SuggestionStorage, + ) => TSuggestionItem[] | Promise /** * The event handler that is fired when a suggestion item is selected. */ - onItemSelect?: (item: SuggestionItemType) => void + onItemSelect?: (item: TSuggestionItem) => void } /** * The storage holding the suggestion items original array, and a collection indexed by the item id. */ -type SuggestionStorage = Readonly<{ +type SuggestionStorage = Readonly<{ /** * The original array of suggestion items. */ - items: SuggestionItemType[] + items: TSuggestionItem[] /** * A collection of suggestion items indexed by the item id. */ - itemsById: { readonly [id: SuggestionAttributes['id']]: SuggestionItemType | undefined } + itemsById: { readonly [id: SuggestionNodeAttributes['id']]: TSuggestionItem | undefined } }> /** * The return type for a suggestion extension created by the factory function. */ -type SuggestionExtensionResult = Node> +type SuggestionExtensionResult = Node> /** * A factory function responsible for creating different types of suggestion extensions with @@ -131,30 +132,40 @@ type SuggestionExtensionResult = Node( type: string, - items: SuggestionItemType[] = [], + items: TSuggestionItem[] = [], // This type makes sure that if a generic type variable is specified, the `attributesMapping` // is also defined (and vice versa) along with making sure that at least one attribute is // specified, and that all constraints are satisfied. - ...attributesMapping: SuggestionItemType extends SuggestionAttributes + ...attributesMapping: TSuggestionItem extends SuggestionNodeAttributes ? [] : [ RequireAtLeastOne<{ - id: ConditionalKeys - label: ConditionalKeys + id: ConditionalKeys + label: ConditionalKeys }>, ] -): SuggestionExtensionResult { +): SuggestionExtensionResult { // Normalize the node type and add the `Suggestion` suffix so that it can be easily identified // when parsing the editor schema programatically (useful for Markdown/HTML serialization) const nodeType = `${camelCase(type)}Suggestion` @@ -167,10 +178,7 @@ function createSuggestionExtension< const labelAttribute = String(attributesMapping[0]?.label ?? 'label') // Create a personalized suggestion extension - return Node.create< - SuggestionOptions, - SuggestionStorage - >({ + return Node.create, SuggestionStorage>({ name: nodeType, priority: SUGGESTION_EXTENSION_PRIORITY, inline: true, @@ -198,25 +206,27 @@ function createSuggestionExtension< }, addAttributes() { return { - [idAttribute]: { + id: { default: null, parseHTML: (element) => element.getAttribute('data-id'), renderHTML: (attributes) => ({ - 'data-id': String(attributes[idAttribute]), + 'data-id': String(attributes.id), }), }, - [labelAttribute]: { + label: { default: null, parseHTML: (element: Element) => { const id = String(element.getAttribute('data-id')) const item = this.storage.itemsById[id] - // Read the latest item label from the storage, if available, otherwise - // fallback to the item label in the `data-label` attribute + // Attempt to read the item label from the storage first (as a way to make + // sure that a previously referenced suggestion always renders the most + // up-to-date label for the suggestion), and fallback to the `data-label` + // attribute if the item is not found in the storage return String(item?.[labelAttribute] ?? element.getAttribute('data-label')) }, renderHTML: (attributes) => ({ - 'data-label': String(attributes[labelAttribute]), + 'data-label': String(attributes.label), }), }, } @@ -231,31 +241,34 @@ function createSuggestionExtension< { [`data-${attributeType}`]: '', 'aria-label': this.options.renderAriaLabel?.({ - id: String(node.attrs[idAttribute]), - label: String(node.attrs[labelAttribute]), + id: String(node.attrs.id), + label: String(node.attrs.label), }), }, HTMLAttributes, ), - `${String(this.options.triggerChar)}${String(node.attrs[labelAttribute])}`, + `${String(this.options.triggerChar)}${String(node.attrs.label)}`, ] }, renderText({ node }) { - return `${String(this.options.triggerChar)}${String(node.attrs[labelAttribute])}` + return `${String(this.options.triggerChar)}${String(node.attrs.label)}` }, addProseMirrorPlugins() { const { - triggerChar, - allowSpaces, - allowedPrefixes, - startOfLine, - onSearchChange, - onItemSelect, - dropdownRenderFn, - } = this.options + options: { + triggerChar, + allowSpaces, + allowedPrefixes, + startOfLine, + onSearchChange, + onItemSelect, + dropdownRenderFn, + }, + storage, + } = this return [ - TiptapSuggestion({ + TiptapSuggestion({ pluginKey: new PluginKey(nodeType), editor: this.editor, char: triggerChar, @@ -266,7 +279,7 @@ function createSuggestionExtension< return ( onSearchChange?.( query, - editor.storage[nodeType] as SuggestionStorage, + editor.storage[nodeType] as SuggestionStorage, ) || [] ) }, @@ -299,7 +312,11 @@ function createSuggestionExtension< ]) .run() - onItemSelect?.(props) + const item = storage.itemsById[props.id] + + if (item) { + onItemSelect?.(item) + } }, render: dropdownRenderFn, }), diff --git a/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.tsx b/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.tsx index bfe4b4c2..093cc7d4 100644 --- a/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.tsx +++ b/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.tsx @@ -22,7 +22,7 @@ type TypistEditorDecoratorProps = { Story: PartialStoryFn args: TypistEditorProps withToolbar?: boolean - renderBottomFunctions?: () => JSX.Element + renderBottomFunctions?: () => React.ReactElement } const TypistEditorDecorator = forwardRef( diff --git a/stories/typist-editor/extensions/suggestions/base-suggestion-dropdown.tsx b/stories/typist-editor/extensions/suggestions/base-suggestion-dropdown.tsx index 71408e23..4bc921be 100644 --- a/stories/typist-editor/extensions/suggestions/base-suggestion-dropdown.tsx +++ b/stories/typist-editor/extensions/suggestions/base-suggestion-dropdown.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { useEffect, useImperativeHandle, useRef, useState } from 'react' import { Box, Inline, Text } from '@doist/reactist' @@ -6,21 +6,21 @@ import { SuggestionRendererRef } from '../../../../src' import styles from './base-suggestion-dropdown.module.css' -type BaseSuggestionDropdownProps = { +type BaseSuggestionDropdownProps = { forwardedRef: React.ForwardedRef - items: TItem[] + items: TSuggestionItem[] itemSize?: number - renderItem: (item: TItem) => JSX.Element - onItemSelect: (item: TItem) => void + renderItem: (item: TSuggestionItem) => React.ReactElement + onItemSelect: (index: number) => void } -function BaseSuggestionDropdown({ +function BaseSuggestionDropdown({ forwardedRef, items, itemSize = 6, renderItem, onItemSelect, -}: BaseSuggestionDropdownProps): JSX.Element | null { +}: BaseSuggestionDropdownProps) { const selectedItemRef = useRef(null) const [selectedIndex, setSelectedIndex] = useState(0) @@ -31,17 +31,6 @@ function BaseSuggestionDropdown({ const areSuggestionsLoading = items.length === 1 && 'isLoading' in items[0] const areSuggestionsEmpty = items.length === 0 - const handleItemSelect = useCallback( - (index: number) => { - const item = items[index] - - if (item) { - onItemSelect(item) - } - }, - [items, onItemSelect], - ) - useEffect( function scrollSelectedItemIntoView() { selectedItemRef.current?.scrollIntoView({ @@ -76,7 +65,7 @@ function BaseSuggestionDropdown({ } if (event.key === 'Enter') { - handleItemSelect(selectedIndex) + onItemSelect(selectedIndex) return true } @@ -84,7 +73,7 @@ function BaseSuggestionDropdown({ }, } }, - [items.length, handleItemSelect, selectedIndex], + [items.length, onItemSelect, selectedIndex], ) return ( @@ -135,7 +124,7 @@ function BaseSuggestionDropdown({ display="flex" alignItems="center" borderRadius="standard" - onClick={() => handleItemSelect(index)} + onClick={() => onItemSelect(index)} ref={index === selectedIndex ? selectedItemRef : null} > {renderItem(item)} diff --git a/stories/typist-editor/extensions/suggestions/hashtag-suggestion-dropdown.tsx b/stories/typist-editor/extensions/suggestions/hashtag-suggestion-dropdown.tsx index 9ae6af1f..af18c8b2 100644 --- a/stories/typist-editor/extensions/suggestions/hashtag-suggestion-dropdown.tsx +++ b/stories/typist-editor/extensions/suggestions/hashtag-suggestion-dropdown.tsx @@ -1,4 +1,5 @@ import { forwardRef } from 'react' +import { useEvent } from 'react-use-event-hook' import { Inline, Text } from '@doist/reactist' @@ -15,11 +16,22 @@ const HashtagSuggestionDropdown = forwardRef< SuggestionRendererRef, SuggestionRendererProps >(function HashtagSuggestionDropdown({ items, command }, ref) { + const handleItemSelect = useEvent((index: number) => { + const item = items[index] as HashtagSuggestionItem | undefined + + if (item) { + command({ + id: item.id, + label: item.name, + }) + } + }) + return ( ( >(function MentionSuggestionDropdown({ items, command }, ref) { + const handleItemSelect = useEvent((index: number) => { + const item = items[index] as MentionSuggestionItem | undefined + + if (item) { + command({ + id: item.uid, + label: item.name, + }) + } + }) + return ( (