Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: General improvements for TypeScript types #883

Merged
merged 1 commit into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion src/extensions/rich-text/rich-text-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<RichTextCodeOptions>({
priority: CODE_EXTENSION_PRIORITY,
excludes: Code.name,
})

export { RichTextCode }

export type { RichTextCodeOptions }
11 changes: 8 additions & 3 deletions src/extensions/rich-text/rich-text-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,22 @@ function linkPasteRule(config: Parameters<typeof markPasteRule>[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<RichTextLinkOptions>({
inclusive: false,
addOptions() {
return {
...this.parent?.(),
openOnClick: 'whenNotEditable' as LinkOptions['openOnClick'],
openOnClick: 'whenNotEditable',
}
},
addAttributes() {
Expand Down Expand Up @@ -117,4 +122,4 @@ const RichTextLink = Link.extend({

export { RichTextLink }

export type { LinkOptions as RichTextLinkOptions }
export type { RichTextLinkOptions }
9 changes: 7 additions & 2 deletions src/extensions/rich-text/rich-text-strikethrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RichTextStrikethroughOptions>({
addKeyboardShortcuts() {
return {
'Mod-Shift-x': () => this.editor.commands.toggleStrike(),
Expand All @@ -15,4 +20,4 @@ const RichTextStrikethrough = Strike.extend({

export { RichTextStrikethrough }

export type { StrikeOptions as RichTextStrikethroughOptions }
export type { RichTextStrikethroughOptions }
119 changes: 68 additions & 51 deletions src/factories/create-suggestion-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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<SuggestionItemType> = {
type SuggestionRendererProps<TSuggestionItem> = {
/**
* 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<TSuggestionItem>['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<TSuggestionItem, SuggestionNodeAttributes>['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
Expand All @@ -55,11 +56,11 @@ type SuggestionRendererRef = {
/**
* The options available to customize the extension created by the factory function.
*/
type SuggestionOptions<SuggestionItemType> = {
type SuggestionOptions<TSuggestionItem> = {
/**
* The character that triggers the autocomplete dropdown.
*/
triggerChar: '@' | '#' | '+'
triggerChar: string

/**
* Allows or disallows spaces in suggested items.
Expand All @@ -79,46 +80,46 @@ type SuggestionOptions<SuggestionItemType> = {
/**
* 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<SuggestionItemType>['render']
dropdownRenderFn?: CoreSuggestionOptions<TSuggestionItem>['render']

/**
* The event handler that is fired when the search string has changed.
*/
onSearchChange?: (
query: string,
storage: SuggestionStorage<SuggestionItemType>,
) => SuggestionItemType[] | Promise<SuggestionItemType[]>
storage: SuggestionStorage<TSuggestionItem>,
) => TSuggestionItem[] | Promise<TSuggestionItem[]>

/**
* 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<SuggestionItemType> = Readonly<{
type SuggestionStorage<TSuggestionItem> = 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<SuggestionItemType> = Node<SuggestionOptions<SuggestionItemType>>
type SuggestionExtensionResult<TSuggestionItem> = Node<SuggestionOptions<TSuggestionItem>>

/**
* A factory function responsible for creating different types of suggestion extensions with
Expand All @@ -131,30 +132,40 @@ type SuggestionExtensionResult<SuggestionItemType> = Node<SuggestionOptions<Sugg
* specify the source item type, and use the optional `attributesMapping` option to map the
* source properties to the internal `data-id` and `data-label` attributes.
*
* This factory function also stores the suggestion items internally in the editor storage (as-is,
* and indexed by an identifier), as a way to make sure that if a previously referenced suggestion
* changes its label, the editor will always render the most up-to-date label for the suggestion by
* reading it from the storage. An example use case for this is when a user mention is added to the
* editor, and the user changed its name afterwards, the editor will always render the most
* up-to-date user name for the mention.
*
* @param type A unique identifier for the suggestion extension type.
* @param items An array of suggestion items to be stored in the editor storage.
* @param attributesMapping An object to map the `data-id` and `data-label` attributes with the
* source item type properties.
*
* @returns A new suggestion extension tailored to a specific use case.
*/
function createSuggestionExtension<
SuggestionItemType extends { [id: SuggestionAttributes['id']]: unknown } = SuggestionAttributes,
TSuggestionItem extends {
[id: SuggestionNodeAttributes['id']]: unknown
} = SuggestionNodeAttributes,
>(
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<SuggestionItemType, SuggestionAttributes['id']>
label: ConditionalKeys<SuggestionItemType, SuggestionAttributes['label']>
id: ConditionalKeys<TSuggestionItem, SuggestionNodeAttributes['id']>
label: ConditionalKeys<TSuggestionItem, SuggestionNodeAttributes['label']>
}>,
]
): SuggestionExtensionResult<SuggestionItemType> {
): SuggestionExtensionResult<TSuggestionItem> {
// 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`
Expand All @@ -167,10 +178,7 @@ function createSuggestionExtension<
const labelAttribute = String(attributesMapping[0]?.label ?? 'label')

// Create a personalized suggestion extension
return Node.create<
SuggestionOptions<SuggestionItemType>,
SuggestionStorage<SuggestionItemType>
>({
return Node.create<SuggestionOptions<TSuggestionItem>, SuggestionStorage<TSuggestionItem>>({
name: nodeType,
priority: SUGGESTION_EXTENSION_PRIORITY,
inline: true,
Expand Down Expand Up @@ -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),
}),
},
}
Expand All @@ -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<SuggestionItemType, SuggestionItemType>({
TiptapSuggestion<TSuggestionItem, SuggestionNodeAttributes>({
pluginKey: new PluginKey(nodeType),
editor: this.editor,
char: triggerChar,
Expand All @@ -266,7 +279,7 @@ function createSuggestionExtension<
return (
onSearchChange?.(
query,
editor.storage[nodeType] as SuggestionStorage<SuggestionItemType>,
editor.storage[nodeType] as SuggestionStorage<TSuggestionItem>,
) || []
)
},
Expand Down Expand Up @@ -299,7 +312,11 @@ function createSuggestionExtension<
])
.run()

onItemSelect?.(props)
const item = storage.itemsById[props.id]

if (item) {
onItemSelect?.(item)
}
},
render: dropdownRenderFn,
}),
Expand Down
Loading
Loading